Skip to content
Open
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
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 `externalize-core-schemas` plugin in their tsdown configs) so one evaluated schema-graph copy is shared per application; their public surfaces stay Zod-free.

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 user-facing docs still describe the pre-PR relationship between @modelcontextprotocol/core and the client/server packages: docs/advanced/wire-schemas.md:59 says server and client "never depend on it" and docs/get-started/packages.md:69 says "you never install it", but after this PR core is a runtime dependency of client/server/server-legacy and their bundles import the schema modules from it. These two doc sentences should be updated in this PR alongside the CLAUDE.md change that already reflects the new relationship.

Extended reasoning...

What's stale. This PR turns @modelcontextprotocol/core into a real runtime dependency of client, server, and server-legacy: each package.json adds "@modelcontextprotocol/core": "workspace:^" to dependencies, and the new externalize-core-schemas plugin in the three tsdown configs rewrites imports of core-internal/src/types/schemas.ts and core-internal/src/shared/auth.ts to @modelcontextprotocol/core as an external, so the published bundles import the schema modules from that package at runtime. CLAUDE.md line 73 was updated to describe exactly this ("Consumed by the sibling packages at runtime…"), but the user-facing docs were not.\n\nWhere the docs now contradict the implementation.\n\n- docs/advanced/wire-schemas.md:59: "Install it separately (npm install @modelcontextprotocol/core) — @modelcontextprotocol/server and @modelcontextprotocol/client keep a Zod-free public surface and never depend on it." — the "never depend on it" clause is now flatly false, and "install it separately" is misleading since core now arrives transitively with client/server.\n- docs/get-started/packages.md:69: "…so if you only call registerTool and callTool you never install it." — after this PR core is always installed transitively whenever client or server is installed.\n\nConcrete walk-through. A user runs npm install @modelcontextprotocol/server. npm resolves dependencies and pulls @modelcontextprotocol/core into node_modules (the pnpm-lock.yaml diff in this PR shows the same for the workspace). The server bundle's dist/index.mjs now contains import … from '@modelcontextprotocol/core' where the schema graph used to be inlined, so the package is loaded at runtime, not just present on disk. Meanwhile the docs the user reads say the package is something they'd "install separately" and that server/client "never depend on it" — the opposite of what npm ls @modelcontextprotocol/core will show them.\n\nWhy nothing else prevents this. The parts of those sentences about the public surface remain true — client/server still export no Zod schemas and their public API stays Zod-free — so the pages don't fail any snippet-sync or example CI check; only the prose about the dependency graph is wrong, and nothing automated validates prose against package.json.\n\nImpact and fix. No runtime behavior is affected; this is documentation drift only, but it's drift introduced by this PR (which already fixed the equivalent sentence in CLAUDE.md), so it's cheapest to fix here. Suggested edits: in wire-schemas.md, replace "Install it separately … never depend on it" with wording like "It is installed automatically as a dependency of @modelcontextprotocol/server and @modelcontextprotocol/client (their bundles resolve the schema modules from it), but their public surfaces stay Zod-free — import from @modelcontextprotocol/core directly when you need the raw schemas." In packages.md, change "you never install it" to something like "you never need to import it directly (it's installed transitively with client/server)."


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:^",
"cross-spawn": "catalog:runtimeClientOnly",
Comment on lines +136 to 137

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 adds @modelcontextprotocol/core as a runtime dependency of @modelcontextprotocol/client, @modelcontextprotocol/server, and @modelcontextprotocol/server-legacy and changes their published dist output (schema modules resolved from core instead of inlined), but ships no .changeset/*.md entry — changeset-bot confirms "No Changeset found". Consider adding a patch changeset for the three affected packages so the perf improvement and the new dependency edge get a version bump and a changelog entry.

Extended reasoning...

What's missing

The PR changes the published runtime output of three packages — @modelcontextprotocol/client, @modelcontextprotocol/server, and @modelcontextprotocol/server-legacy — via the new externalize-core-schemas tsdown plugin (their bundles now import the spec + OAuth schema modules from @modelcontextprotocol/core at runtime instead of inlining a copy), and adds "@modelcontextprotocol/core": "workspace:^" as a runtime dependency in each of their package.json files. None of this reaches consumers until new versions are published, yet the PR contains no .changeset/*.md entry — the changeset-bot comment on this PR confirms "No Changeset found".

Why it matters

The repo's release flow is changesets-driven (.changeset/config.json with changelog-github, prerelease mode via pre.json), and every recent behavioral or packaging PR on main ships one — e.g. probe-window-handler-restore.md, standard-header-ows.md, malformed-resource-uri-invalid-params.md, and even pure packaging changes like cjs-dual-builds.md / cjs-ajv-validator-subpath.md. There is also a follow-up commit on main ("chore: add missing changesets for CommonJS builds") showing missing changesets are treated as something to fix. The changeset config only ignores example packages, so nothing auto-generates an entry for these three packages.

Concrete walk-through

  1. This PR merges as-is with no changeset.
  2. changesets sees no pending entry naming client/server/server-legacy from this PR, so this change alone triggers no version bump — the ~50ms cold-start improvement and the new core dependency edge stay unpublished.
  3. Later, an unrelated PR adds a changeset that bumps these packages (or the pending beta release train publishes them). The new versions now ship the externalized-schema bundles and the new runtime dependency on core, but the generated CHANGELOG for those versions contains no line describing that client/server/server-legacy now depend on @modelcontextprotocol/core at runtime — a fact consumers with strict lockfile/audit policies or bundler externals configuration may care about.

Why nothing breaks at merge time

The package.json files already carry the dependency edge, so any future publish is functionally correct; the repo is in prerelease mode and the change will still ride the next "Version Packages" release. The cost is only a delayed and undocumented release — a process/documentation gap rather than a runtime failure, which is why this is a nit rather than a blocking issue.

How to fix

Add a small patch changeset, e.g. .changeset/externalize-core-schemas.md:

---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/server-legacy': patch
---

perf: resolve the spec + OAuth schema modules from @modelcontextprotocol/core at runtime instead of inlining them, so applications using multiple SDK packages evaluate one shared schema-graph copy; core is now a regular runtime dependency of these packages.

(The changeset-bot comment even links a pre-filled template for this.)

"eventsource": "catalog:runtimeClientOnly",
"eventsource-parser": "catalog:runtimeClientOnly",
Expand Down
26 changes: 26 additions & 0 deletions packages/client/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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',
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 @@ -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.
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
26 changes: 26 additions & 0 deletions packages/server-legacy/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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,
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
26 changes: 26 additions & 0 deletions packages/server/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading