What happened?
In the stateless pattern the docs recommend, createMcpHandler(() => buildServer()), a fresh McpServer is created per request and the app re-registers its tools on it. registerTool converts each zod inputSchema to JSON Schema right away and caches the result in _toolInputSchemaJson on that instance. Since the instance is new each time, the cache never hits, and every request pays the full conversion for every tool. That includes initialize, which never reads a tool schema.
With 53 tools of 25 fields each, registration costs ~19 ms per request. Over HTTP that shows up as initialize taking 30.8 ms where a plain Express route on the same server takes 1.7 ms.
Hoisting the z.object out of the factory does not help: zod's ~standard.jsonSchema.input() rebuilds the conversion from scratch on every call (input(opts) === input(opts) is false), and the SDK calls it on every registration anyway. Passing a hoisted instance brought registration from 19.2 ms to 18.3 ms. Memoizing jsonSchema.input on that hoisted instance brings it to 0.8 ms.
Full numbers and the scripts: https://github.com/alpic-ai/mcp-sdk-schema-conversion-repro
I think #1847 is where conversion moved from tools/list time to registration time. That fixed re-conversion per tools/list for long-lived servers, but with a server per request the cost just moved to registration.
What did you expect?
Converting a given schema instance to cost once per process, not once per McpServer. A module-level WeakMap<schema, Map<target, jsonSchema>> around standardSchemaToJsonSchema would do it and would let apps opt in by hoisting their schemas. A lazy conversion (on first tools/list or tools/call) would also skip the cost on initialize, but the WeakMap alone covers it.
Code to reproduce
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
const field = (f: number) =>
f % 3 === 0
? z.string().min(1).describe(`Field ${f}`).optional()
: f % 3 === 1
? z.number().int().optional()
: z.array(z.object({ id: z.string(), kind: z.enum(["a", "b"]).optional() })).optional();
const schemas = Array.from({ length: 53 }, () =>
z.object(Object.fromEntries(Array.from({ length: 25 }, (_, f) => [`f${f}`, field(f)]))),
);
function build() {
const server = new McpServer({ name: "repro", version: "1.0.0" });
schemas.forEach((inputSchema, i) =>
server.registerTool(`tool_${i}`, { inputSchema }, async () => ({ content: [] })),
);
}
for (let i = 0; i < 20; i++) build();
const start = performance.now();
build();
console.log(`${(performance.now() - start).toFixed(1)} ms to register 53 hoisted tools on a fresh McpServer`);
SDK version
@modelcontextprotocol/server 2.0.0, @modelcontextprotocol/node 2.0.0, zod 4.6.5, Node 24.16
Area
Server
What happened?
In the stateless pattern the docs recommend,
createMcpHandler(() => buildServer()), a freshMcpServeris created per request and the app re-registers its tools on it.registerToolconverts each zodinputSchemato JSON Schema right away and caches the result in_toolInputSchemaJsonon that instance. Since the instance is new each time, the cache never hits, and every request pays the full conversion for every tool. That includesinitialize, which never reads a tool schema.With 53 tools of 25 fields each, registration costs ~19 ms per request. Over HTTP that shows up as
initializetaking 30.8 ms where a plain Express route on the same server takes 1.7 ms.Hoisting the
z.objectout of the factory does not help: zod's~standard.jsonSchema.input()rebuilds the conversion from scratch on every call (input(opts) === input(opts)isfalse), and the SDK calls it on every registration anyway. Passing a hoisted instance brought registration from 19.2 ms to 18.3 ms. MemoizingjsonSchema.inputon that hoisted instance brings it to 0.8 ms.Full numbers and the scripts: https://github.com/alpic-ai/mcp-sdk-schema-conversion-repro
I think #1847 is where conversion moved from
tools/listtime to registration time. That fixed re-conversion pertools/listfor long-lived servers, but with a server per request the cost just moved to registration.What did you expect?
Converting a given schema instance to cost once per process, not once per
McpServer. A module-levelWeakMap<schema, Map<target, jsonSchema>>aroundstandardSchemaToJsonSchemawould do it and would let apps opt in by hoisting their schemas. A lazy conversion (on firsttools/listortools/call) would also skip the cost oninitialize, but the WeakMap alone covers it.Code to reproduce
SDK version
@modelcontextprotocol/server 2.0.0, @modelcontextprotocol/node 2.0.0, zod 4.6.5, Node 24.16
Area
Server