Skip to content

fix(core): memoize Standard Schema → JSON Schema conversion per schema instance - #2839

Open
0xamlab wants to merge 1 commit into
modelcontextprotocol:mainfrom
0xamlab:fix/standard-schema-conversion-memo
Open

0xamlab wants to merge 1 commit into
modelcontextprotocol:mainfrom
0xamlab:fix/standard-schema-conversion-memo

Conversation

@0xamlab

@0xamlab 0xamlab commented Sep 21, 2026

Copy link
Copy Markdown

Fixes #2838.

standardSchemaToJsonSchema now memoizes successful Standard Schema → JSON Schema conversions process-wide, keyed by schema identity and io direction, so the stateless per-request-server pattern stops re-converting every tool schema on every request.

Motivation and Context

In the recommended stateless pattern createMcpHandler(() => buildServer()), a fresh McpServer is built per request and the app re-registers its tools on it. registerTool converts each zod inputSchema eagerly and caches the result in _toolInputSchemaJson on that instance; tools/list converts again per call. Since the instance is new every request, the cache never hits — every request pays the full conversion for every tool, including initialize, which never reads a tool schema. The issue measured ~19 ms/request for 53 hoisted tools (30.8 ms initialize vs 1.7 ms for a plain Express route on the same server). Hoisting the zod schemas doesn't help on its own because zod's ~standard.jsonSchema.input() rebuilds from scratch on every call and the SDK called it per registration.

Root cause: conversion results were memoized only per McpServer instance; nothing keyed the conversion to the schema itself.

The fix:

  • standardSchemaToJsonSchema (packages/core-internal/src/util/standardSchema.ts) memoizes successful conversions in a module-level WeakMap<schema, { input?, output? }>. Every conversion call site — registerTool's eager conversion, the lazy toolInputSchemaJson() path, tools/list (input and output), outputSchema handling, prompt argument extraction, elicitation — already funnels through this one function, so a single insertion point covers them all.
  • Only successful conversions are cached: a throwing schema keeps throwing from the same call sites it always has (pinned by a test).
  • WeakMap keys keep entries collectible with their schema, so apps that build fresh schemas per request (nothing to reuse) retain nothing new.
  • Repeat calls return the same object. All in-SDK consumers already treat conversion results as read-only (the SEP-2243 x-mcp-header scan, the elicitation wire-grammar walk, and the 2025 codec's legacy outputSchema wrap each build new objects rather than mutating); the JSDoc now states the read-only contract explicitly.

Effect on the issue's repro (53 hoisted tools × 25 fields, fresh McpServer per build, measured against the built packages): 14–17.5 ms → 1.2–1.3 ms per registration pass, matching the ~0.8 ms the issue reports for explicit memoization.

How Has This Been Tested?

New regression coverage:

  • packages/core-internal/test/util/standardSchema.test.ts — a memoization block: at most one conversion per schema instance per io direction (counting Standard Schema double); the memoized value is the post-type:'object'-stamping result; a real hoisted zod schema returns the identical object on repeat calls; conversion failures are not memoized. All four fail on main, pass with the fix.
  • packages/server/test/server/toolSchemaMemoization.test.ts — the issue scenario end to end: one hoisted schema registered on 25 fresh McpServer instances, then tools/list against each; the converter runs exactly once. Fails on main (25 conversions), passes with the fix (1).

Suites:

  • core-internal: 1461 passed (69 files); server: 510 passed (46 files); typecheck and eslint+prettier clean for both packages; lint:all clean repo-wide.
  • test:all: every package green except @modelcontextprotocol/test-e2e, which reports 2 unhandled REQUEST_TIMEOUT errors in scenarios/protocol.test.ts — reproduced identically on unmodified main (same 2641 passed / 147 expected fail / 2 errors), so pre-existing and unrelated to this change.

Breaking Changes

None. No public API changes; conversion output is unchanged (the memo returns what the converter already produced). The only behavioral delta: repeat conversions of the same schema instance return the identical object instead of a fresh equal object.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed (the standardSchemaToJsonSchema JSDoc documents the memoization and the read-only contract)

Additional context

  • Trade-off accepted, as proposed in the issue: conversion output that depends on mutable ambient state (e.g. zod's globalRegistry) is captured at first conversion. The per-instance cache already had this property within one server's lifetime; this extends it to process lifetime for hoisted schemas — exactly the "cost once per process" semantics the issue asks for.
  • Changeset: patch for client + server (both inline core-internal; server-legacy does not depend on it).

…a instance

In the stateless createMcpHandler(() => buildServer()) pattern the app
builds a fresh McpServer per request and re-registers its tools, so the
per-instance _toolInputSchemaJson memo never hit: every request
re-converted every registered tool's schema (once eagerly in
registerTool, again per tools/list).

standardSchemaToJsonSchema now memoizes successful conversions
process-wide in a WeakMap keyed by schema identity and io direction, so
an app that hoists its schemas to module scope (the natural pattern in
that model) converts each schema once per process instead of once per
McpServer instance — 53 hoisted tools on a fresh server: ~14-19 ms ->
~1 ms. Conversion failures are not memoized, entries stay collectible
with their schema, and repeat calls return the same (read-only) object.

Fixes modelcontextprotocol#2838
@0xamlab
0xamlab requested a review from a team as a code owner September 21, 2026 15:30
@changeset-bot

changeset-bot Bot commented Sep 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0e68e73

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2839

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2839

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2839

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2839

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2839

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2839

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2839

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2839

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2839

commit: 0e68e73

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant