Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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