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); +});