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
14 changes: 14 additions & 0 deletions .changeset/export-protocol-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/codemod': patch
---

Export the `Protocol` base class and `mergeCapabilities` from the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` package roots.

Some SDK consumers build their own JSON-RPC protocols on the `Protocol` engine rather than speaking MCP itself — ext-apps (MCP Apps) runs its `ui/initialize` handshake over an iframe postMessage channel this way. Subclassing `Client`/`Server` forces the core MCP `initialize` handshake onto such channels, which their deployed peers do not understand. Exporting `Protocol` restores the v1 extension point: `Protocol.connect()` wires the transport without any handshake, and non-spec methods pass through the wire layer without era gating.

The supporting protocol types (`ProtocolOptions`, `BaseContext`, `RequestOptions`, `Transport`, …) were already public.

The codemod now rewrites `Protocol` and `mergeCapabilities` imports from `shared/protocol.js` to the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The SDK is organized into three main layers:
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/core-internal/public`** (`packages/core-internal/src/exports/public/index.ts`) — Curated public API. Exports TypeScript types, error classes, constants, guards, and the `Protocol` base class (+ `mergeCapabilities`) for consumers that build custom JSON-RPC protocols on the engine. 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 and the canonical home of the schema source modules (`src/schemas.ts`, `src/auth.ts`, `src/constants.ts`). The root entry re-exports **only** the `*Schema` Zod constants (MCP spec + OAuth/OpenID) — the published home for raw runtime validation (`CallToolResultSchema.parse(...)`); runtime-neutral (`zod` is its only dependency). The `./internal` subpath re-exports the schema modules wholesale for the sibling packages: `core-internal` re-exports them at the old module paths, and the `client`/`server`/`server-legacy` bundles resolve `@modelcontextprotocol/core/internal` as a real external dependency instead of carrying their own schema copies (their public surfaces stay Zod-free).

Expand Down
33 changes: 23 additions & 10 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@
`completable(schema, cb).optional()` (see
[Standard Schema objects](#standard-schema-objects-raw-shapes-deprecated)); shapes it
cannot invert get an `@mcp-codemod-error` marker.
- Drops `Protocol` / `mergeCapabilities` from `shared/protocol.js` imports, re-exports,
mocks, and dynamic imports — no v2 package exports them — leaving a marker with the
replacement at each site.
- Rewrites `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the
client or server package root, like the module's other symbols (see
[Removed type aliases](#removed-type-aliases) for the subclass shape changes).

## What the codemod does NOT handle

Expand Down Expand Up @@ -1444,13 +1444,26 @@
objects. A payload typed `Record<string, unknown>` no longer assigns (`TS2322`) — give
the source a JSON-compatible type or cast at the boundary.

The `Protocol` base class itself is no longer exported (it is internal engine). If you
were reaching into protocol internals — rare, mostly debugging tools —
`client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every
inbound request that no registered handler matches, before capability gating. Delete
the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod
drops `Protocol` (and `mergeCapabilities`) from the rewritten import and leaves an
`@mcp-codemod-error` marker at the site explaining the replacement.
The `Protocol` base class and `mergeCapabilities` moved: import them from the
`@modelcontextprotocol/client` or `@modelcontextprotocol/server` package root instead
of `shared/protocol.js`. Extending `Protocol` directly remains the right shape for
consumers that run their own JSON-RPC protocol on the SDK engine (custom methods, own
handshake) — `Protocol.connect()` wires the transport without performing the MCP
`initialize` handshake. Subclasses adapt to the v2 shape: one `ContextT` type
parameter instead of the three message generics, a `buildContext` implementation
(identity is fine), and method-string-keyed handler registration (see
[`setRequestHandler` / `setNotificationHandler` use method strings](#setrequesthandler--setnotificationhandler-use-method-strings)). If you
were only reaching into protocol internals for
debugging, prefer `client.fallbackRequestHandler` / `server.fallbackRequestHandler`,
which receives every inbound request that no registered handler matches, before
capability gating. The codemod rewrites `Protocol` and `mergeCapabilities` imports to
the package root, like the module's other symbols.

One caveat: the client and server packages each bundle their own compiled copy of the
class, so the two roots' `Protocol` exports are distinct classes. Import it from one
package consistently within a process — `instanceof` returns `false` across the two
copies, and their type declarations are nominally incompatible (the class has private
members).

Check warning on line 1466 in docs/migration/upgrade-to-v2.md

View check run for this annotation

Claude / Claude Code Review

New public Protocol extension point has no prose docs outside the migration guide

The newly-public `Protocol` extension point is only documented in prose inside the v1→v2 migration guide, which is addressed to migrating users — a new v2 consumer has no discoverable docs that `Protocol` is subclassable, how to implement it, or the dual-bundled-copy `instanceof` caveat (which lives only here). Consider adding a short section or pointer in `docs/advanced/custom-methods.md` (and/or an examples/ story) covering the custom-JSON-RPC-engine use case.
Comment on lines +1447 to +1466

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 newly-public Protocol extension point is only documented in prose inside the v1→v2 migration guide, which is addressed to migrating users — a new v2 consumer has no discoverable docs that Protocol is subclassable, how to implement it, or the dual-bundled-copy instanceof caveat (which lives only here). Consider adding a short section or pointer in docs/advanced/custom-methods.md (and/or an examples/ story) covering the custom-JSON-RPC-engine use case.

Extended reasoning...

What the finding is. This PR makes Protocol and mergeCapabilities public API on the @modelcontextprotocol/client and @modelcontextprotocol/server package roots, positioned as the supported extension point for consumers that build their own JSON-RPC protocols on the SDK engine (the ext-apps ui/initialize use case). However, the only prose documentation added is the section in docs/migration/upgrade-to-v2.md (~lines 1447–1466) — content framed for v1 migrators ("The Protocol base class and mergeCapabilities moved: import them from … instead of shared/protocol.js"). There is no guide page, section, or example addressed to a new v2 consumer.

Verification. Grepping docs/ for extends Protocol, Protocol base class, subclass Protocol, or custom JSON-RPC matches nothing outside the migration guide. docs/advanced/custom-methods.md — the natural home, since it already documents custom JSON-RPC methods on Client/Server via setRequestHandler — never mentions that Protocol is public and directly subclassable. mergeCapabilities appears in no non-migration doc. No examples/ story demonstrates extends Protocol. The only other documentation is the (good) JSDoc on the Protocol class in packages/core-internal/src/shared/protocol.ts.

Why this matters. Concrete walkthrough of the gap: a developer starting fresh on v2 wants to run a custom handshake protocol over an SDK transport (exactly the ext-apps shape this PR exists to support). They browse docs/advanced/custom-methods.md, custom-transports.md, low-level-server.md — and find only the Client/Server custom-method path, which still forces the MCP initialize handshake onto their channel. Nothing tells them (1) that Protocol is importable from the package root, (2) what a subclass must implement (buildContext — identity is fine — plus the three capability assertion hooks), or (3) the dual-bundled-copy caveat: the client and server packages each bundle their own compiled copy of the class, so instanceof returns false across the two copies and the nominal types don't mix. That last caveat is a real consumer-facing gotcha, and it is currently discoverable only via the migration guide. The repo review checklist also lists, for new features, "verify prose documentation is added (not just JSDoc), and assess whether examples/ needs a new or updated example" as a distinct item from the migration-guide item.

Why nit, not blocking. Prose documentation does exist (migration guide + changeset + JSDoc), the primary audience for this feature today is migrating v1 consumers like ext-apps — who are well served by the migration guide — and there is zero runtime impact. The export itself is well-tested (barrel + both package roots + a no-initialize-on-the-wire behavior pin).

Suggested fix. Add a short section (or pointer) in docs/advanced/custom-methods.md covering the "build your own protocol on the engine" case: import Protocol from one package root, implement buildContext + the three assertion hooks, note that connect() performs no MCP initialize, and include the single-package-consistently / instanceof caveat. Optionally add an examples/ story mirroring packages/core-internal/test/shared/publicProtocol.test.ts. This can equally land as a fast follow-up.


#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106)

Expand Down
17 changes: 17 additions & 0 deletions packages/client/test/client/protocolExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Pins that the `Protocol` base class and `mergeCapabilities` are exported from
* the package ROOT — not just from the core-internal public barrel. The root's
* `export * from '@modelcontextprotocol/core-internal/public'` is what carries
* them; replacing it with named exports (or adding a colliding explicit export)
* would silently drop them while the barrel-level pin stays green.
*/
import { describe, expect, test } from 'vitest';

import { mergeCapabilities, Protocol } from '../../src/index';

describe('package root exports', () => {
test('Protocol and mergeCapabilities are exported from the client root', () => {
expect(typeof Protocol).toBe('function');
expect(typeof mergeCapabilities).toBe('function');
});
});
4 changes: 2 additions & 2 deletions packages/codemod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ guards that mix the two enums), add `import { z } from 'zod'` when a wrap needs
it, rewrite `vi.mock`
/ `jest.mock` / dynamic `import()` paths, invert optional completable nesting
(`completable(schema.optional(), cb)` becomes `completable(schema, cb).optional()`),
and drop `Protocol` / `mergeCapabilities` (no v2 export) with an action-required
marker naming the replacement.
and rewrite `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the
client or server package root.

## `@mcp-codemod-error` markers

Expand Down
12 changes: 1 addition & 11 deletions packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,20 +171,10 @@
ResourceTemplate: 'ResourceTemplateType'
}
},
'@modelcontextprotocol/sdk/shared/protocol.js': {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved',
removedSymbols: {
Protocol:
'The Protocol base class is not exported by the v2 packages. To observe or handle inbound requests ' +
'that have no registered handler, use client.fallbackRequestHandler / server.fallbackRequestHandler; ' +
'build custom behavior on Client or Server instead of subclassing Protocol. ' +
'See the migration guide: Behavioral changes > Client connection & dispatch.',
mergeCapabilities:
'mergeCapabilities() is not exported by the v2 packages. Pass the complete capabilities object to the ' +
'Client/Server constructor, or merge capability objects with a plain object spread.'
}
status: 'moved'
},

Check warning on line 177 in packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts

View check run for this annotation

Claude / Claude Code Review

Codemod can silently split Protocol imports across client/server roots, creating the dual-class instanceof hazard the guide warns about

Routing `Protocol` through `RESOLVE_BY_CONTEXT` resolves per file, so in a mixed client+server project one codemod run can silently rewrite file A's `Protocol` import to `@modelcontextprotocol/client` and file B's to `@modelcontextprotocol/server` — exactly the dual-copy configuration this PR's migration-guide caveat says to avoid (`instanceof` is false across the two compiled copies and the nominal types are incompatible). Consider emitting an info/action-required diagnostic when the `Protocol`
Comment on lines 174 to 177

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Routing Protocol through RESOLVE_BY_CONTEXT resolves per file, so in a mixed client+server project one codemod run can silently rewrite file A's Protocol import to @modelcontextprotocol/client and file B's to @modelcontextprotocol/server — exactly the dual-copy configuration this PR's migration-guide caveat says to avoid (instanceof is false across the two compiled copies and the nominal types are incompatible). Consider emitting an info/action-required diagnostic when the Protocol value symbol is routed by context in a both project, or when one run resolves it to different packages across files.

Extended reasoning...

The bug. This PR removes the removedSymbols entry for Protocol/mergeCapabilities from the shared/protocol.js mapping (packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts:174-177), so Protocol now routes through the generic RESOLVE_BY_CONTEXT path like the module's structural types. But context resolution is per file: resolveTypesPackage (packages/codemod/src/utils/projectAnalyzer.ts:118-135) returns @modelcontextprotocol/client when a file has client-only sibling imports and @modelcontextprotocol/server when it has server-only siblings, before the project-level projectType is consulted. There is no run-level consistency mechanism for a symbol across files.

The code path. Consider a projectType: 'both' codebase — e.g. the exact shape of this PR's motivating consumer, ext-apps, where App (client-side, imports client SDK siblings) and AppBridge (host-side, imports server siblings) both extend Protocol. Step by step:

  1. app.ts imports { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js' alongside @modelcontextprotocol/sdk/client/... imports. fileHasClientImports=true, fileHasServerImports=false → rewritten to @modelcontextprotocol/client.
  2. app-bridge.ts imports the same symbol alongside @modelcontextprotocol/sdk/server/... imports → rewritten to @modelcontextprotocol/server.
  3. No diagnostic fires for either file — the info note in resolveTypesPackage only fires for no-signal files, and its text ("both client and server re-export them… importing from either compiles") was written for structural types; it is now misleading for the nominally-typed Protocol class.
  4. The two package roots each bundle their own compiled copy of the class (per this PR's own migration-guide caveat in docs/migration/upgrade-to-v2.md), so x instanceof Protocol is false across the copies, and since Protocol has many private members, the two declarations are nominally incompatible — cross-file type usage produces confusing TS2322 errors.

Why existing code doesn't prevent it. Pre-PR, Protocol imports were dropped with an @mcp-codemod-error marker, forcing a deliberate manual choice of one package — the split was impossible to produce mechanically. Post-PR, the codemod can mechanically produce the configuration the guide explicitly instructs users to avoid ("import it from one package consistently within a process"), with zero signal.

Impact. Bounded, which is why this is a nit rather than a blocker: (1) the trigger requires a mixed project importing Protocol in multiple files with divergent sibling signals and cross-file reliance on class identity; (2) the type-level mismatch surfaces at typecheck as TS2322 (confusing but discoverable), leaving only runtime instanceof across copies as a truly silent failure; (3) the same per-file routing already applies to other classes (InMemoryTransport, UriTemplate) without diagnostics, so this extends an existing accepted pattern rather than introducing a new one.

How to fix. Emit an info/action-required diagnostic when the Protocol value symbol specifically is routed by context in a both-signal project (or, more precisely, when one codemod run resolves it to different package roots across files), pointing at the migration-guide caveat. Also consider adjusting the no-signal info note so it doesn't claim "importing from either compiles" when the routed symbols include the nominal Protocol class.

'@modelcontextprotocol/sdk/shared/transport.js': {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,9 @@ export const importPathsTransform: Transform = {
);
}
}
// Qualified accesses to symbols with no v2 export (`ns.Protocol`) can't be fixed by
// Qualified accesses to symbols with no v2 export (`ns.GoneClass`) can't be fixed by
// moving the namespace binding — flag each accessed one. Expression positions are
// PropertyAccessExpressions; type positions (`let p: ns.Protocol`) are QualifiedNames.
// PropertyAccessExpressions; type positions (`let p: ns.GoneClass`) are QualifiedNames.
if (mapping.removedSymbols) {
const nsName = namespaceImport.getText();
const accessedRemoved = new Map<string, Node>();
Expand Down
Loading
Loading