Repository navigation
Description
Solving Missing Return Types in MCP Code Mode via _meta.tsReturnType, Client-Side Auto .d.ts Inference & Built-in WASM Script Client
- Status: Proposal
- Type: Standards Track (Client SDK & Tool Metadata Extension)
- Created: 2026-11-09
1. Abstract & Core Idea (LLM-Generated One-Time Script per Tool Call)
The Core Workflow:
Instead of the LLM making multiple static tools/call requests and receiving huge raw JSON responses into its context window, the MCP Client exposes the connected MCP tools as compact TypeScript interfaces (declare namespace sdk { ... }) and provides a single tool (execute_mcp_script). Using those TypeScript interfaces, the LLM dynamically writes and sends a custom, one-time JavaScript/TypeScript function (async (sdk) => { ... }) in a single tool call to:
- Call one or more MCP tools (including sequential dependent calls where
Tool Buses the output ofTool A), - Manipulate, filter, join, and aggregate the raw responses in-flight using custom logic, and
- Return only the final distilled result back to the LLM context window.
Solving the Two Blockers (Security & Missing Return Types):
- Zero-Trust Client-Side Execution (Solving Server Secret Risk): Because an LLM can generate arbitrary code, running that script on the MCP Server would risk exposing server variables and
process.envsecrets. By executing the LLM-generated script on the MCP Client inside a disposable QuickJS WebAssembly sandbox (quickjs-emscripten) with only thesdkproxy injected, server secrets are 100% unreachable and zero server changes are required. - TypeScript Return Interfaces (
_meta.tsReturnType& Auto-Inference): For the LLM to write working response-manipulation code on the first try, it must know the exact TypeScript structure of the tool's response. Because 95%+ of existing MCP servers omitoutputSchema(which results inPromise<unknown>), this proposal introduces optional_meta.tsReturnTypemetadata plus automatic Client-side.d.tsreturn-type inference from live JSON responses.
2. Motivation
Standard MCP tool invocation suffers from four compounding bottlenecks in production host applications (such as Claude Code, Claude Desktop, Cursor, and IDE agents):
- Context Window Bloating (Data Moves to Compute):
Calling a data-fetching tool (e.g.,listCustomers,listIssues,queryLogs) dumps tens or hundreds of kilobytes of raw JSON directly into the LLM context window, even when the model only needs a filtered subset or aggregate count. - Multi-Turn Dependent Call Latency & Context Re-Billing:
WhenTool Brequires an ID or field fromTool A's output (Tool A -> Tool B -> Tool C), standard MCP requires a full LLM inference round-trip between every step—re-billing the entire conversation history on each turn. - Missing Output Type Definitions on 95%+ of Existing MCP Servers:
While MCP supports optionaloutputSchema, the vast majority of deployed MCP servers omit it and return serialized JSON insidecontent[0].text. Without knowing the exact return shape upfront, an LLM cannot reliably write a single-pass transformation script. - Server-Side Execution Security Risks:
Executing LLM-generated scripts on the MCP Server exposes server secrets (process.env, DB credentials, internal networks) unless every server author deploys heavy isolation infrastructure.
By placing a Zero-Trust WASM Sandbox and Automatic .d.ts Type Inferrer inside the MCP Client / Host Application layer, every existing MCP server gains >98% token reduction and single-turn multi-server orchestration with zero server modifications and zero server secret exposure.
3. Specification & Architecture
3.1. Tool Metadata Extension (_meta.tsReturnType) & Client Auto-Inference
- Explicit TypeScript Return Type (Optional Server Metadata):
MCP Servers MAY attach_meta.tsReturnType(a compact TypeScript type string) or standardoutputSchemato tool definitions intools/list:{ "name": "getCustomerById", "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "_meta": { "tsReturnType": "{ id: string; name: string; tier: 'enterprise' | 'pro' | 'free'; active: boolean } | null" } } - Client-Side Automatic
.d.tsInference (Fallback for Existing MCP Servers):
When an upstream tool omits both_meta.tsReturnTypeandoutputSchema, the MCP Client inspects the first JSON response returned bytools/call, infers its TypeScript structural interface automatically, and caches the resulting.d.tssignature for subsequent turns.
3.2. Client-Generated Ambient TypeScript Declarations (declare namespace sdk)
The MCP Client aggregates all connected MCP servers into a single compact .d.ts declaration block:
declare namespace sdk {
namespace crm {
function listCustomers(args?: { includeInactive?: boolean }): Promise<Array<{ id: string; name: string; tier: 'enterprise' | 'pro' | 'free'; region: string; active: boolean }>>;
function getCustomerById(args: { id: string }): Promise<{ id: string; name: string; tier: string; active: boolean } | null>;
}
namespace billing {
function listInvoices(args?: { status?: "paid" | "overdue" | "draft" }): Promise<Array<{ invoiceId: string; customerId: string; amountUsd: number; status: string }>>;
}
}3.3. Client-Side Zero-Trust WASM Execution (McpScriptClient.executeScript)
When the LLM invokes execute_mcp_script, the MCP Client:
- Instantiates a disposable QuickJS WebAssembly runtime (
quickjs-emscripten) with a strict memory cap (default:16 MB) and CPU deadline interrupt handler (default:500 ms). - Injects only a frozen capability proxy object (
sdk.<server>.<tool>). The WASM linear memory contains noprocess,env,fs,require,fetch, or shared V8 prototype chain with the host. - Executes the LLM's
async (sdk) => { ... }function:- Parallel calls (
await Promise.all([sdk.crm.listCustomers(), sdk.billing.listInvoices()])) and sequential dependent chains (Tool A -> output -> Tool B) resolve inside the sandbox via standard MCPclient.callTool()calls on the host. - Raw JSON responses are parsed inside WASM memory, filtered/joined/reduced by the script, and immediately freed when the WASM context is disposed.
- Parallel calls (
- Returns only the final serialized return value to the LLM context window.
3.4. End-to-End Example (Dependent Tool Chaining + Filtering in 1 Turn)
Instead of 3 sequential LLM turns dumping 300 KB of raw JSON into the context window, the LLM submits a single execute_mcp_script call:
async (sdk) => {
// Step 1: Fetch customers and filter in-memory inside WASM sandbox
const allCustomers = await sdk.crm.listCustomers({ includeInactive: true });
const target = allCustomers.find(c => !c.active && c.tier === "enterprise");
if (!target) return null;
// Step 2: Dependent call on the same MCP server using `target.id` from Step 1
const verifiedCustomer = await sdk.crm.getCustomerById({ id: target.id });
// Step 3: Cross-server join with billing invoices
const overdue = await sdk.billing.listInvoices({ status: "overdue" });
const customerOverdue = overdue.filter(inv => inv.customerId === verifiedCustomer.id);
// Only this compact object (~150 bytes) is returned to the LLM context window
return {
customerId: verifiedCustomer.id,
customerName: verifiedCustomer.name,
overdueCount: customerOverdue.length,
overdueTotalUsd: customerOverdue.reduce((sum, i) => sum + i.amountUsd, 0)
};
}4. Security Analysis
All untrusted code runs exclusively inside the MCP Client's disposable QuickJS WebAssembly linear memory space:
| Threat Vector | Mitigation | Verified Outcome |
|---|---|---|
MCP Server Secret Leakage (process.env) |
Script executes on the MCP Client inside WASM; upstream MCP servers only receive standard JSON-RPC tools/call requests. |
ReferenceError: 'process' is not defined |
Prototype Chain Escape (constructor.constructor) |
Separate WASM heap; no shared V8 objects or prototypes with the Node.js host process. | ReferenceError: 'process' is not defined |
Filesystem / OS Access (fs, child_process) |
No WASI filesystem or OS imports mounted into the QuickJS WASM module. | ReferenceError: 'require' is not defined |
Data Exfiltration (fetch, sockets) |
Zero network bindings inside the WASM runtime. | ReferenceError: 'fetch' is not defined |
CPU / Memory Denial of Service (while(true){}) |
Enforced runtime.setMemoryLimit() and shouldInterruptAfterDeadline() interrupt handler. |
InternalError: interrupted at deadline |
5. Benchmark Results (Reference Implementation)
Evaluated on a multi-server task joining 500 CRM customer records (crm MCP server) with 1,500 billing invoice records (billing MCP server) plus a 3-step sequential dependent lookup (listCustomers -> getCustomerById -> listInvoices):
| Metric | Standard MCP | Client-Side Script Mode | Improvement |
|---|---|---|---|
| LLM Tool Call Round-Trips | 3 sequential turns | 1 single turn | 3x fewer turns |
| Raw Payload Entered into LLM Context | 305,711 bytes | 1,168 bytes | 99.62% reduction |
Total LLM Context Tokens (incl. .d.ts + script) |
~76,428 tokens | ~965 tokens | 98.74% token savings |
Dependent Chain (Tool A -> Tool B -> Tool C) |
3 LLM turns | 1 LLM turn (15.6 ms local) | Eliminates 2 full context re-sends |
6. Backward Compatibility
- 100% Backward-Compatible with Existing MCP Servers: Requires zero changes to deployed MCP servers.
- Hybrid or Pure Exposure in Host Apps: Host applications (Claude Code, Claude Desktop, etc.) can expose
execute_mcp_scriptalongside standard tools (Hybrid Mode) or replace raw tool schemas with compact.d.tsdeclarations when tool counts or payload sizes exceed a threshold.
Activity
[-]feat(client): Client-Side Sandboxed Script Execution & Auto .d.ts Interface Discovery[/-][+][Proposal] Solving the Missing outputSchema Problem in MCP Code Mode: _meta.tsReturnType & Client-Side Auto .d.ts Inference[/+][-][Proposal] Solving the Missing outputSchema Problem in MCP Code Mode: _meta.tsReturnType & Client-Side Auto .d.ts Inference[/-][+]feat(client): Built-in WASM Script Client + Auto .d.ts Return Type Inference (Solving Missing outputSchema in Code Mode)[/+]maran-t commentedon Nov 9, 2026
TL;DR
When the LLM inspects the MCP tool list (and their TypeScript input/return interfaces) and sees that a request requires dependent tool calls or response filtering, instead of sending one tool call at a time and waiting for each raw response to come back to the model, the LLM sends a single generated script as a one tool call.
That single script calls all the required MCP tools sequentially/in parallel, performs the required filtering and intermediate logic between the tool call responses locally inside the MCP Client's sandbox, and returns only the final required output in a single tool call turn.
SalyyS1 commentedon Nov 11, 2026
Interesting proposal. One thing we found while benchmarking MCP context overhead for mcp-slim across 60 real MCP tool definitions (
git,filesystem,postgres,github) is that the token bloat split happens at two distinct points:Upfront
tools/listschema overhead (12,500+tokens before the first turn):When generating compact
.d.tsdefinitions from upstreaminputSchema(Section 1), FastMCP and Pydantic servers emit nested$defs/{"$ref": "#/$defs/..."}pointers and verboseanyOf: [{"type": "string"}, {"type": "null"}]wrappers. Inlining$refpointers recursively and collapsing nullableanyOf/["T", "null"]unions intoT | nullbefore emitting the.d.ts(or compact JSON schema) cuts the declaration footprint by ~50% on its own, and pairing it with on-demand tool search (mcp_search_tools+mcp_call_tool) drops the upfront schema footprint by 98.7% (12,512 -> 168tokens).When
tools/callresponses don't need multi-step control flow:For dependent multi-tool loops (
Tool BconsumingTool A), a sandboxed script runner avoids round-trip latency. For single-call tools where the bloat is just verbose REST JSON (e.g. GitHub API returning 18*_urlhypermedia fields,nulls, and empty arrays per item), deterministic wire-level JSON/NDJSON compaction at the transport boundary strips ~43-55% of output tokens in<0.5 mswithout requiring the model to generate code.maran-t commentedon Nov 12, 2026
Great insights @SalyyS1 — both optimizations complement this architecture really well!
1. Schema normalization during
.d.tsgenerationAgreed. Resolving
$defs/$refreferences and simplifying nullableanyOfunions would make the generated TypeScript declarations significantly more compact.The generator should preserve JSON Schema semantics, particularly the distinction between optional properties (
field?: T) and required nullable properties (field: T | null). Shared or recursive references could also be represented as named types to avoid unnecessary duplication.2. Syntactic compaction vs. semantic script execution
I think these are complementary optimization layers:
execute_mcp_script): For semantic filtering, aggregation, cross-server joins, and dependent calls (Tool A -> Tool B -> Tool C), executing a generated script inside the MCP client avoids exposing intermediate payloads to the LLM and eliminates intermediate inference round-trips.One important distinction: compaction should be configurable and preserve fields required for execution. For legacy tools without
outputSchema, I'd favor inferring types from the original response structure, then compacting the resulting.d.tsrepresentation rather than inferring from potentially lossy data.3. On-demand discovery + script execution
The
mcp_search_toolsapproach could also integrate nicely here.Instead of exposing every connected tool's
.d.tsupfront, the client could discover relevant tool definitions on demand, generate compact TypeScript interfaces, and let the LLM produce a single script that orchestrates those tools through the WASM sandbox.That introduces a discovery step when interfaces aren't already cached, but avoids repeated LLM round-trips during dependent execution.
Overall, I see schema normalization, lazy discovery, deterministic payload compaction, and sandboxed script execution as complementary capabilities rather than competing approaches.
Appreciate sharing the benchmarks and implementation insights!
SalyyS1 commentedon Nov 12, 2026
@maran-t Good call on ordering type inference before payload compaction for legacy tools without
outputSchema.If
OutputSlimmerstrips keys whose values happened to benullor[]in the first sample response, inferring the.d.tsafter compaction would miss those optional properties (field?: T | null) on subsequent calls where they are populated. Running.d.tsshape inference on the raw upstream JSON response (and unioning observed property types across calls when the cache warms up) while applying wire compaction only to the payload returned to the model gives you complete TypeScript definitions alongside minimal context usage.