From f59ff4d5576f661ca36abe9a7a73d647f1df9f82 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 7 Jul 2026 09:50:01 +0000 Subject: [PATCH 1/4] Resolve schema modules from @modelcontextprotocol/core instead of inlining them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client, server, and server-legacy bundle @modelcontextprotocol/core-internal wholesale, which inlines the spec + OAuth Zod schema modules that are also published verbatim as @modelcontextprotocol/core. An application using client + server therefore evaluates the same large schema graph three times (and nests three copies of the code). Add an externalize-core-schemas rolldown plugin to the three configs that resolves core-internal/src/types/schemas.ts and core-internal/src/shared/auth.ts to @modelcontextprotocol/core at runtime, and declare core as a real dependency of the three packages. One evaluated schema-graph copy is now shared per application. Every name crossing the boundary at runtime is exported by core: - guards.ts / specTypeSchema.ts / client.ts runtime imports are all in core's export list (the specTypeSchema namespace access is keyed by the SPEC_SCHEMA_KEYS allowlist, which excludes internal helpers); - the remaining internal-only exports (BaseRequestParamsSchema, NotificationsParamsSchema, ClientTasksCapabilitySchema, ServerTasksCapabilitySchema, SafeUrlSchema, OptionalSafeUrlSchema) are only imported type-only or within the externalized modules themselves; - ListChangedOptionsBaseSchema was the one runtime-used SDK-internal helper living in schemas.ts, so it moves to types/listChangedOptions.ts and stays bundled (it is an SDK option schema, not a spec schema, and is deliberately not part of core's public surface). Type declarations still inline the schema types via the dts paths mappings — only runtime duplication costs. The wire-revision codec schemas and other core-internal modules remain bundled (they are private and not published anywhere), so client/server keep their direct zod dependency. --- CLAUDE.md | 2 +- packages/client/package.json | 1 + packages/client/tsdown.config.ts | 26 ++++++++++++++++ packages/core-internal/src/types/index.ts | 1 + .../src/types/listChangedOptions.ts | 31 +++++++++++++++++++ packages/core-internal/src/types/schemas.ts | 25 --------------- .../core-internal/src/types/specTypeSchema.ts | 2 +- packages/core/test/coreSchemas.test.ts | 1 - packages/server-legacy/package.json | 1 + packages/server-legacy/tsdown.config.ts | 26 ++++++++++++++++ packages/server/package.json | 1 + packages/server/tsdown.config.ts | 26 ++++++++++++++++ pnpm-lock.yaml | 9 ++++++ 13 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 packages/core-internal/src/types/listChangedOptions.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4682dc362f..4a15788098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `externalize-core-schemas` plugin in 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`. diff --git a/packages/client/package.json b/packages/client/package.json index 752b5a38c3..8cfd5d4226 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -133,6 +133,7 @@ "test:watch": "vitest" }, "dependencies": { + "@modelcontextprotocol/core": "workspace:^", "cross-spawn": "catalog:runtimeClientOnly", "eventsource": "catalog:runtimeClientOnly", "eventsource-parser": "catalog:runtimeClientOnly", diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 948cf95c88..9b5f37176e 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -1,7 +1,33 @@ import { defineConfig } from 'tsdown'; +/** + * core-internal's two schema source modules (types/schemas.ts + shared/auth.ts) are published + * verbatim as @modelcontextprotocol/core (see packages/core/tsdown.config.ts). Resolving them to + * that package at runtime — instead of inlining yet another copy — 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. + * Type declarations still inline the types via the dts `paths` mapping below — only runtime + * duplication costs. + */ +const CORE_SCHEMA_MODULES = /[\\/]core-internal[\\/]src[\\/](?:types[\\/]schemas|shared[\\/]auth)\.ts$/; + export default defineConfig({ failOnWarn: 'ci-only', + plugins: [ + { + name: 'externalize-core-schemas', + async resolveId(source, importer, options) { + if (importer === undefined) return null; + const resolved = await this.resolve(source, importer, options); + if (resolved !== null && CORE_SCHEMA_MODULES.test(resolved.id)) { + return { id: '@modelcontextprotocol/core', external: true }; + } + return null; + } + } + ], entry: [ 'src/index.ts', 'src/stdio.ts', diff --git a/packages/core-internal/src/types/index.ts b/packages/core-internal/src/types/index.ts index 46dd118f11..350ca12c09 100644 --- a/packages/core-internal/src/types/index.ts +++ b/packages/core-internal/src/types/index.ts @@ -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'; diff --git a/packages/core-internal/src/types/listChangedOptions.ts b/packages/core-internal/src/types/listChangedOptions.ts new file mode 100644 index 0000000000..441b7a6bc1 --- /dev/null +++ b/packages/core-internal/src/types/listChangedOptions.ts @@ -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) +}); diff --git a/packages/core-internal/src/types/schemas.ts b/packages/core-internal/src/types/schemas.ts index bc64597b98..90364913f0 100644 --- a/packages/core-internal/src/types/schemas.ts +++ b/packages/core-internal/src/types/schemas.ts @@ -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. diff --git a/packages/core-internal/src/types/specTypeSchema.ts b/packages/core-internal/src/types/specTypeSchema.ts index f7a109cbd5..5cb02590d7 100644 --- a/packages/core-internal/src/types/specTypeSchema.ts +++ b/packages/core-internal/src/types/specTypeSchema.ts @@ -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`). * Keeping the list explicit means new public spec types must be added here deliberately, and * internals never leak into `SpecTypeName`. diff --git a/packages/core/test/coreSchemas.test.ts b/packages/core/test/coreSchemas.test.ts index 85a0e08a09..fa7ac586af 100644 --- a/packages/core/test/coreSchemas.test.ts +++ b/packages/core/test/coreSchemas.test.ts @@ -37,7 +37,6 @@ describe('@modelcontextprotocol/core', () => { const SPEC_INTERNAL_HELPERS = [ 'BaseRequestParamsSchema', 'ClientTasksCapabilitySchema', - 'ListChangedOptionsBaseSchema', 'NotificationsParamsSchema', 'ServerTasksCapabilitySchema' ]; diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index acc880f6f6..36c5583aca 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -83,6 +83,7 @@ "test:watch": "vitest" }, "dependencies": { + "@modelcontextprotocol/core": "workspace:^", "zod": "catalog:runtimeShared", "raw-body": "catalog:runtimeServerOnly", "content-type": "catalog:runtimeShared", diff --git a/packages/server-legacy/tsdown.config.ts b/packages/server-legacy/tsdown.config.ts index b4c2881fd1..5da03a2c72 100644 --- a/packages/server-legacy/tsdown.config.ts +++ b/packages/server-legacy/tsdown.config.ts @@ -1,7 +1,33 @@ import { defineConfig } from 'tsdown'; +/** + * core-internal's two schema source modules (types/schemas.ts + shared/auth.ts) are published + * verbatim as @modelcontextprotocol/core (see packages/core/tsdown.config.ts). Resolving them to + * that package at runtime — instead of inlining yet another copy — 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. + * Type declarations still inline the types via the dts `paths` mapping — only runtime + * duplication costs. + */ +const CORE_SCHEMA_MODULES = /[\\/]core-internal[\\/]src[\\/](?:types[\\/]schemas|shared[\\/]auth)\.ts$/; + export default defineConfig({ failOnWarn: 'ci-only', + plugins: [ + { + name: 'externalize-core-schemas', + async resolveId(source, importer, options) { + if (importer === undefined) return null; + const resolved = await this.resolve(source, importer, options); + if (resolved !== null && CORE_SCHEMA_MODULES.test(resolved.id)) { + return { id: '@modelcontextprotocol/core', external: true }; + } + return null; + } + } + ], entry: ['src/index.ts', 'src/sse/index.ts', 'src/auth/index.ts'], format: ['esm', 'cjs'], fixedExtension: true, diff --git a/packages/server/package.json b/packages/server/package.json index d4a75c70fc..5a98083d13 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -133,6 +133,7 @@ "test:watch": "vitest" }, "dependencies": { + "@modelcontextprotocol/core": "workspace:^", "zod": "catalog:runtimeShared" }, "devDependencies": { diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index adec842c2f..efd271d75e 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -1,7 +1,33 @@ import { defineConfig } from 'tsdown'; +/** + * core-internal's two schema source modules (types/schemas.ts + shared/auth.ts) are published + * verbatim as @modelcontextprotocol/core (see packages/core/tsdown.config.ts). Resolving them to + * that package at runtime — instead of inlining yet another copy — 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. + * Type declarations still inline the types via the dts `paths` mapping below — only runtime + * duplication costs. + */ +const CORE_SCHEMA_MODULES = /[\\/]core-internal[\\/]src[\\/](?:types[\\/]schemas|shared[\\/]auth)\.ts$/; + export default defineConfig({ failOnWarn: 'ci-only', + plugins: [ + { + name: 'externalize-core-schemas', + async resolveId(source, importer, options) { + if (importer === undefined) return null; + const resolved = await this.resolve(source, importer, options); + if (resolved !== null && CORE_SCHEMA_MODULES.test(resolved.id)) { + return { id: '@modelcontextprotocol/core', external: true }; + } + return null; + } + } + ], entry: [ 'src/index.ts', 'src/stdio.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82fdee06b0..58132a04fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1290,6 +1290,9 @@ importers: packages/client: dependencies: + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core cross-spawn: specifier: catalog:runtimeClientOnly version: 7.0.6 @@ -1778,6 +1781,9 @@ importers: packages/server: dependencies: + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -1845,6 +1851,9 @@ importers: packages/server-legacy: dependencies: + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core content-type: specifier: catalog:runtimeShared version: 1.0.5 From 6ac1d06a05bb06601cf029a10f3716ac628403d0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 7 Jul 2026 17:02:50 +0000 Subject: [PATCH 2/4] Add a core dist-entry contract test that builds on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's CI test job never runs a standalone build — a package's dist exists only if one of its tests builds it (client's barrelClean.test.ts does this for client). Now that client, server, and server-legacy resolve their schema modules from @modelcontextprotocol/core's dist entry at runtime, a missing core build breaks every consumer of a built package, which is exactly how the examples' suites failed in CI. Mirror the barrelClean pattern: build core on demand in beforeAll, then pin the runtime surface the boundary depends on (representative spec + OAuth schema exports and the overall export count). --- packages/core/test/distEntry.test.ts | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/core/test/distEntry.test.ts diff --git a/packages/core/test/distEntry.test.ts b/packages/core/test/distEntry.test.ts new file mode 100644 index 0000000000..a3dc7f149b --- /dev/null +++ b/packages/core/test/distEntry.test.ts @@ -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); + }); +}); From 9443e627a29bbec46f9266a4b45ce75ff504c574 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 10 Jul 2026 14:06:56 +0000 Subject: [PATCH 3/4] Add changeset for schema externalization No-Verification-Needed: changeset-only release metadata --- .changeset/externalize-core-schemas.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/externalize-core-schemas.md diff --git a/.changeset/externalize-core-schemas.md b/.changeset/externalize-core-schemas.md new file mode 100644 index 0000000000..bcddd32041 --- /dev/null +++ b/.changeset/externalize-core-schemas.md @@ -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. From afcf2557bc23f18749db9f4924686c54c58fe876 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 10 Jul 2026 15:03:15 +0000 Subject: [PATCH 4/4] Assert the externalize-core-schemas rewrite at build time Extract the plugin duplicated across the client, server, and server-legacy tsdown configs into packages/core-internal/externalizeCoreSchemas.tsdown.mjs, and replace the path regex with an explicit list of the two schema modules. The build now fails loudly when the rewrite cannot have worked, in both directions: a listed module that is missing or never rewritten fails the build (renaming or moving a schema module previously re-inlined the whole schema graph into every consumer with all gates green), and a listed module that still reaches an emitted runtime chunk fails the build (a resolver id decoration bypassing the comparison). The checks look at emitted runtime chunks rather than the module graph because the d.ts pass loads the schema sources on purpose to inline type declarations. Also add a consumer-side dist test asserting the built client chunks import '@modelcontextprotocol/core' and link against its real export surface under plain-node ESM semantics, closing the hole where a value import of an SDK-internal helper schema would build fine but throw a link error in published consumers. --- CLAUDE.md | 2 +- .../client/externalizedCoreBoundary.test.ts | 53 +++++++++ packages/client/tsdown.config.ts | 30 +---- .../externalizeCoreSchemas.tsdown.d.mts | 16 +++ .../externalizeCoreSchemas.tsdown.mjs | 108 ++++++++++++++++++ packages/core-internal/package.json | 1 + packages/server-legacy/tsdown.config.ts | 30 +---- packages/server/tsdown.config.ts | 30 +---- pnpm-lock.yaml | 37 ++---- 9 files changed, 202 insertions(+), 105 deletions(-) create mode 100644 packages/client/test/client/externalizedCoreBoundary.test.ts create mode 100644 packages/core-internal/externalizeCoreSchemas.tsdown.d.mts create mode 100644 packages/core-internal/externalizeCoreSchemas.tsdown.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 4a15788098..e57cbf835f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). Consumed by the sibling packages at runtime: `client`/`server`/`server-legacy` bundles resolve the schema modules from this package (see the `externalize-core-schemas` plugin in their tsdown configs) so one evaluated schema-graph copy is shared per application; their public surfaces stay Zod-free. +- **`@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`. diff --git a/packages/client/test/client/externalizedCoreBoundary.test.ts b/packages/client/test/client/externalizedCoreBoundary.test.ts new file mode 100644 index 0000000000..6e573b5cf5 --- /dev/null +++ b/packages/client/test/client/externalizedCoreBoundary.test.ts @@ -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'))); +} + +// 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); + + 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); +}); diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 9b5f37176e..f5e0323c3a 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -1,33 +1,13 @@ import { defineConfig } from 'tsdown'; -/** - * core-internal's two schema source modules (types/schemas.ts + shared/auth.ts) are published - * verbatim as @modelcontextprotocol/core (see packages/core/tsdown.config.ts). Resolving them to - * that package at runtime — instead of inlining yet another copy — 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. - * Type declarations still inline the types via the dts `paths` mapping below — only runtime - * duplication costs. - */ -const CORE_SCHEMA_MODULES = /[\\/]core-internal[\\/]src[\\/](?:types[\\/]schemas|shared[\\/]auth)\.ts$/; +import { externalizeCoreSchemas } from '../core-internal/externalizeCoreSchemas.tsdown.mjs'; export default defineConfig({ failOnWarn: 'ci-only', - plugins: [ - { - name: 'externalize-core-schemas', - async resolveId(source, importer, options) { - if (importer === undefined) return null; - const resolved = await this.resolve(source, importer, options); - if (resolved !== null && CORE_SCHEMA_MODULES.test(resolved.id)) { - return { id: '@modelcontextprotocol/core', external: true }; - } - return null; - } - } - ], + // 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', diff --git a/packages/core-internal/externalizeCoreSchemas.tsdown.d.mts b/packages/core-internal/externalizeCoreSchemas.tsdown.d.mts new file mode 100644 index 0000000000..e810ca28c4 --- /dev/null +++ b/packages/core-internal/externalizeCoreSchemas.tsdown.d.mts @@ -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; diff --git a/packages/core-internal/externalizeCoreSchemas.tsdown.mjs b/packages/core-internal/externalizeCoreSchemas.tsdown.mjs new file mode 100644 index 0000000000..e0bfb490f8 --- /dev/null +++ b/packages/core-internal/externalizeCoreSchemas.tsdown.mjs @@ -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.` + ); + } + } + }; +} diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index f2ca34f5a2..b1b3bcee76 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -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" diff --git a/packages/server-legacy/tsdown.config.ts b/packages/server-legacy/tsdown.config.ts index 5da03a2c72..6e32bb39e9 100644 --- a/packages/server-legacy/tsdown.config.ts +++ b/packages/server-legacy/tsdown.config.ts @@ -1,33 +1,13 @@ import { defineConfig } from 'tsdown'; -/** - * core-internal's two schema source modules (types/schemas.ts + shared/auth.ts) are published - * verbatim as @modelcontextprotocol/core (see packages/core/tsdown.config.ts). Resolving them to - * that package at runtime — instead of inlining yet another copy — 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. - * Type declarations still inline the types via the dts `paths` mapping — only runtime - * duplication costs. - */ -const CORE_SCHEMA_MODULES = /[\\/]core-internal[\\/]src[\\/](?:types[\\/]schemas|shared[\\/]auth)\.ts$/; +import { externalizeCoreSchemas } from '../core-internal/externalizeCoreSchemas.tsdown.mjs'; export default defineConfig({ failOnWarn: 'ci-only', - plugins: [ - { - name: 'externalize-core-schemas', - async resolveId(source, importer, options) { - if (importer === undefined) return null; - const resolved = await this.resolve(source, importer, options); - if (resolved !== null && CORE_SCHEMA_MODULES.test(resolved.id)) { - return { id: '@modelcontextprotocol/core', external: true }; - } - return null; - } - } - ], + // 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, diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index efd271d75e..d811a4fed0 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -1,33 +1,13 @@ import { defineConfig } from 'tsdown'; -/** - * core-internal's two schema source modules (types/schemas.ts + shared/auth.ts) are published - * verbatim as @modelcontextprotocol/core (see packages/core/tsdown.config.ts). Resolving them to - * that package at runtime — instead of inlining yet another copy — 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. - * Type declarations still inline the types via the dts `paths` mapping below — only runtime - * duplication costs. - */ -const CORE_SCHEMA_MODULES = /[\\/]core-internal[\\/]src[\\/](?:types[\\/]schemas|shared[\\/]auth)\.ts$/; +import { externalizeCoreSchemas } from '../core-internal/externalizeCoreSchemas.tsdown.mjs'; export default defineConfig({ failOnWarn: 'ci-only', - plugins: [ - { - name: 'externalize-core-schemas', - async resolveId(source, importer, options) { - if (importer === undefined) return null; - const resolved = await this.resolve(source, importer, options); - if (resolved !== null && CORE_SCHEMA_MODULES.test(resolved.id)) { - return { id: '@modelcontextprotocol/core', external: true }; - } - return null; - } - } - ], + // 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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58132a04fc..d7a992f705 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1546,6 +1546,9 @@ importers: prettier: specifier: catalog:devTools version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) typescript: specifier: catalog:devTools version: 5.9.3 @@ -2238,10 +2241,6 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -2254,11 +2253,6 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -2268,10 +2262,6 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -7063,35 +7053,24 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 '@babel/runtime@7.29.2': {} - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -8871,7 +8850,7 @@ snapshots: ast-kit@2.2.0: dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 pathe: 2.0.3 async-function@1.0.0: {} @@ -10837,8 +10816,8 @@ snapshots: rolldown-plugin-dts@0.20.0(@typescript/native-preview@7.0.0-dev.20260327.2)(rolldown@1.0.0-beta.57)(typescript@5.9.3): dependencies: '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 ast-kit: 2.2.0 birpc: 4.0.0 dts-resolver: 2.1.3