Skip to content

Export Protocol and mergeCapabilities from client/server package roots#2501

Open
felixweinberger wants to merge 1 commit into
mainfrom
fweinberger/export-protocol
Open

Export Protocol and mergeCapabilities from client/server package roots#2501
felixweinberger wants to merge 1 commit into
mainfrom
fweinberger/export-protocol

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Makes the Protocol base class and mergeCapabilities importable from the @modelcontextprotocol/client and @modelcontextprotocol/server package roots.

Motivation and Context

Some SDK consumers use Protocol as a JSON-RPC engine for their own protocol rather than speaking MCP itself. ext-apps is the concrete case: App/AppBridge run a ui/initialize handshake over an iframe postMessage channel, and in v1 both extend Protocol directly. The v2 guidance (subclass Client/Server) forces the core MCP initialize handshake onto that channel — see modelcontextprotocol/ext-apps#710, where views end up performing a double handshake (initialize + ui/initialize) that deployed hosts reject with -32601.

v2's Protocol is still handshake-free (connect() only wires the transport) and custom methods pass the wire layer without era gating, so exporting the class restores the v1 extension point without behavior changes.

Also updates the codemod: Protocol / mergeCapabilities imports from shared/protocol.js now rewrite to the package root like the module's other symbols, instead of being dropped with an action-required marker.

How Has This Been Tested?

  • New pin tests: barrel + both package roots export the symbols; a Protocol subclass pair exchanges a custom method with no MCP initialize on the wire (both directions captured).
  • Full port of ext-apps to v2 built on this branch (packed tarballs): App/AppBridge extend Protocol, keep the v1 wire contract (ui/initialize is the first and only handshake), all 302 ext-apps tests pass, and the double-handshake workarounds from ext-apps#710 become deletable.
  • Codemod CLI driven on a v1 sample: import { Protocol, mergeCapabilities, type ProtocolOptions } from '.../shared/protocol.js' rewrites cleanly to @modelcontextprotocol/server; codemod suite green (629).

Breaking Changes

None — additive export. One caveat documented in the migration guide: the client and server packages each bundle their own compiled copy of the class, so import it from one package consistently within a process (instanceof and the nominal types don't mix across the two copies).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The removedSymbols codemod machinery now has no live mapping users (these two symbols were the last); it stays in place for future removals, with its tests re-pinned against a synthetic mapping. Happy to remove it in a follow-up if we'd rather not keep unused machinery.

Consumers that run their own JSON-RPC protocol on the Protocol engine
(e.g. the MCP Apps SDK's ui/initialize handshake over postMessage) have
no v2 extension point: subclassing Client/Server forces the MCP
initialize handshake onto channels whose deployed peers do not speak
it. Restore the v1 extension point by exporting Protocol and
mergeCapabilities from the curated public barrel, which both package
roots re-export. Protocol.connect() wires the transport without any
handshake, and non-spec methods pass the wire layer without era gating,
so the v1 wire posture is fully expressible.

The codemod now rewrites Protocol/mergeCapabilities imports from
shared/protocol.js to the package root like the module's other symbols;
its removedSymbols machinery keeps coverage via a synthetic mapping.
Root-level export pins prevent silent regression, and the migration
guide documents the subclass shape changes and the cross-bundle class
identity caveat (each root bundles its own copy).
@felixweinberger felixweinberger requested a review from a team as a code owner July 15, 2026 08:22
@changeset-bot

changeset-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f60b401

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2501

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2501

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2501

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2501

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2501

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2501

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2501

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2501

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2501

commit: f60b401

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Beyond the inline findings, one candidate was examined and ruled out: the concern that the newly-public Protocol rewrites handler-thrown -32002 to -32602 on custom methods. The 2025 codec's encodeErrorCode does apply that mapping era-blind, but it matches what the deployed v1.x SDK already emitted, so a v1 Protocol subclass migrating to this export sees identical wire behavior — no regression.

Extended reasoning...

Bugs were found (three nit-level findings, posted inline), so no approval — and independently, making the Protocol engine class public API is a deliberate surface-expansion decision a maintainer should sign off on, even though the ext-apps justification and test coverage (barrel/root pins, no-initialize-on-the-wire behavior pin, codemod suite) are solid. This note records the one refuted candidate so a later review pass does not re-derive it: I confirmed in packages/core-internal/src/wire/rev2025-11-25/codec.ts:137-141 that the -32002→-32602 mapping is documented as matching deployed v1.x behavior, so it is not a behavior change introduced by this PR.

Comment on lines 552 to 562
* features like request/response linking, notifications, and progress.
*
* `Protocol` is abstract; `Client` and `Server` are the concrete role-specific
* implementations most code should use.
* implementations most code should use. Consumers that run their own JSON-RPC
* protocol on the engine (custom methods, own handshake — e.g. the MCP Apps SDK)
* extend `Protocol` directly instead: `connect()` wires the transport without
* performing the MCP `initialize` handshake. Subclasses implement `buildContext`
* (identity is fine) and the three capability assertion hooks.
*/
export abstract class Protocol<ContextT extends BaseContext> {
private _transport?: Transport;

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 wire layer silently strips reserved MCP field names from custom-method traffic on the newly-public Protocol extension point, contradicting the changeset's claim that non-spec methods "pass through the wire layer without era gating": (1) _onrequest() unconditionally lifts top-level inputResponses/requestState out of every inbound request's params before the 3-arg handler's schema validation, and (2) the 2025 codec's decodeResult unconditionally deletes a top-level resultType key from any inbound result before the caller's schema validates it — a Protocol subclass never negotiates a version, so it is permanently on that codec. A custom protocol using any of these names gets a hard -32602/InvalidResult failure (required field) or silent data loss (optional field), where v1 passed both legs verbatim. Consider gating both strips on isSpecRequestMethod, or at minimum documenting these reserved top-level names in the new Protocol JSDoc and the migration guide's Protocol section.

Extended reasoning...

What happens

The PR frames the Protocol export as restoring the v1 extension point for consumers that run their own JSON-RPC protocol on the engine, and the changeset states that "non-spec methods pass through the wire layer without era gating." Two pre-existing wire-layer mechanisms break that contract for custom-method traffic — neither is gated on isSpecRequestMethod, unlike the era gates in the same dispatch funnel which explicitly treat non-spec methods as consumer-owned and era-blind.

Request legProtocol._onrequest() calls liftWireOnlyMaterial(rawRequest, 'request') unconditionally (packages/core-internal/src/shared/protocol.ts:934). RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] (line 198) is filtered purely by key in params (line 219) with no spec-method check, so those top-level params members are deleted from the params the handler sees on ANY request method. The 3-arg setRequestHandler path then validates the already-lifted {...request.params}. Additionally, a lifted inputResponses value is run through partitionInputResponses, which drops entries that don't look like bare MRTR response objects — real data loss for a custom protocol that reuses the name with its own shape. Notably, the lift's own comment carves out notifications for exactly this reason ("a vendor notification may legitimately use the same names") — custom-method requests get no equivalent carve-out.

Response leg — every Protocol.request() resolves through _requestWithSchemaViaCodec, whose response handler calls codec.decodeResult(request.method, response.result) before validating against the caller's schema. A direct Protocol subclass never negotiates a protocol version (skipping the MCP initialize handshake is the PR's entire premise), so codecForVersion(undefined) pins it to the 2025 codec (wire/codec.ts:298-300). That codec's decodeResult (wire/rev2025-11-25/codec.ts:110-121) ignores its _method parameter and unconditionally deletes a top-level resultType key from any plain-object result. The strip's tolerate-and-drop rationale (a 2025-era MCP peer sending 2026 vocabulary is misbehaving) is sound for MCP traffic but doesn't extend to custom protocols, where resultType is a perfectly natural result field name.

Step-by-step proof (response leg)

  1. Two Protocol subclasses A and B connect over any transport with no MCP handshake, per the new publicProtocol.test.ts pattern.
  2. B registers setRequestHandler('acme/search', { params: ..., result: ... }, handler) whose handler returns { resultType: 'partial', hits: [...] }. The 2025 codec's encodeResult is identity for non-tools/list methods, so the result goes out intact.
  3. A calls a.request({ method: 'acme/search', params }, z.object({ resultType: z.string(), hits: z.array(z.string()) })).
  4. On A, decodeResult hits the 'resultType' in raw branch, copies the object, deletes the key, and hands the husk to validateStandardSchema.
  5. A's schema requires resultType, so validation fails and every call rejects with SdkError(SdkErrorCode.InvalidResult, 'Invalid result for acme/search: ...') — the method is permanently broken even though B answered correctly. If the schema marks the field optional, the value silently vanishes instead.

The request leg has the mirror-image proof: B registers a params schema requiring requestState; A sends { doc: 'x', requestState: 's1' }; the lift deletes params.requestState before validation; B answers -32602 Invalid params despite the client having sent the field.

Why nothing else catches it

The new pin test (publicProtocol.test.ts) exercises only a result field named echoed and a param named value, so neither strip is on its path. The new Protocol JSDoc (protocol.ts:552-562), the migration guide's Protocol section, and docs/advanced documentation all describe the extension point without mentioning any reserved top-level names. v1's Protocol handed request.params to handlers verbatim and validated response.result directly against the caller's schema, so a v1 subclass migrating along the documented path can regress silently.

Why nit rather than blocking

Both mechanisms pre-date this PR and are deliberate for MCP traffic; the PR doesn't touch them — it publicizes the consumer class for which the field names matter. The trigger requires a custom protocol to pick exactly inputResponses, requestState, or resultType as a top-level name, and the motivating consumer (ext-apps, 302 passing tests) doesn't. Nothing shipped concretely breaks on merge; the gap is between the prose contract and the wire behavior.

Fix

Either gate both strips on isSpecRequestMethod — the retry-key lift in liftWireOnlyMaterial (mirroring its existing notification carve-out), and the resultType strip in rev2025Codec.decodeResult (which already receives the method as its first parameter, currently ignored as _method) — or document the reserved top-level names (inputResponses, requestState, resultType, plus the reserved io.modelcontextprotocol/* _meta envelope keys) in the new Protocol JSDoc and the migration guide's Protocol section, and soften the changeset's "without era gating" phrasing accordingly.

Comment on lines 174 to 177
'@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'
},

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.

Comment on lines +1447 to +1466
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).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant