Skip to content

preview: combined import-cost changes (#2458 + #2476 + #2477)#2482

Closed
felixweinberger wants to merge 14 commits into
mainfrom
fweinberger/startup-stack-preview
Closed

preview: combined import-cost changes (#2458 + #2476 + #2477)#2482
felixweinberger wants to merge 14 commits into
mainfrom
fweinberger/startup-stack-preview

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Draft aggregation of the three open import-cost PRs at their current heads, merged onto main — not for merge; it exists so the combined build can be installed and integration-tested from the Continuous Releases preview packages below.

Review happens on the individual PRs; this branch is their clean merge (no additional changes). Full workspace gates pass on the combined tree.

AjvJsonSchemaValidator built its Ajv2020 instance (plus ajv-formats
registration) in the constructor. Client and Server construct the default
validator unconditionally, so every embedding application paid several
milliseconds of AJV instantiation at startup even when no JSON Schema
validation ever runs - a tax on CLI cold start in particular.

Defer engine creation to the first getValidator() call via a private lazy
getter. A caller-supplied engine is still used as-is from construction, the
2020-12 dialect check still fires before any engine is built, and the public
API is unchanged.
No-Verification-Needed: changeset-only commit
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.
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.
The MCP spec schemas (types/schemas.ts), the OAuth/OpenID schemas
(shared/auth.ts), the protocol constants (types/constants.ts), and the three
JSON value types move verbatim from core-internal into packages/core/src —
core now owns the schema sources instead of bundling them out of
core-internal through build-only subpath aliases.

Mechanism:
- core's curated root entry re-exports the same public surface as before
  (unchanged 172 exports), now from its own local modules.
- A new ./internal subpath re-exports the moved modules wholesale for the
  sibling SDK packages (internal helper schemas, auth types, constants, JSON
  types — names that are deliberately not public on the root entry).
- core-internal keeps the old module paths as one-to-one named re-export
  shims, so no importer or test changes anywhere.
- client/server/server-legacy declare @modelcontextprotocol/core as a real
  dependency; their bundles keep @modelcontextprotocol/core/internal as an
  external runtime import (explicit tsdown external entry) instead of
  carrying their own bundled schema copies.
- The build-only core-internal/schemas + core-internal/auth tsconfig/tsdown
  aliases are deleted everywhere; the per-package tsconfigs gain a single
  source-first alias for @modelcontextprotocol/core/internal so typecheck
  and vitest stay build-order independent.

Layering stays acyclic: core depends only on zod; core-internal depends on
core; the wire era modules are untouched (their frozen copies and their
runtime constants imports resolve through the shims unchanged).
…home

Test-only adaptations to the schema-source move; each guard keeps pinning the
same invariant, only pointed at the new canonical location:

- packageTopologyPins: pin @modelcontextprotocol/core's export map as
  ['.', './internal'] — the internal seam is deliberate, not public API.
- coreSchemas: read the spec-schema source from core's own src/schemas.ts
  (the auth group still reads core-internal's authSchemas registry).
- wireOnlyHiding: read the @deprecated task-schema and constants sources
  from packages/core/src (the old paths are now re-export shims with no
  doc comments to scan).
- codemod authSchemaNames: core's barrel now ends its auth block with
  "} from './auth'" instead of the deleted build-only alias specifier.
- cloudflareWorkers: generalize packServerPackage to packWorkspacePackage
  and install the workspace core tarball alongside the server tarball —
  the packed server resolves @modelcontextprotocol/core/internal at
  runtime, which the registry copy of core does not carry yet.
The schema sources moved into @modelcontextprotocol/core with the sibling
packages resolving @modelcontextprotocol/core/internal at runtime. That seam
can rot in ways no existing test catches; this locks it down:

- Exact sibling pins: client/server/server-legacy (and core-internal, for
  consistency) depend on core via workspace:* so pnpm publishes an exact
  version pin instead of a caret range. The ./internal surface is only
  guaranteed for the core version each sibling was built against, so a caret
  range would let installs mix skewed versions.
- Changesets fixed group for core + client + server + server-legacy, keeping
  the pinned versions releasable in lockstep.
- ./internal is labeled as an SDK-internal contract in its header, with
  @internal JSDoc on the re-exports (source-level only: the dts bundler
  flattens the re-exports and drops statement-level comments, so the built
  declarations do not carry the tag).
- A client boundary test parses the built dists in both directions: every
  name the client bundle imports or re-exports from core must resolve
  against core's built entry export lists (skew), and sentinel schemas that
  exist only in core's modules must never appear as definitions in the
  client bundle (re-inlining via lost external config or eager aliases).
- A core-internal shim-purity test pins the old schema/auth/constants module
  paths as pure re-export forwards: no zod import, no local definitions,
  imports only from @modelcontextprotocol/core/internal — so new schemas
  can't accrete at the old paths and silently miss core's published entries.
  The shim headers now state that rule, and the type-only JSON value
  re-export in types.ts is pinned as erasable.

No-Verification-Needed: tests, manifest pins, release config, and comments only — no runtime surface change
No-Verification-Needed: release-metadata-only change
barrelClean and coreBoundary each rebuilt a missing dist from their own
beforeAll; vitest runs test files in separate workers, so a cold checkout
raced two pnpm builds in the same package and the clean step deleted
files under the other worker's reader.

Route both through a shared single-flight helper: an atomic mkdir lock
with one canonical sentinel set per package, a stale-lock steal for
holders that died without cleanup, an async build bounded by a timeout
so the worker's event loop and vitest's hook timer stay live, and only
EEXIST treated as contention.

No-Verification-Needed: test-only change, no runtime surface
The builds take seconds; the previous bounds were sized by stacking
worst cases on worst cases. Keep the ordering invariant (build kill <
waiter deadline < stale-lock steal < hook timeout) at realistic
magnitudes: 60s/90s/120s with 180s and 240s hooks.

No-Verification-Needed: test-only timeout constants
… blind spots

- Add typesVersions for the ./internal subpath so moduleResolution:node10
  consumers resolve its declarations (exports maps are invisible there)
- Update wire-schemas and packages docs: core now arrives transitively as
  the shared runtime schema graph of client/server/server-legacy
- Boundary test: scan dist recursively (validators/ chunks were excluded)
  and reject bare side-effect imports of core, which have no from-clause
@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 60d7f1f

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

This PR includes changesets to release 9 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major
@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 Jul 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 60d7f1f

The wire schemas are built lazily since the memoized-factory change,
which is the right default on process-per-invocation runtimes where
module evaluation is boot latency. On isolate-based serverless
platforms the accounting inverts: module scope evaluates during
deployment/isolate warm-up outside any request's billed CPU, so lazy
construction lands inside the first request each fresh isolate serves
- observable in production as a per-request CPU regression with a
fresh-isolate-first-request signature.

- preloadSchemas() (core-internal wire/preload.ts): synchronous,
  idempotent; forces both era schema factories and every memoized
  lookup layer above them (the 2025 registry maps, the 2026 in-band
  input-request maps, the 2026 wire-result wrappers), so post-preload
  validation constructs nothing. Exported as public API from the
  client and server package roots for platforms that bill request CPU
  but not module evaluation.
- The workerd shims call it at module scope, so Cloudflare Workers
  deployments get eager construction automatically. Node and browser
  builds stay lazy; the server package gains a dedicated browser shim
  (its browser export condition previously reused the workerd shim,
  which would have leaked the eager call into browser bundles).
- Dist-level pins in both packages: the workerd entries must carry the
  module-scope call, node/browser entries must not, and the shim must
  import preloadSchemas from the same shared chunk as the root entry
  (a duplicated definition would warm a twin module graph and leave
  the real one cold).
- The server dist suites now share a single-flight build helper so two
  test files cannot race tsdown's clean step on a cold checkout; the
  client helper's dist sentinels now cover the shim entries.

Verified against packed tarballs in a scratch consumer: a bare import
constructs nothing (first preloadSchemas call does the work, second is
free); a wrangler bundle resolves shimsWorkerd and contains exactly
one module-scope call; an esbuild browser bundle resolves shimsBrowser
and contains none; the Cloudflare Workers integration test stays
green.
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.

1 participant