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 f96b681bc7..69b28f3c61 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 21960a6176..2e4908bffd 100644 --- a/packages/core-internal/src/types/schemas.ts +++ b/packages/core-internal/src/types/schemas.ts @@ -1452,31 +1452,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/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); + }); +}); 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 d77e533db8..5e3a648857 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