Minimal reproduction for @modelcontextprotocol/server@2.0.0. No framework, only the SDK, Express and zod.
McpServer.registerTool converts the zod inputSchema to JSON Schema eagerly and stores the result on the server instance. In the stateless pattern (createMcpHandler(() => buildServer()), a fresh McpServer per request) that cache never hits, so every request, including initialize, pays the full conversion cost for every tool. zod does not memoize ~standard.jsonSchema.input(), so hoisting the z.object does not help either.
pnpm install
pnpm bench # registration cost in isolation
pnpm server & pnpm measure # request latency over HTTPNode 24.16, zod 4.6.5, Apple Silicon. 53 tools, 25 fields each, some nested arrays of objects.
Registration of all 53 tools on a fresh McpServer, median of 60 runs after warm-up:
| variant | median |
|---|---|
raw shape (SDK builds z.object) |
19.2 ms |
hoisted z.object, same instance every time |
18.3 ms |
hoisted + memoized ~standard.jsonSchema.input() |
0.8 ms |
no inputSchema |
0.02 ms |
Request latency over HTTP with createMcpHandler(() => buildServer()), median of 50 requests after warm-up:
| request | median |
|---|---|
GET /status (plain Express route) |
1.7 ms |
initialize |
30.8 ms |
tools/list |
55.5 ms |
_createRegisteredTool in @modelcontextprotocol/server calls standardSchemaToJsonSchema(inputSchema, "input") at registration and stores it in this._toolInputSchemaJson[name]. That map lives on the McpServer instance. zod's createStandardJSONSchemaMethod rebuilds the conversion context and walks the schema on every call, so input(opts) === input(opts) is false.
The third bench row patches ~standard.jsonSchema.input on a hoisted schema with a one-line memo. That is the shape of a fix inside the SDK: cache the converted JSON Schema per schema instance (and per conversion target) in a module-level WeakMap, so a hoisted schema converts once per process regardless of how many McpServer instances are created.