Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/bound-execute-result-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@iqai/mcp-debank": patch
---

Bound the `execute` result size so whale-wallet queries can't overflow the consuming model's context window.

The `execute` tool serialized the guest `run()` return value with no size limit and returned it verbatim to the MCP client. For large wallets (e.g. vitalik.eth), whole-portfolio queries — "all token holdings across all chains", "all NFT holdings", "all approvals on Ethereum" — produced payloads of ~500k tokens, which overflowed the consuming agent's context window before the summarizing model call could even run.

There is now a generic size budget enforced at the result boundary (`DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS`, default 200,000 chars ≈ 50k tokens). When a result exceeds it, the envelope metadata (`ok` / `error` / `log_lines` / `err_lines`) is kept intact and the bulk `result` is truncated — arrays keep the first N elements, objects trim their largest arrays, strings keep a prefix — with a structured `_truncated: { shown, total, reason, hint }` signal attached so the consumer can answer "showing top N of M — refine to see more" instead of crashing. Results under budget are returned byte-for-byte unchanged.
11 changes: 11 additions & 0 deletions .changeset/nft-per-chain-fanout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@iqai/mcp-debank": minor
---

Fetch whale NFT holdings by fanning out per-chain instead of the slow server-side aggregate.

`getUserAllNftList` previously called DeBank's `/user/all_nft_list`, which aggregates across every chain server-side and takes 10–20 s for active wallets — routinely timing out to nothing for whales (e.g. vitalik.eth). It now discovers the wallet's used chains (`used_chain_list`) and fans out the fast per-chain `/user/nft_list` calls in parallel with bounded concurrency (8), so wall-clock is ≈ (chains / 8) × ~1.5 s.

The method also degrades gracefully instead of failing all-or-nothing: a chain that errors or is too slow is dropped and reported, and the method enforces its own soft deadline (~75% of the call's timeout budget) so it returns the chains that landed *before* the client's hard abort fires — partial-on-timeout, mirroring the result-size-budget philosophy.

**Return-shape change (guest-facing):** `getUserAllNftList` now resolves to `{ nfts, chains, partial, chains_skipped }` instead of a bare `UserNFT[]`. Read `result.nfts` for the holdings (each NFT is stamped with its `chain`); `partial: true` with `chains_skipped` names the chains to retry (narrow via `chain_ids`, or use `getUserNftList` per chain). The tool description, cookbook, and instructions are updated accordingly.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ Search the embedded MiniSearch index over all DeBank API methods and cookbook en

### Safety limits

The `execute` sandbox enforces five bounds. Defaults are tuned for legitimate Promise.all-style work; the two budget knobs are env-overridable for tests and tightening.
The `execute` sandbox enforces six bounds. Defaults are tuned for legitimate Promise.all-style work; the budget knobs are env-overridable for tests and tightening.

| Limit | Default | Override | Behaviour on hit |
| :--- | :--- | :--- | :--- |
Expand All @@ -342,6 +342,7 @@ The `execute` sandbox enforces five bounds. Defaults are tuned for legitimate Pr
| Calls per `execute` | 100 | `DEBANK_MCP_EXECUTE_BUDGET` | Subsequent guest calls return `"Execute call budget exceeded (100 calls per invocation): <method>"` |
| Concurrent calls | 10 | `DEBANK_MCP_EXECUTE_CONCURRENCY` | Calls queue on a semaphore; no error, just back-pressure |
| Per-call upstream | 5 s abort + 6 s axios | hard-coded | Returns `"DeBank call timed out after 5s: <method>"` |
| Result size | 200,000 chars (~50k tokens) | `DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS` | The bulk `result` is truncated (arrays keep the first N; objects trim their largest arrays; strings keep a prefix) and a `_truncated: { shown, total, reason, hint }` signal is attached. Envelope metadata (`ok` / `error` / `log_lines` / `err_lines`) is always kept, so the consuming model gets a bounded, actionable payload instead of overflowing its context window on a whale-wallet query. |

The sandbox also blocks the source-level identifiers `process.`, `require(`, `import(`, and `eval(` at submission time. These aren't security boundaries (the isolate is) — they're a defense-in-depth check that fails fast with a clear message.

Expand Down
216 changes: 216 additions & 0 deletions src/mcp/execute/result-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import {
boundExecuteResult,
DEFAULT_EXECUTE_RESULT_BUDGET_CHARS,
resolveResultBudgetChars,
} from "./result-budget.js";
import type { SandboxResult } from "./sandbox.js";

const ok = (result: unknown): SandboxResult => ({
ok: true,
result,
log_lines: [],
err_lines: [],
});

describe("boundExecuteResult — pass-through (normal path)", () => {
it("small result is returned byte-identical with no _truncated signal", () => {
const input = ok([1, 2, 3]);
const { text, truncated } = boundExecuteResult(input, 100_000);
expect(truncated).toBe(false);
expect(text).toBe(JSON.stringify(input));
expect(text).not.toContain("_truncated");
});

it("preserves ok/log_lines/err_lines exactly on the small path", () => {
const input: SandboxResult = {
ok: true,
result: { hello: "world" },
log_lines: ["a", "b"],
err_lines: ["oops"],
};
const parsed = JSON.parse(boundExecuteResult(input, 100_000).text);
expect(parsed).toEqual(input);
});
});

describe("boundExecuteResult — array result over budget", () => {
const items = Array.from({ length: 500 }, (_, i) => ({
id: i,
symbol: `TKN${i}`,
amount: i * 1.5,
chain: "eth",
}));
const input: SandboxResult = {
ok: true,
result: items,
log_lines: ["ran ok"],
err_lines: [],
};

it("keeps only the first N and stays within budget", () => {
const budget = 2_000;
const { text, truncated } = boundExecuteResult(input, budget);
expect(truncated).toBe(true);
expect(text.length).toBeLessThanOrEqual(budget);
const parsed = JSON.parse(text);
expect(Array.isArray(parsed.result)).toBe(true);
expect(parsed.result.length).toBeGreaterThan(0);
expect(parsed.result.length).toBeLessThan(items.length);
// kept elements are the ORIGINAL first N, untouched
expect(parsed.result[0]).toEqual(items[0]);
});

it("carries a structured _truncated signal (shown < total, reason, hint)", () => {
const { text } = boundExecuteResult(input, 2_000);
const parsed = JSON.parse(text);
expect(parsed._truncated.total).toBe(items.length);
expect(parsed._truncated.shown).toBe(parsed.result.length);
expect(parsed._truncated.shown).toBeLessThan(parsed._truncated.total);
expect(typeof parsed._truncated.reason).toBe("string");
expect(parsed._truncated.reason.toLowerCase()).toContain("budget");
expect(typeof parsed._truncated.hint).toBe("string");
expect(parsed._truncated.hint.length).toBeGreaterThan(0);
});

it("keeps the small metadata (ok/log_lines) intact", () => {
const { text } = boundExecuteResult(input, 2_000);
const parsed = JSON.parse(text);
expect(parsed.ok).toBe(true);
expect(parsed.log_lines).toEqual(["ran ok"]);
});
});

describe("boundExecuteResult — object-with-arrays over budget", () => {
const tokens = Array.from({ length: 400 }, (_, i) => ({
symbol: `T${i}`,
amount: i,
price: i / 3,
}));
const input: SandboxResult = {
ok: true,
result: {
summary: { chains: 24, total_usd: 987654.32 },
skipped: ["dust-chain"],
holdings: tokens,
},
log_lines: [],
err_lines: [],
};

it("truncates the largest array while preserving scalar metadata", () => {
const budget = 3_000;
const { text, truncated } = boundExecuteResult(input, budget);
expect(truncated).toBe(true);
expect(text.length).toBeLessThanOrEqual(budget);
const parsed = JSON.parse(text);
// scalar/summary metadata preserved intact
expect(parsed.result.summary).toEqual({ chains: 24, total_usd: 987654.32 });
expect(parsed.result.skipped).toEqual(["dust-chain"]);
// the bulk array was trimmed
expect(parsed.result.holdings.length).toBeLessThan(tokens.length);
expect(parsed.result.holdings[0]).toEqual(tokens[0]);
});

it("names the truncated field in _truncated.fields", () => {
const { text } = boundExecuteResult(input, 3_000);
const parsed = JSON.parse(text);
const holdings = parsed._truncated.fields.find(
(f: { field: string }) => f.field === "holdings",
);
expect(holdings).toBeDefined();
expect(holdings.total).toBe(tokens.length);
expect(holdings.shown).toBe(parsed.result.holdings.length);
expect(holdings.shown).toBeLessThan(holdings.total);
});

it("finds arrays nested inside child objects (path is dotted)", () => {
const nested: SandboxResult = ok({
data: {
tokens: Array.from({ length: 400 }, (_, i) => ({ i, s: `S${i}` })),
},
});
const { text } = boundExecuteResult(nested, 3_000);
const parsed = JSON.parse(text);
expect(text.length).toBeLessThanOrEqual(3_000);
expect(parsed.result.data.tokens.length).toBeLessThan(400);
const field = parsed._truncated.fields.find(
(f: { field: string }) => f.field === "data.tokens",
);
expect(field).toBeDefined();
});
});

describe("boundExecuteResult — non-array/non-object bulk", () => {
it("truncates a giant string result to a prefix within budget", () => {
const big = "x".repeat(50_000);
const { text, truncated } = boundExecuteResult(ok(big), 1_000);
expect(truncated).toBe(true);
expect(text.length).toBeLessThanOrEqual(1_000);
const parsed = JSON.parse(text);
expect(typeof parsed.result).toBe("string");
expect(parsed.result.length).toBeLessThan(big.length);
expect(parsed._truncated.total).toBe(big.length);
});

it("falls back to dropping a non-reducible object bulk, still bounded", () => {
// An object with no arrays, dominated by one huge string value.
const input = ok({ blob: "y".repeat(50_000) });
const { text, truncated } = boundExecuteResult(input, 1_000);
expect(truncated).toBe(true);
expect(text.length).toBeLessThanOrEqual(1_000);
const parsed = JSON.parse(text);
expect(parsed.result).toBeNull();
expect(parsed._truncated.reason.toLowerCase()).toContain("truncat");
});
});

describe("boundExecuteResult — non-serializable propagates", () => {
it("throws on a BigInt result so the caller can emit its error envelope", () => {
expect(() => boundExecuteResult(ok(1n), 100_000)).toThrow(TypeError);
});
});

describe("result budget configuration", () => {
const KEY = "DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS";

it("default is ~50k tokens worth of chars", () => {
expect(DEFAULT_EXECUTE_RESULT_BUDGET_CHARS).toBe(200_000);
});

it("resolveResultBudgetChars reads a valid env override", () => {
const prev = process.env[KEY];
process.env[KEY] = "12345";
try {
expect(resolveResultBudgetChars()).toBe(12_345);
} finally {
if (prev === undefined) delete process.env[KEY];
else process.env[KEY] = prev;
}
});

it("falls back to the default for missing / invalid / non-positive values", () => {
const prev = process.env[KEY];
try {
delete process.env[KEY];
expect(resolveResultBudgetChars()).toBe(
DEFAULT_EXECUTE_RESULT_BUDGET_CHARS,
);
process.env[KEY] = "not-a-number";
expect(resolveResultBudgetChars()).toBe(
DEFAULT_EXECUTE_RESULT_BUDGET_CHARS,
);
process.env[KEY] = "0";
expect(resolveResultBudgetChars()).toBe(
DEFAULT_EXECUTE_RESULT_BUDGET_CHARS,
);
process.env[KEY] = "-5";
expect(resolveResultBudgetChars()).toBe(
DEFAULT_EXECUTE_RESULT_BUDGET_CHARS,
);
} finally {
if (prev === undefined) delete process.env[KEY];
else process.env[KEY] = prev;
}
});
});
Loading
Loading