perf: build era wire schemas lazily through memoized factories#2476
perf: build era wire schemas lazily through memoized factories#2476felixweinberger wants to merge 2 commits into
Conversation
Importing the client or server package used to construct both frozen era wire-schema graphs (2025-11-25 and 2026-07-28) at module evaluation, before any message was validated. Move each era's schema definitions into a buildSchemas() factory memoized at module level: - schema definitions are verbatim from the old era schemas.ts (and the CallToolResultWireSchema wire seam verbatim from the 2025 registry, which now serves it through the same memo) - registries keep method membership and the exported method lists as static null-valued key objects mapped over the same wire unions, so the both-direction drift guards remain compile errors and importing a registry constructs nothing - codecs and inputRequired pull schemas through the memo at first use - each era schemas.ts becomes an eager named-export shim over the memo for tests and tooling; the shim is not on any runtime import path and tree-shakes out of the published bundles Reference identity is preserved: registry lookups, codecs, and the shim all serve objects from one memo, so the by-reference registry pins hold (the pin test now warms the memo via the shim import). Importing client+server gets roughly a third cheaper in a scratch consumer; all package suites, the examples e2e matrix, typecheck, and lint pass unchanged.
🦋 Changeset detectedLatest commit: 74f610c The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
The shim restated the SEP-1686 task deprecations but dropped the SEP-2577 tags on the logging, sampling, and roots families; restore each block verbatim from the pre-factoring module so importers keep IDE strike-through and deprecation lint.
There was a problem hiding this comment.
Thanks — commit 74f610c fully addresses my earlier comment about the dropped SEP-2577 @deprecated tags, and I found no new issues on this revision. Deferring to a human reviewer only because of the sheer size of the move (both era schema graphs relocated into memoized factories), not because of anything specific I'd flag.
Extended reasoning...
Overview
This PR converts both frozen protocol-revision wire-schema graphs (2025-11-25 and 2026-07-28) from eager module-level construction to memoized buildSchemas() factories in packages/core-internal/src/wire/. The registries keep static null-valued method-key objects (preserving the mapped-type drift guards and the exported method lists), the codecs and input-required maps pull schemas through the memo at first use, and each era's schemas.ts becomes an eager named-export shim over the same memo for tests/tooling. The registry pin test now warms the memo via the shim import and pins CallToolResultWireSchema at its new home.
Prior review status
My earlier review flagged one issue: the rewritten 2025 shim dropped ~25 @deprecated ... (SEP-2577) JSDoc tags (logging, sampling, and roots families) while deliberately restating the SEP-1686 task-family tags. The follow-up commit 74f610c restores those blocks verbatim — I verified the tags are present in the current tree. The multi-agent bug-hunting pass on this revision found no bugs.
Security risks
None identified. The change is a construction-timing refactor: no schema semantics change (the author machine-checked token-identity of all 297 declarations), no new inputs are parsed differently, and the memoized factories are synchronous, so there is no async seam or TOCTOU window in any validation path. Reference identity of the schema objects across registry, codec, and shim is preserved through the single memo and pinned by tests.
Level of scrutiny
This touches the wire-parse contract for every MCP message in both protocol revisions — production-critical code — and the diff is very large (~4,400 lines moved). The mechanical-move claim is well-evidenced (token-stream comparison, full suites passing, end-to-end scratch-consumer verification, by-reference pin tests), and the lazy-construction pattern is sound (memo ??= build() with static membership keys keeping the compile-time drift guards). Still, a verbatim-move refactor of this scale in behavior-frozen files warrants a human maintainer's sign-off — e.g. on the shim-vs-factory layering and the suggested follow-up lint banning runtime imports of the eager shims.
Other factors
Existing coverage is strong: 2,500+ tests across core-internal/server/client pass unchanged, and the registry pin tests specifically guard the reference-identity invariant this refactor depends on. The changeset correctly targets client/server/server-legacy (whose bundles inline core-internal). Duplicate-construction under the two ordering paths (registry-first vs shim-first) resolves to the same memo object, so no double-build hazard exists.
Importing the client or server package currently constructs both frozen protocol-revision wire-schema graphs (2025-11-25 and 2026-07-28) at module evaluation, before any message is validated. This PR moves each revision's schema definitions into a
buildSchemas()factory memoized at module level, so the construction cost is paid on first validation instead of at import.Motivation and Context
A consumer that imports
@modelcontextprotocol/clientor@modelcontextprotocol/serverpays for building ~150 zod schemas per protocol revision, twice over (both revisions), per bundled copy — all before it has connected to anything. For short-lived CLI-style consumers this is pure startup overhead on every invocation.The fix:
buildSchemas()factory memoized at module level (theCallToolResultWireSchemawire seam moves verbatim from the 2025 registry into the same factory).schemas.tsbecomes an eager named-export shim over the memo for tests and tooling; the shim is not on any runtime import path and tree-shakes out of the published bundles.This eliminates the per-revision schema-construction cost at import (~2 revisions' worth per bundled copy of core-internal). Together with #2458 and #2459, this closes ~55% of a measured consumer import regression and ~96% of its end-to-end startup regression.
How Has This Been Tested?
typecheck:allandlint:allclean.Breaking Changes
None. No public API changes; the schema definitions are token-identical to before; registry lookups keep returning reference-identical schema objects; the exported method lists are unchanged.
Types of changes
Checklist
Additional context
memo ??= build()), so there is no async seam in any validation path and the first-use cost is paid exactly once per process.schemas.tsshims from withinsrc/wire, making the laziness invariant mechanical there too.Changeset: patch for
client,server, andserver-legacy(their bundles inline core-internal).