From c85efc61a9bc57d9f119f8e79571d29dbb29ccd0 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:13:08 -0500 Subject: [PATCH] perf(schema): drop the draft-07 $schema header from every tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK's zod->JSON-Schema conversion stamps "$schema": "http://json-schema.org/draft-07/schema#" onto every tool's inputSchema. Clients validate the schema either way and the Anthropic API ignores it, but it ships in the tool block the model carries on every turn. Measured against the built server (real MCP handshake, tools/list, tokenized with o200k_base): the full profile drops 13,200 -> 12,900 tokens. Per profile: trading 5,689 -> 5,554, media 5,541 -> 5,436, research 3,114 -> 3,024, chat 1,969 -> 1,924. Exactly 15 tokens per tool. The SDK exposes no option to suppress it, so we wrap the tools/list handler as it is installed, using only the public setRequestHandler. If a future SDK stops routing through ListToolsRequestSchema the wrapper stops matching and the header simply comes back — it is never load-bearing, and it no-ops entirely when there is no low-level server to wrap (the tool-annotation and apps tests pass a minimal fake). No behaviour change: 425/425 tests pass, annotations and _meta.ui are untouched, and a live check confirms schema validation still rejects missing and mistyped arguments. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WUL3ExR4Nz7uebxKaKwjDi --- src/mcp-handler.ts | 5 +++ src/utils/strip-schema-dialect.ts | 42 +++++++++++++++++++++++ test/schema-dialect.test.ts | 56 +++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/utils/strip-schema-dialect.ts create mode 100644 test/schema-dialect.test.ts diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 833a519..1177f68 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -26,6 +26,7 @@ import { registerDefiTool } from "./tools/defi.js"; import { registerPolymarketReadTool, registerPolymarketTool } from "./tools/polymarket.js"; import { resolveTools, type ToolName } from "./profiles.js"; import { registerAppResources } from "./apps.js"; +import { stripJsonSchemaDialect } from "./utils/strip-schema-dialect.js"; /** * Initialize the MCP server. The active tool `profile` (resolved from @@ -39,6 +40,10 @@ export function initializeMcpServer( server: McpServer, profileArgs?: { argv?: string[]; env?: NodeJS.ProcessEnv }, ): { profile: string; tools: ToolName[] } { + // Must run before any tool is registered — that is when the SDK lazily + // installs the tools/list handler this wraps. + stripJsonSchemaDialect(server); + // Default global spend cap from BLOCKRUN_BUDGET_LIMIT (USD). Without it the // ledger starts unlimited; the cap is in-memory and resets when the (npx-spawned) // process restarts, so an operator who wants a hard ceiling should set the env. diff --git a/src/utils/strip-schema-dialect.ts b/src/utils/strip-schema-dialect.ts new file mode 100644 index 0000000..3fea9c4 --- /dev/null +++ b/src/utils/strip-schema-dialect.ts @@ -0,0 +1,42 @@ +// src/utils/strip-schema-dialect.ts +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Drop the `"$schema": "http://json-schema.org/draft-07/schema#"` header the + * SDK's zod→JSON-Schema conversion stamps onto every tool's inputSchema. + * + * It is a dialect declaration: MCP clients validate the schema either way, and + * the Anthropic API ignores it outright. But it is emitted once per tool and + * lands in the model's context on every turn — 14 tokens x 20 tools = 280 + * tokens of pure boilerplate in a 13.8K-token tool block. + * + * The SDK gives no option to suppress it, so we intercept the `tools/list` + * handler as it is installed. This uses only the public `setRequestHandler`, + * and if a future SDK stops routing through `ListToolsRequestSchema` the + * wrapper simply stops matching — the `$schema` key comes back and nothing + * breaks. Must run BEFORE the first tool is registered, since that is when the + * SDK lazily installs the handler. + */ +export function stripJsonSchemaDialect(server: McpServer): void { + // Purely an optimization: if the low-level server isn't there to wrap (a + // test double, a future SDK shape), skip it rather than throw. The worst + // case is that `$schema` stays in the payload. + const lowLevel = server.server; + if (typeof lowLevel?.setRequestHandler !== "function") return; + const original = lowLevel.setRequestHandler.bind(lowLevel); + + lowLevel.setRequestHandler = ((requestSchema: unknown, handler: (...args: unknown[]) => unknown) => { + if (requestSchema !== ListToolsRequestSchema) { + return original(requestSchema as never, handler as never); + } + return original(requestSchema as never, (async (...args: unknown[]) => { + const result = await handler(...args) as { tools?: { inputSchema?: Record }[] }; + // The SDK rebuilds these objects on every call, so mutating is safe. + for (const tool of result?.tools ?? []) { + if (tool.inputSchema) delete tool.inputSchema.$schema; + } + return result; + }) as never); + }) as typeof lowLevel.setRequestHandler; +} diff --git a/test/schema-dialect.test.ts b/test/schema-dialect.test.ts new file mode 100644 index 0000000..0fe1f37 --- /dev/null +++ b/test/schema-dialect.test.ts @@ -0,0 +1,56 @@ +// Run with: npm test (tsx --test) +// +// The SDK's zod->JSON-Schema conversion stamps a draft-07 `$schema` header on +// every tool's inputSchema. Clients ignore it, but it ships in the tool block +// the model carries on every turn — 300 tokens across the full profile. +// stripJsonSchemaDialect() removes it by wrapping the tools/list handler, so +// the wrapper is identity-matched against the SDK's request schema: an SDK +// upgrade that reshapes that path would silently put the header back. This +// test is what notices. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { initializeMcpServer } from "../src/mcp-handler.js"; + +async function listTools(argv: string[]) { + const server = new McpServer({ name: "test", version: "0.0.0" }); + initializeMcpServer(server, { argv, env: {} }); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "0.0.0" }); + await Promise.all([server.connect(serverSide), client.connect(clientSide)]); + const { tools } = await client.listTools(); + await client.close(); + return tools; +} + +test("no tool advertises a $schema dialect header", async () => { + const tools = await listTools([]); + assert.equal(tools.length, 20); + const offenders = tools + .filter((t) => "$schema" in (t.inputSchema as Record)) + .map((t) => t.name); + assert.deepEqual(offenders, []); +}); + +test("stripping the dialect leaves the rest of the schema intact", async () => { + const tools = await listTools([]); + const markets = tools.find((t) => t.name === "blockrun_markets"); + assert.ok(markets, "blockrun_markets should be in the full profile"); + + const schema = markets.inputSchema as { + type?: string; + properties?: Record; + required?: string[]; + }; + assert.equal(schema.type, "object"); + assert.deepEqual(schema.required, ["path"]); + assert.ok(schema.properties?.path, "path property survives"); + assert.ok(schema.properties?.agent_id, "agent_id property survives"); + + // Annotations and MCP App metadata ride alongside inputSchema — the wrapper + // must not disturb them. + assert.ok(markets.annotations, "annotations survive"); + assert.equal(tools.filter((t) => t._meta?.ui).length, 2); +});