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
5 changes: 5 additions & 0 deletions src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions src/utils/strip-schema-dialect.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }[] };
// 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;
}
56 changes: 56 additions & 0 deletions test/schema-dialect.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>))
.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<string, unknown>;
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);
});
Loading