Skip to content

[v2] registerTool converts zod schemas eagerly per McpServer instance, so stateless servers re-convert every tool on every request #2838

Description

@harijoe

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions