Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/externalize-core-schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/server-legacy': minor
---

Resolve the spec and OAuth schema modules from `@modelcontextprotocol/core` at
runtime instead of inlining a copy into each package's bundle. `client`,
`server`, and `server-legacy` gain a runtime dependency on
`@modelcontextprotocol/core`, and their builds mark it external so the schema
definitions ship once instead of once per package. Public import paths and the
exported API are unchanged.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ The SDK separates internal code from the public API surface:
- **`@modelcontextprotocol/core-internal`** (main entry, `packages/core-internal/src/index.ts`) — Internal barrel. Exports everything (including Zod schemas, Protocol class, stdio utils). Only consumed by sibling packages within the monorepo (`private: true`).
- **`@modelcontextprotocol/core-internal/public`** (`packages/core-internal/src/exports/public/index.ts`) — Curated public API. Exports only TypeScript types, error classes, constants, and guards. Re-exported by client and server packages.
- **`@modelcontextprotocol/client`** and **`@modelcontextprotocol/server`** (`packages/*/src/index.ts`) — Final public surface. Package-specific exports (named explicitly) plus re-exports from `core-internal/public`.
- **`@modelcontextprotocol/core`** (`packages/core/src/index.ts`) — Public Zod-schema package. Re-exports **only** the `*Schema` Zod constants (MCP spec + OAuth/OpenID), bundled from `core-internal` at build time. The published home for raw runtime validation (`CallToolResultSchema.parse(...)`); runtime-neutral (`zod` is its only dependency). Not consumed by the sibling packages `client`/`server` keep their own bundled schema copies and stay Zod-free in their public surface.
- **`@modelcontextprotocol/core`** (`packages/core/src/index.ts`) — Public Zod-schema package. Re-exports **only** the `*Schema` Zod constants (MCP spec + OAuth/OpenID), bundled from `core-internal` at build time. The published home for raw runtime validation (`CallToolResultSchema.parse(...)`); runtime-neutral (`zod` is its only dependency). Consumed by the sibling packages at runtime: `client`/`server`/`server-legacy` bundles resolve the schema modules from this package (see the shared `externalize-core-schemas` plugin in `packages/core-internal/externalizeCoreSchemas.tsdown.mjs`, applied by their tsdown configs) so one evaluated schema-graph copy is shared per application; their public surfaces stay Zod-free.

When modifying exports:
- Use explicit named exports, not `export *`, in package `index.ts` files and `core-internal/public`.
Expand Down
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@modelcontextprotocol/core": "workspace:^",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This PR creates a hard runtime ABI between the published client/server/server-legacy bundles and @modelcontextprotocol/core (the bundles emit named ESM imports from core, so a single missing export makes the whole package fail at import time), but nothing ties changes to the two externalized modules (core-internal/src/types/schemas.ts, shared/auth.ts) to a core release: .changeset/config.json has fixed: []/linked: [], and workspace:^ resolves at publish to a caret floor at core's current version — which, if core wasn't bumped, is exactly the published artifact missing the new export. Consider adding core to a changesets fixed/linked group with client/server/server-legacy (or a CI check diffing the bundles' core import names against the latest published core) so a routine future schema addition can't publish packages that hard-fail to import against the newest published core.

Extended reasoning...

The bug. The externalize-core-schemas plugin makes the published client/server/server-legacy bundles emit real named ESM imports from @modelcontextprotocol/core (~32 named imports plus namespace access to all ~160 spec schema keys via the bundled specTypeSchema.ts). Under Node's ESM link-time validation, a single name missing from core's dist is not a lazy failure — it is SyntaxError: The requested module '@modelcontextprotocol/core' does not provide an export named '...', and the entire consuming package fails to import (verified experimentally by the finders). That means the published bundles now have a runtime ABI against whatever core version npm resolves — and nothing in the repo forces the two sides of that ABI to release together.\n\nWhy nothing enforces the coupling. .changeset/config.json has "fixed": [] and "linked": [] — no lockstep group covers core + its three new consumers. updateInternalDependencies: patch only propagates in the dependency→dependent direction (when core itself bumps); it never forces a core bump when a consumer bumps. core-internal is private: true and invisible to changesets, so edits to the externalized source modules (core-internal/src/types/schemas.ts, core-internal/src/shared/auth.ts) prompt nothing. And workspace:^ is rewritten by pnpm at publish time to a caret floor at core's current workspace version — if core wasn't bumped in the same release, that floor is exactly the already-published core artifact that lacks the new export.\n\nWhy no existing guard catches it. Every guard in the tree is source-level or same-tree: coreSchemas.test.ts syncs core/src/index.ts with core-internal source; the new distEntry.test.ts builds core from current source; CI test jobs and this PR's packed-tarball smoke resolve link:../core from the same tree. The skew this bug describes exists only between two independently published npm artifacts — a surface no check in the repo ever compares. Every CI check stays green while the broken pair publishes.\n\nConcrete failure walk-through.\n1. A future PR (spec revisions add schemas routinely) adds NewThingSchema to core-internal/src/types/schemas.ts, adds it to SPEC_SCHEMA_KEYS and core/src/index.ts (the drift test in coreSchemas.test.ts forces the source sync), and uses it at runtime in packages/client.\n2. The author adds a changeset for client only — nothing prompts a core changeset, and the changeset-bot only comments; there is no blocking check.\n3. CI is fully green (workspace core always contains the new export), and the release publishes client@2.0.0-beta.N with dependency ^2.0.0-beta.3 while core is not republished.\n4. Every fresh npm install @modelcontextprotocol/client resolves the newest published core (2.0.0-beta.3, missing NewThingSchema), and the client package cannot even be imported.\n\nA milder variant hits stale lockfiles even when core is correctly republished, because the caret floor isn't raised unless core bumps in the same release.\n\nWhy this PR is the right place to fix it. Before this PR the failure mode did not exist: each package inlined its own schema copy, so a schema addition shipped atomically inside the consuming package. This PR is what establishes the cross-package runtime ABI. Note also that the PR description justifies the new dependency edge with core being 'already published and versioned in lockstep' — but no lockstep exists (fixed: [], and server-legacy is at 2.0.0-beta.2 while core/client are at 2.0.0-beta.3 today); the stated safety premise is unenforced prose. Repo history contains a 'chore: add missing changesets' fix-up commit, showing forgotten changesets are a real occurrence here.\n\nHow to fix (cheap options). Any of: (a) add core, client, server, server-legacy to a changesets fixed (or at least linked) group in .changeset/config.json; (b) pin the dependency exactly (workspace:*) so consumers always require the core built alongside them; or (c) add a CI/publish-time check that diffs the dist bundles' @modelcontextprotocol/core import names against the exports of the latest published core tarball. Option (a) is a one-line config change and closes the gap entirely.

"cross-spawn": "catalog:runtimeClientOnly",
Comment thread
claude[bot] marked this conversation as resolved.
"eventsource": "catalog:runtimeClientOnly",
"eventsource-parser": "catalog:runtimeClientOnly",
Expand Down
53 changes: 53 additions & 0 deletions packages/client/test/client/externalizedCoreBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

import { beforeAll, describe, expect, test } from 'vitest';

const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..');
const distDir = join(pkgDir, 'dist');
const coreDir = join(pkgDir, '..', 'core');
const CORE_IMPORT = /\bfrom\s+["']@modelcontextprotocol\/core["']/;

function chunksImportingCore(): string[] {
return (readdirSync(distDir, { recursive: true }) as string[])
.filter(file => file.endsWith('.mjs'))
.map(file => join(distDir, file))
.filter(chunk => CORE_IMPORT.test(readFileSync(chunk, 'utf8')));
}

Check warning on line 18 in packages/client/test/client/externalizedCoreBoundary.test.ts

View check run for this annotation

Claude / Claude Code Review

Externalized-core boundary test covers only client and only ESM chunks

The new consumer-side boundary guard is applied to only one of the three packages that received the externalize plugin: `packages/server` and `packages/server-legacy` get the same rewrite but have no equivalent of `externalizedCoreBoundary.test.ts`, so a future server-only value import of a name core does not export would build green and fail at consumers' import time; additionally, `chunksImportingCore()` filters `.mjs` only, leaving the CJS half of the dual build unscanned. Consider replicatin
Comment on lines +13 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new consumer-side boundary guard is applied to only one of the three packages that received the externalize plugin: packages/server and packages/server-legacy get the same rewrite but have no equivalent of externalizedCoreBoundary.test.ts, so a future server-only value import of a name core does not export would build green and fail at consumers' import time; additionally, chunksImportingCore() filters .mjs only, leaving the CJS half of the dual build unscanned. Consider replicating this test in server/server-legacy (the main gap) and optionally extending the chunk scan to .cjs artifacts plus a require() smoke in packages/core/test/distEntry.test.ts.

Extended reasoning...

The gap. This PR's safety mechanism for the new runtime boundary — plain-node importing every built chunk that names @modelcontextprotocol/core, so a value import of a name core does not export fails in CI with a real ESM link error — exists only for packages/client. The same externalize-core-schemas plugin is applied to packages/server and packages/server-legacy (their tsdown.config.ts), whose bundles emit their own sets of named imports from core (server-side code touches schemas client never imports, e.g. server auth paths through shared/auth.ts). A grep of both packages' test/ trees finds zero references to @modelcontextprotocol/core or the externalized boundary; server's barrelClean.test.ts only regex-greps emitted chunk text and never plain-node-imports the dist, so it cannot catch a missing export under real ESM link semantics. The plugin's own doc comment acknowledges the guard is client-only ("client's externalizedCoreBoundary.test.ts checks the built output against core's real export surface").\n\nWhy the plugin itself doesn't cover this. The plugin's generateBundle assertions verify only that the rewrite happened (a listed module was resolved and rewritten, and none reached an emitted chunk) — they never check that the rewritten import names actually exist in core's export surface. And typecheck can't catch it either: the dts paths mapping resolves core-internal to source, where internal-only helpers (e.g. SafeUrlSchema, OptionalSafeUrlSchema in shared/auth.ts, which core deliberately does not re-export) are visible.\n\nStep-by-step failure scenario. (1) A future change makes server code value-import SafeUrlSchema from shared/auth.ts. (2) Typecheck passes (source-level resolution sees the export). (3) The plugin rewrites the import to @modelcontextprotocol/core and its assertions pass — the rewrite happened. (4) The built dist/index.mjs now contains import { SafeUrlSchema } from '@modelcontextprotocol/core', a name core does not export. (5) For @modelcontextprotocol/server this would incidentally fail the examples CI job (stories load the built server dist under native ESM linking), but that coverage is accidental, not a pinned contract, and only for the entry points examples happen to exercise. For server-legacy there is no incidental coverage at all: no example depends on it, and its own tests run from src via tsconfig paths — its dist→core boundary has zero runtime exercise anywhere in CI. The published package would then throw SyntaxError: The requested module '@modelcontextprotocol/core' does not provide an export named 'SafeUrlSchema' in every consumer, at import time.\n\nThe CJS dimension, and the refutation. chunksImportingCore() filters .endsWith('.mjs') only, and distEntry.test.ts imports only dist/index.mjs, so the CJS half of the dual builds (main: ./dist/index.cjs, which emits require('@modelcontextprotocol/core')) is never scanned — and CJS fails silently (destructuring a missing export yields undefined, not a link error). One verifier refuted this half on the grounds that the failure is format-independent: the .mjs and .cjs chunks are emitted from the same module graph by the same build, so any bad name necessarily appears in the ESM chunks too, where the committed test fails loudly; a CJS-only divergence requires a hypothetical tsdown/rolldown CJS-emission regression, and core's two dist artifacts currently expose identical export surfaces (verified: 172 names each). That objection is fair, and it is why the .cjs extension is the secondary, belt-and-braces part of this finding rather than the core of it — the primary, source-level-reachable gap is the two packages with no test at all, which the refutation does not dispute.\n\nImpact and why nit. Nothing is broken as merged — the author verified every name currently crossing the boundary exists in core, so this is a completeness gap in a guard this PR introduces, not a live defect. It only bites on a future change, and for server the examples job provides partial incidental coverage. But it is exactly the hazard the client test was written to catch, left open on two of the three packages that got the plugin.\n\nFix. Copy externalizedCoreBoundary.test.ts into packages/server/test and packages/server-legacy/test (it is already parameterized only by pkgDir; a shared helper would also work). Optionally, extend the chunk filter to .cjs (matching require(\"@modelcontextprotocol/core\") syntax as well, since the from '...' regex won't match CJS) and add a require() smoke of dist/index.cjs to packages/core/test/distEntry.test.ts.


// Consumer side of the externalize-core-schemas boundary (see
// packages/core-internal/externalizeCoreSchemas.tsdown.mjs): the built package must resolve the
// schema modules from @modelcontextprotocol/core at runtime, and everything it imports from there
// must actually exist in core. The build-time assertions in the plugin guard the producer side;
// this test pins the consumer side of the published contract.
describe('@modelcontextprotocol/client dist resolves schemas from @modelcontextprotocol/core', () => {
beforeAll(() => {
if (!existsSync(join(distDir, 'index.mjs'))) {
execFileSync('pnpm', ['build'], { cwd: pkgDir, stdio: 'inherit' });
}
if (!existsSync(join(coreDir, 'dist', 'index.mjs'))) {
execFileSync('pnpm', ['build'], { cwd: coreDir, stdio: 'inherit' });
}
}, 240_000);

Check warning on line 33 in packages/client/test/client/externalizedCoreBoundary.test.ts

View check run for this annotation

Claude / Claude Code Review

Concurrent on-demand dist builds with clean:true can race between test files

The build-on-demand `beforeAll` here can race with the identical hook in `barrelClean.test.ts`: vitest runs test files in parallel workers (the shared vitest config doesn't disable `fileParallelism`), and on a fresh CI checkout dist never exists, so both files can see a missing `dist/index.mjs` and spawn concurrent `pnpm build` runs of the same package — and client's tsdown config has `clean: true`, so the second build rimrafs dist while the first build's output is being written or read, produci
Comment on lines +26 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The build-on-demand beforeAll here can race with the identical hook in barrelClean.test.ts: vitest runs test files in parallel workers (the shared vitest config doesn't disable fileParallelism), and on a fresh CI checkout dist never exists, so both files can see a missing dist/index.mjs and spawn concurrent pnpm build runs of the same package — and client's tsdown config has clean: true, so the second build rimrafs dist while the first build's output is being written or read, producing intermittent ENOENT/import flakes. Consider building via a shared globalSetup, a lock/marker file, or colocating the two dist-building suites in one file.

Extended reasoning...

What the bug is. This PR gives packages/client a second test file that builds dist on demand. barrelClean.test.ts:40-44 and the new externalizedCoreBoundary.test.ts:26-33 both gate on existsSync(join(distDir, 'index.mjs')) and both run execFileSync('pnpm', ['build'], { cwd: pkgDir }) when it's missing. Client's tsdown.config.ts sets clean: true, so every build starts by deleting dist. Two concurrent builds of the same package are therefore not idempotent: the second build's clean step can delete chunks the first build just wrote — or chunks the first file's tests are actively reading.

Why the builds run concurrently. The shared common/vitest-config/vitest.config.js does not set fileParallelism: false, maxWorkers, or any sequence option, and client's own config doesn't override it, so vitest runs test files across parallel workers by default. CI's test job runs pnpm install followed by pnpm -r test with no prior build stepdistEntry.test.ts's own comment notes "the repo's CI test job never runs a standalone build — dist only exists if a test builds it." So on every fresh CI run, client's dist is absent, and both files' beforeAll hooks fire.

Step-by-step failure scenario.

  1. Fresh CI runner; packages/client/dist does not exist.
  2. Vitest schedules barrelClean.test.ts and externalizedCoreBoundary.test.ts in two parallel workers.
  3. Worker A's beforeAll sees no dist/index.mjs and spawns pnpm build (a tsdown build that takes tens of seconds — the entire duration is the race window).
  4. Worker B's beforeAll runs while A's build is still in flight, also sees no dist/index.mjs (or a partial dist), and spawns a second pnpm build.
  5. Build B's clean: true step rimrafs dist, deleting chunks build A already emitted.
  6. Worker A's tests then hit readFileSync ENOENT in chunkImportsOf(), an empty chunksImportingCore(), or a spawned plain-node import() of a half-written chunk — a red CI run with a failure signature unrelated to any real regression.

Why nothing prevents it. There is no lock, marker file, or globalSetup serializing the builds; the existsSync check is a classic TOCTOU gate. Before this PR, barrelClean.test.ts was the only builder of client dist, so exactly one build ever ran and no race existed — the double-builder is introduced by this PR.

Scope caveat (what is not broken). The cross-package variant of this concern — the boundary test's second hook building packages/core while core's own distEntry.test.ts builds it — does not manifest under the standard invocations: pnpm -r test executes suites in topological order, and this PR makes client depend on core (workspace:^), so core's suite (which builds core's dist) completes before client's suite starts, and the boundary test's core build is a no-op skip. Only the intra-client race is real.

Impact. A new probabilistic flake vector in the per-PR CI gate. It requires the two files' beforeAll windows to overlap and an unlucky interleaving (two concurrent builds emit identical output, so many interleavings are benign — consistent with this PR's green node 20/22/24 matrix), so it costs intermittent triage time rather than a deterministic failure. Notably, the PR description already complains about triaging an unrelated wrangler flake; this adds another of the same species.

How to fix. Any of: (a) move the dist build into a vitest globalSetup for the client package so it runs exactly once before workers start; (b) colocate the boundary tests in barrelClean.test.ts (or a shared describe sequence) so one file owns the build; (c) serialize with a lockfile/marker around the existsSync + build; or (d) have the second hook wait-and-recheck instead of building. Option (a) is the cleanest and also covers any future dist-reading test file.


test('built ESM chunks import the schemas from @modelcontextprotocol/core instead of inlining them', () => {
expect(chunksImportingCore().length).toBeGreaterThan(0);
});

test('every name the built chunks import from @modelcontextprotocol/core exists in core, under real ESM linking', () => {
for (const chunk of chunksImportingCore()) {
// Plain-node import gives real ESM link semantics: it fails at instantiation if the chunk
// names an export core does not provide (e.g. an SDK-internal helper schema accidentally
// imported as a value) — exactly the error a published package would throw in consumers.
// Vitest's own module transform would report such a name as `undefined` instead of failing.
const result = spawnSync(
process.execPath,
['--input-type=module', '-e', `await import(${JSON.stringify(pathToFileURL(chunk).href)});`],
{ encoding: 'utf8', timeout: 30_000 }
);
expect(result.status, `importing ${chunk} failed:\n${result.error ?? result.stderr}`).toBe(0);
}
}, 60_000);

Check warning on line 52 in packages/client/test/client/externalizedCoreBoundary.test.ts

View check run for this annotation

Claude / Claude Code Review

Namespace re-import of core bypasses ESM link validation for the 160-key specTypeSchema surface

The new boundary test's comment says a plain-node import "fails at instantiation if the chunk names an export core does not provide" — but that is only true for named import specifiers. The bundled specTypeSchema.ts reaches the externalized module via `import * as schemas from '@modelcontextprotocol/core'` plus dynamic `schemas[key]` access over all ~160 SPEC_SCHEMA_KEYS, and ESM linking never validates namespace member access, so a key missing from the resolved core imports cleanly as `undefine
Comment on lines +38 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new boundary test's comment says a plain-node import "fails at instantiation if the chunk names an export core does not provide" — but that is only true for named import specifiers. The bundled specTypeSchema.ts reaches the externalized module via import * as schemas from '@modelcontextprotocol/core' plus dynamic schemas[key] access over all ~160 SPEC_SCHEMA_KEYS, and ESM linking never validates namespace member access, so a key missing from the resolved core imports cleanly as undefined and only fails at the consumer's first specTypeSchemas.X/isSpecType.X call. Cheap hardening: throw in the register() loop when schemas[key] === undefined (making future drift loud at import time, where this test would catch it), and/or assert every SPEC_SCHEMA_KEYS name is in Object.keys of core's built dist entry.

Extended reasoning...

What the gap is. packages/core-internal/src/types/specTypeSchema.ts is bundled into client/server/server-legacy and reaches the externalized schema module through a namespace import: import * as schemas from './schemas' is rewritten by the externalize-core-schemas plugin into import * as schemas from \"@modelcontextprotocol/core\" (verified in the built output — e.g. packages/server/dist/mcp-*.mjs contains exactly that statement, followed by for (const key of SPEC_SCHEMA_KEYS) register(key, schemas[key]);). ES module linking validates only named import specifiers; namespace member access is never checked at link time. So if a key in SPEC_SCHEMA_KEYS has no corresponding export in the resolved @modelcontextprotocol/core, schemas[key] silently evaluates to undefined, the package imports without error, and register() happily stores the undefined value (it only builds a lazy safeParse closure — nothing dereferences the schema at import time).

Why the test's comment overstates its coverage. The comment at externalizedCoreBoundary.test.ts:41-44 claims the plain-node import "fails at instantiation if the chunk names an export core does not provide." That holds for the ~32 named imports the chunks emit, but not for the single largest surface crossing the boundary: all ~160 spec schema keys accessed dynamically through the namespace. Verified experimentally: import * as ns from ...; ns.doesNotExist is undefined under plain-node ESM — no SyntaxError, no link failure. The test passes even when this surface is broken.

The concrete code path that fails. isSpecType/specTypeSchemas are public API (core-internal/public exports them, and client/server re-export public wholesale), so the registration loop runs at package import time in every built artifact. A missing key produces:

  1. schemas['NewThingSchema']undefined (no error).
  2. register('NewThingSchema', undefined) stores undefined and a closure (v) => schema.safeParse(v).success.
  3. Package import succeeds; every test and smoke that merely imports the package stays green.
  4. The consumer's first isSpecType.NewThing(value) call throws TypeError: Cannot read properties of undefined (reading 'safeParse') — a baffling error far from the actual cause.

Two realistic triggers. (a) Version skew: a future PR adds a schema to schemas.ts + SPEC_SCHEMA_KEYS with no other direct SDK runtime use (common for spec vocabulary that exists only on the validation surface, like several current Task* entries). The bundle then carries no named import of it — only namespace access — so against an older published core the package does not hard-fail at import (as the existing ABI review comment assumes for named imports); it ships broken validators. (b) Helper-list drift with same-version core: the subset relation SPEC_SCHEMA_KEYS ⊆ core-exports is maintained only by hand-synced lists (SPEC_INTERNAL_HELPERS in packages/core/test/coreSchemas.test.ts, INTERNAL_HELPER_SCHEMAS in core-internal's specTypeSchema.test.ts, and the allowlist itself); a mis-sync where a name lands in SPEC_SCHEMA_KEYS but is excluded from core reproduces the same silent state, and every vitest run hides it because tests resolve schemas.ts from source via tsconfig paths, where the name always exists.

Why nothing else prevents it. The plugin's build-time assertions only prove the rewrite happened (external imports are never validated against the target's export surface at build time); this test's plain-node import validates named specifiers only and evaluation defers all schemas[key] reads; coreSchemas.test.ts and specTypeSchema.test.ts are source-text checks on separate lists with no assertion tying SPEC_SCHEMA_KEYS to the built core's Object.keys.

How to fix (cheap). Either or both: (1) make the registration loop eager-fail — in specTypeSchema.ts, if (schemas[key] === undefined) throw new Error(\spec schema ${key} missing from resolved @modelcontextprotocol/core`)— which converts both scenarios into a loud import-time error that this exact test (and every consumer) would surface immediately; (2) add an assertion (here or indistEntry.test.ts) that every SPEC_SCHEMA_KEYSname appears inObject.keys(await import(core/dist/index.mjs))`.

Why this is a nit, not blocking. All 160 keys are present in core's dist today (independently verified), so nothing is broken as merged; both triggers require future drift or version skew. This is hardening of the guard design this PR itself introduces — worth doing here because the test comment currently overstates what the test proves, but merging without it breaks nothing. It also complements (does not duplicate) the earlier ABI review comment, which assumes missing exports fail loudly at import — this is the complementary silent-failure path that that comment's proposed checks would also miss.

});
6 changes: 6 additions & 0 deletions packages/client/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { defineConfig } from 'tsdown';

import { externalizeCoreSchemas } from '../core-internal/externalizeCoreSchemas.tsdown.mjs';

export default defineConfig({
failOnWarn: 'ci-only',
// Resolve the published schema modules from @modelcontextprotocol/core at runtime instead of
// inlining them — see packages/core-internal/externalizeCoreSchemas.tsdown.mjs for the rationale
// and the build-time assertions that keep the rewrite honest.
plugins: [externalizeCoreSchemas()],
entry: [
'src/index.ts',
'src/stdio.ts',
Expand Down
16 changes: 16 additions & 0 deletions packages/core-internal/externalizeCoreSchemas.tsdown.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Rolldown } from 'tsdown';

/**
* Absolute paths of the core-internal schema source modules that are published verbatim as
* @modelcontextprotocol/core (types/schemas.ts + shared/auth.ts). Single source of truth for
* {@link externalizeCoreSchemas}; keep in sync with the aliases in packages/core/tsdown.config.ts.
*/
export declare const CORE_SCHEMA_MODULES: readonly string[];

/**
* tsdown/rolldown plugin that rewrites every import resolving to one of {@link CORE_SCHEMA_MODULES} into an external
* import of '@modelcontextprotocol/core', so built packages share ONE evaluated copy of the spec + OAuth Zod schema
* graph per application. Asserts at build time, in both directions, that the rewrite actually happened — see the
* implementation in externalizeCoreSchemas.tsdown.mjs for the full rationale.
*/
export declare function externalizeCoreSchemas(): Rolldown.Plugin;
108 changes: 108 additions & 0 deletions packages/core-internal/externalizeCoreSchemas.tsdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Shared tsdown build plugin. Plain .mjs (not .ts) because tsdown's config loader transpiles only
// the entry tsdown.config.ts itself and native-imports everything else, and Node 20 cannot import
// TypeScript sources. Types for consumers live in externalizeCoreSchemas.tsdown.d.mts.
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));

/**
* core-internal's two schema source modules are published verbatim as @modelcontextprotocol/core (see
* packages/core/tsdown.config.ts). The externalizeCoreSchemas() tsdown plugin rewrites every import that RESOLVES to
* one of them — whatever the import specifier looked like — into an external import of '@modelcontextprotocol/core',
* so built packages resolve the schemas from that package at runtime instead of inlining yet another copy. That keeps
* ONE evaluated copy of the spec + OAuth Zod schema graph per application, however many SDK packages it uses.
*
* Safe because core exports every name that crosses this boundary at runtime: SDK-internal helper schemas live in
* separate core-internal modules (e.g. types/listChangedOptions.ts) and remain bundled, and the remaining
* internal-only exports of the two modules are imported type-only (client's externalizedCoreBoundary.test.ts checks
* the built output against core's real export surface). Type declarations still inline the types via each consumer's
* dts `paths` mapping — only runtime duplication costs.
*
* @type {readonly string[]}
*/
export const CORE_SCHEMA_MODULES = [
path.join(HERE, 'src', 'types', 'schemas.ts'), // MCP spec schemas
path.join(HERE, 'src', 'shared', 'auth.ts') // OAuth/OpenID schemas
];

const CORE_INTERNAL_SRC = path.join(HERE, 'src') + path.sep;

/**
* Resolvers may decorate an id beyond the plain file path (`\0` virtual-module prefix, `?query` suffix).
*
* @param {string} id
* @returns {string}
*/
function undecorate(id) {
return id.replace(/^\0/, '').replace(/\?.*$/, '');
}

/**
* Rewrites imports of {@link CORE_SCHEMA_MODULES} to external '@modelcontextprotocol/core' imports, and fails the
* build when the rewrite cannot have worked, in both directions:
*
* - a listed module that does not exist on disk, or is never rewritten by a build that emits runtime chunks bundling
* core-internal sources, fails the build — otherwise renaming or moving a schema module would silently re-inline
* the whole schema graph into every consumer;
* - a listed module that still ends up in an emitted runtime chunk fails the build — the rewrite was bypassed, e.g.
* by a resolver id decoration the comparison does not recognize.
*
* The checks look at emitted runtime chunks (.mjs/.cjs), not the raw module graph, because the d.ts pass loads the
* schema .ts sources on purpose: type declarations inline the types via each consumer's dts `paths` mapping.
*
* @returns {import('tsdown').Rolldown.Plugin}
*/
export function externalizeCoreSchemas() {
const rewrites = new Map(CORE_SCHEMA_MODULES.map(module => [module, 0]));
return {
name: 'externalize-core-schemas',
buildStart() {
for (const module of CORE_SCHEMA_MODULES) {
if (!existsSync(module)) {
this.error(
`externalize-core-schemas: listed schema module ${module} does not exist. ` +
`If it moved, update CORE_SCHEMA_MODULES in packages/core-internal/externalizeCoreSchemas.tsdown.mjs ` +
`and the aliases in packages/core/tsdown.config.ts, which publish the same modules.`
);
}
}
},
async resolveId(source, importer, options) {
if (importer === undefined) return null;
const resolved = await this.resolve(source, importer, options);
if (resolved === null) return null;
const id = path.normalize(undecorate(resolved.id));
const count = rewrites.get(id);
if (count === undefined) return null;
rewrites.set(id, count + 1);
return { id: '@modelcontextprotocol/core', external: true };
},
generateBundle(_outputOptions, bundle) {
const bundledIds = new Set(
Object.values(bundle)
.filter(output => output.type === 'chunk' && !/\.d\.[cm]?ts$/.test(output.fileName))
.flatMap(chunk => chunk.moduleIds.map(id => path.normalize(undecorate(id))))
);
// Outputs with no core-internal runtime code (e.g. the d.ts pass) prove nothing about the rewrite.
if (![...bundledIds].some(id => id.startsWith(CORE_INTERNAL_SRC))) return;
const missed = CORE_SCHEMA_MODULES.filter(module => rewrites.get(module) === 0);
if (missed.length > 0) {
this.error(
`externalize-core-schemas: no import resolved to ${missed.join(' or ')}, ` +
`so nothing was externalized from there and the schema graph would be inlined. ` +
`If the module was renamed or split, update CORE_SCHEMA_MODULES in ` +
`packages/core-internal/externalizeCoreSchemas.tsdown.mjs and packages/core/tsdown.config.ts.`
);
}
const inlined = CORE_SCHEMA_MODULES.filter(module => bundledIds.has(module));
if (inlined.length > 0) {
this.error(
`externalize-core-schemas: ${inlined.join(' and ')} reached an emitted runtime chunk ` +
`despite being listed for externalization — the resolveId rewrite was bypassed.`
);
}
}
};
}
1 change: 1 addition & 0 deletions packages/core-internal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"eslint-config-prettier": "catalog:devTools",
"eslint-plugin-n": "catalog:devTools",
"prettier": "catalog:devTools",
"tsdown": "catalog:devTools",
"typescript": "catalog:devTools",
"typescript-eslint": "catalog:devTools",
"vitest": "catalog:devTools"
Expand Down
1 change: 1 addition & 0 deletions packages/core-internal/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './constants';
export * from './enums';
export * from './errors';
export * from './guards';
export * from './listChangedOptions';
export * from './schemas';
export * from './specTypeSchema';
export * from './types';
31 changes: 31 additions & 0 deletions packages/core-internal/src/types/listChangedOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as z from 'zod/v4';

/**
* Base schema for list changed subscription options (without callback).
* Used internally for Zod validation of `autoRefresh` and `debounceMs`.
*
* Lives outside `schemas.ts` deliberately: it is an SDK option schema, not a spec
* schema, so it is not part of `@modelcontextprotocol/core`'s public surface —
* and `schemas.ts` is resolved from that package at runtime by the client/server
* bundles, which can only see the names core actually exports.
*/
export const ListChangedOptionsBaseSchema = z.object({
/**
* If `true`, the list will be refreshed automatically when a list changed notification is received.
* The callback will be called with the updated list.
*
* If `false`, the callback will be called with `null` items, allowing manual refresh.
*
* @default true
*/
autoRefresh: z.boolean().default(true),
/**
* Debounce time in milliseconds for list changed notification processing.
*
* Multiple notifications received within this timeframe will only trigger one refresh.
* Set to `0` to disable debouncing.
*
* @default 300
*/
debounceMs: z.number().int().nonnegative().default(300)
});
25 changes: 0 additions & 25 deletions packages/core-internal/src/types/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1455,31 +1455,6 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({
params: NotificationsParamsSchema.optional()
});

/**
* Base schema for list changed subscription options (without callback).
* Used internally for Zod validation of `autoRefresh` and `debounceMs`.
*/
export const ListChangedOptionsBaseSchema = z.object({
/**
* If `true`, the list will be refreshed automatically when a list changed notification is received.
* The callback will be called with the updated list.
*
* If `false`, the callback will be called with `null` items, allowing manual refresh.
*
* @default true
*/
autoRefresh: z.boolean().default(true),
/**
* Debounce time in milliseconds for list changed notification processing.
*
* Multiple notifications received within this timeframe will only trigger one refresh.
* Set to `0` to disable debouncing.
*
* @default 300
*/
debounceMs: z.number().int().nonnegative().default(300)
});

/* Logging */
/**
* The severity of a log message.
Expand Down
2 changes: 1 addition & 1 deletion packages/core-internal/src/types/specTypeSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import * as schemas from './schemas';
* Explicit allowlist of protocol Zod schemas that correspond to a public spec type in `types.ts`.
*
* This intentionally excludes internal helper schemas exported from `schemas.ts` that have no
* matching public type (e.g. `ListChangedOptionsBaseSchema`, `BaseRequestParamsSchema`,
* matching public type (e.g. `BaseRequestParamsSchema`,
* `NotificationsParamsSchema`, `ClientTasksCapabilitySchema`, `ServerTasksCapabilitySchema`).
Comment on lines +24 to 25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The stale 'ListChangedOptionsBaseSchema' entry remains in the INTERNAL_HELPER_SCHEMAS array in packages/core-internal/test/types/specTypeSchema.test.ts (line 169), even though this PR moved that schema out of types/schemas.ts and removed it from the exclusion comment in specTypeSchema.ts and from the parallel list in packages/core/test/coreSchemas.test.ts. The entry is now dead (it can never match Object.keys(schemas)) and the array's "mirrors the exclusion comment" claim is no longer accurate — removing that one line keeps the three lists consistent.

Extended reasoning...

What the issue is: The PR relocates ListChangedOptionsBaseSchema from packages/core-internal/src/types/schemas.ts into the new packages/core-internal/src/types/listChangedOptions.ts (so it stays bundled while schemas.ts gets externalized to @modelcontextprotocol/core). The two lists that track schemas.ts internal helpers were updated accordingly: the exclusion comment in specTypeSchema.ts (this PR drops the name from the "e.g." list) and SPEC_INTERNAL_HELPERS in packages/core/test/coreSchemas.test.ts (entry removed in the diff). But the third parallel list — INTERNAL_HELPER_SCHEMAS in packages/core-internal/test/types/specTypeSchema.test.ts line 169 — still contains 'ListChangedOptionsBaseSchema'.\n\nCode path: That test does import * as schemas from '../../src/types/schemas' and its drift-guard filters Object.keys(schemas) against INTERNAL_HELPER_SCHEMAS to compute the set of exported schemas that must appear in SPEC_SCHEMA_KEYS. After this PR, schemas.ts no longer exports ListChangedOptionsBaseSchema, so the entry never matches anything — it is dead data.\n\nStep-by-step proof:\n1. Post-PR, grep 'export const ListChangedOptionsBaseSchema' packages/core-internal/src/types/schemas.ts returns nothing (the definition now lives in types/listChangedOptions.ts).\n2. The test's namespace import targets ../../src/types/schemas directly (not the types/index.ts barrel), so Object.keys(schemas) no longer contains 'ListChangedOptionsBaseSchema'.\n3. The filter !INTERNAL_HELPER_SCHEMAS.includes(name) therefore never sees that name; the entry has no effect on the assertion.\n4. The array's own comment (line 166) says it "Mirrors the exclusion comment in specTypeSchema.ts" — but this PR edited that exclusion comment to remove ListChangedOptionsBaseSchema, so the mirror claim is now stale.\n\nWhy nothing prevents it: The test still passes — an extra never-matching name in an exclusion list cannot fail the drift guard — so CI does not flag the leftover.\n\nImpact: Purely cosmetic/maintenance drift: a future reader comparing the three lists (specTypeSchema.ts comment, coreSchemas.test.ts, this test) will find them inconsistent, and the dead entry could mask intent if the schema name were ever reintroduced in schemas.ts.\n\nFix: Delete the 'ListChangedOptionsBaseSchema', line from INTERNAL_HELPER_SCHEMAS in packages/core-internal/test/types/specTypeSchema.test.ts (line 169), matching the update already made to the sibling list in packages/core/test/coreSchemas.test.ts.

* Keeping the list explicit means new public spec types must be added here deliberately, and
* internals never leak into `SpecTypeName`.
Expand Down
1 change: 0 additions & 1 deletion packages/core/test/coreSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ describe('@modelcontextprotocol/core', () => {
const SPEC_INTERNAL_HELPERS = [
'BaseRequestParamsSchema',
'ClientTasksCapabilitySchema',
'ListChangedOptionsBaseSchema',
'NotificationsParamsSchema',
'ServerTasksCapabilitySchema'
];
Expand Down
38 changes: 38 additions & 0 deletions packages/core/test/distEntry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { beforeAll, describe, expect, test } from 'vitest';

const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const distEntry = join(pkgDir, 'dist', 'index.mjs');

// client, server, and server-legacy resolve their schema modules from this
// package's dist entry at runtime, so a missing or incomplete build breaks
// every consumer of a built package (the repo's CI test job never runs a
// standalone build — dist only exists if a test builds it, the same way
// client's barrelClean.test.ts builds client). Build on demand and pin the
// runtime surface the boundary depends on.
describe('@modelcontextprotocol/core dist entry', () => {
beforeAll(() => {
if (!existsSync(distEntry)) {
execFileSync('pnpm', ['build'], { cwd: pkgDir, stdio: 'inherit' });
}
}, 120_000);

test('root entry resolves and exposes the runtime schema surface', async () => {
const mod = await import(distEntry);
for (const name of [
'CallToolResultSchema',
'InitializeRequestSchema',
'JSONRPCMessageSchema',
'OAuthMetadataSchema',
'OAuthProtectedResourceMetadataSchema'
]) {
expect(mod[name], `core dist entry must export ${name}`).toBeDefined();
}
// The externalized boundary re-exports the whole spec schema surface;
// a partial build would show up as a collapsed export count.
expect(Object.keys(mod).length).toBeGreaterThan(150);
});
});
1 change: 1 addition & 0 deletions packages/server-legacy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@modelcontextprotocol/core": "workspace:^",
"zod": "catalog:runtimeShared",
"raw-body": "catalog:runtimeServerOnly",
"content-type": "catalog:runtimeShared",
Expand Down
6 changes: 6 additions & 0 deletions packages/server-legacy/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { defineConfig } from 'tsdown';

import { externalizeCoreSchemas } from '../core-internal/externalizeCoreSchemas.tsdown.mjs';

export default defineConfig({
failOnWarn: 'ci-only',
// Resolve the published schema modules from @modelcontextprotocol/core at runtime instead of
// inlining them — see packages/core-internal/externalizeCoreSchemas.tsdown.mjs for the rationale
// and the build-time assertions that keep the rewrite honest.
plugins: [externalizeCoreSchemas()],
entry: ['src/index.ts', 'src/sse/index.ts', 'src/auth/index.ts'],
format: ['esm', 'cjs'],
fixedExtension: true,
Expand Down
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@modelcontextprotocol/core": "workspace:^",
"zod": "catalog:runtimeShared"
},
"devDependencies": {
Expand Down
Loading
Loading