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
8 changes: 8 additions & 0 deletions .changeset/era-gate-explicit-schema-handlers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Fixed the inbound era gate rejecting explicit-schema request handlers (`setRequestHandler(method, schemas, handler)`) for method names that a past protocol revision used for an unrelated core method, even though the current era's registry no longer defines that name at all. This made extension methods reusing a historical core name — like the Tasks extension's (SEP-2663) `tasks/get` and `tasks/cancel`, which collide with the 2025-11-25 core methods of the same name — permanently unreachable on the 2026-07-28 era: every inbound request answered `-32601 Method not found` before the registered handler was ever consulted, regardless of what the handler or its schema accepted.

The era gate now only blocks methods registered through the typed `setRequestHandler(method, handler)` overload (the SDK's own built-ins, like `initialize` or `ping`, correctly keep answering by absence once an era moves past them). A method registered with an explicit schema is the extension-authoring path, and the consumer's own schema now takes precedence over the historical registry collision.
23 changes: 16 additions & 7 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,17 @@ mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol ver
for requests; drop + `onerror` for notifications).

Methods deleted by a protocol revision are **physically absent** from that era's
registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a
handler is registered, and sending an era-mismatched spec method (e.g. `server/discover`
toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws
`SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport.
registry for TYPED dispatch: an inbound `tasks/get` handler registered via
`setRequestHandler('tasks/get', handler)` on a 2026-era connection still gets `-32601`,
and sending an era-mismatched spec method via the typed `request(method, options)` form
(e.g. `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era
peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the
transport. An EXPLICIT SCHEMA is the extension-authoring escape hatch and is exempt from
this gate in both directions — `setRequestHandler('tasks/get', { params, result },
handler)` is reachable, and `request({ method: 'tasks/get', params },
GetTaskResultSchema)` is sendable, on every era, so a historical core name an extension
reuses (e.g. the Tasks extension, SEP-2663) is never permanently blocked by a past
revision's registry entry.

If you were on a v2 alpha and consumed wire schemas directly:

Expand Down Expand Up @@ -684,9 +691,11 @@ methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult`
maps still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with
the `isCallToolResult` guard if you are pinned to one of those alphas. `2.0.0-alpha.4`
and later include the exclusion.) Where
task interop is genuinely required, use the explicit-schema custom-method form
(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*`
requests → `-32601`.
task interop is genuinely required, use the explicit-schema custom-method form on both
sides: `request({ method: 'tasks/get', params }, GetTaskResultSchema)` to send, and
`setRequestHandler('tasks/get', { params, result }, handler)` to serve — both reach the
wire/handler on every era, unlike the typed 2-arg overloads, which stay `-32601`/typed-error
gated for `tasks/*` on the 2026 era exactly like any other era-deleted spec method.

The experimental tasks **interception** layer is removed entirely — see
[upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed).
Expand Down
60 changes: 53 additions & 7 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,16 @@ export abstract class Protocol<ContextT extends BaseContext> {
private _transport?: Transport;
private _requestMessageId = 0;
private _requestHandlers: Map<string, (request: JSONRPCRequest, ctx: ContextT) => Promise<Result>> = new Map();
/**
* Methods registered through the explicit-schema `setRequestHandler(method, schemas,
* handler)` overload — the consumer supplied their own params/result schema rather than
* relying on a spec-registry entry. A name in this set is exempt from the spec-universe
* era gate in `_onrequest` (see the comment there): the consumer explicitly declared how
* to validate the method, so a historical core name reused by an extension (e.g.
* `tasks/get`) is served by the registered handler even on an era whose registry no
* longer defines it as a core method.
*/
private _customSchemaRequestMethods = new Set<string>();
private _requestHandlerAbortControllers: Map<RequestId, AbortController> = new Map();
private _notificationHandlers: Map<string, (notification: JSONRPCNotification, codec: WireCodec) => Promise<void>> = new Map();
private _responseHandlers: Map<number, (response: JSONRPCResultResponse | Error) => void> = new Map();
Expand Down Expand Up @@ -994,11 +1004,27 @@ export abstract class Protocol<ContextT extends BaseContext> {

// Era gate — deletions are physical: a spec method that is not in
// this era's registry is −32601 BY ABSENCE, before any handler
// lookup, even when a handler is registered (a custom handler cannot
// shadow a deleted spec method across eras). Methods outside the
// spec universe are consumer-owned extension methods and stay
// era-blind.
if (isSpecRequestMethod(request.method) && !codec.hasRequestMethod(request.method)) {
// lookup, even when a TYPED spec handler is registered for it (a
// `setRequestHandler(method, handler)` registration cannot shadow a
// deleted spec method across eras — this is how `initialize` stays
// unreachable once a 2026-era connection has negotiated past it).
// Methods outside the spec universe are consumer-owned extension
// methods and stay era-blind.
//
// A name that IS in the spec universe but was registered through the
// EXPLICIT-SCHEMA overload (`setRequestHandler(method, schemas,
// handler)`, tracked in `_customSchemaRequestMethods`) is exempt: the
// consumer supplied their own schema rather than relying on the
// era-registry entry, which is exactly the extension-method
// authoring path — some extensions (e.g. the Tasks extension,
// SEP-2663) reuse a name that a past core revision also used for an
// unrelated core method, and that historical collision should not
// make the extension's own handler unreachable.
if (
isSpecRequestMethod(request.method) &&
!codec.hasRequestMethod(request.method) &&
!this._customSchemaRequestMethods.has(request.method)
) {
sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found');
return;
}
Expand Down Expand Up @@ -1268,10 +1294,16 @@ export abstract class Protocol<ContextT extends BaseContext> {
): Promise<StandardSchemaV1.InferOutput<T>>;
request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise<unknown> {
const codec = this._resolveOutboundCodec(request.method);
this._assertOutboundRequestInEra(codec, request.method);
if (isStandardSchema(schemaOrOptions)) {
// Explicit schema: the extension-authoring path, symmetric with the
// inbound `setRequestHandler(method, schemas, handler)` exemption
// (#2598) — a name a past era's registry used for an unrelated core
// method (e.g. the Tasks extension's `tasks/get`) must not block a
// consumer's own schema-driven call just because the CURRENT era's
// registry doesn't define it as a core method either.
return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions);
}
this._assertOutboundRequestInEra(codec, request.method);
const validate = codecResultValidator(codec, request.method);
if (validate === undefined) {
throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`);
Expand Down Expand Up @@ -1320,7 +1352,11 @@ export abstract class Protocol<ContextT extends BaseContext> {
* directions: sending a spec method that the resolved era does not define
* dies locally with a typed error before anything reaches the transport.
* Methods outside the spec universe are consumer-owned extension methods
* and stay era-blind.
* and stay era-blind. Only applies to the TYPED dispatch path
* (`request(method, options)`, resolved via `codecResultValidator`) — the
* public `request()` overload skips this gate entirely when the caller
* passes an explicit result schema (mirrors the inbound exemption for
* `setRequestHandler(method, schemas, handler)`; see the comment there).
*/
private _assertOutboundRequestInEra(codec: WireCodec, method: string): void {
if (isSpecRequestMethod(method) && !codec.hasRequestMethod(method)) {
Expand Down Expand Up @@ -1735,6 +1771,15 @@ export abstract class Protocol<ContextT extends BaseContext> {
throw new TypeError('setRequestHandler: handler is required');
}

// Track explicit-schema registrations (see `_customSchemaRequestMethods`'s
// declaration) so the era gate can exempt them; a re-registration through the
// typed 2-arg overload reverts a method back to ordinary era gating.
if (typeof schemasOrHandler === 'function') {
this._customSchemaRequestMethods.delete(method);
} else {
this._customSchemaRequestMethods.add(method);
}

this._requestHandlers.set(method, this._wrapHandler(method, stored));
}

Expand Down Expand Up @@ -1771,6 +1816,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
*/
removeRequestHandler(method: RequestMethod | string): void {
this._requestHandlers.delete(method);
this._customSchemaRequestMethods.delete(method);
}

/**
Expand Down
29 changes: 20 additions & 9 deletions packages/core-internal/src/wire/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,29 @@
*
* Deletions are physical: registry membership is the deletion story. The
* 2026-era registry has no `tasks/*`, `initialize`, `ping`, `logging/setLevel`,
* `resources/(un)subscribe` or server→client wire-request entries, so an
* inbound era-mismatched method falls to −32601 by absence — even when a
* handler is registered — and an outbound one dies locally with a typed
* `SdkError` before anything reaches the transport. The 2025-era registry has
* no `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically.
* `resources/(un)subscribe` or server→client wire-request entries, so a
* TYPED-dispatch era-mismatched method falls to −32601 by absence inbound
* (`setRequestHandler(method, handler)`) or dies locally with a typed
* `SdkError` outbound (`request(method, options)`), before anything reaches
* the transport either way. The 2025-era registry has no
* `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically.
*
* Custom-handler shadowing policy (both directions): a method that belongs to
* the SPEC-METHOD UNIVERSE — the union of every codec's registry, derived,
* not hand-curated — is ALWAYS era-gated, so a custom handler registered for
* a deleted spec method (e.g. `tasks/get`) serves it only on the era that
* defines it. Methods outside the universe are consumer-owned extension
* methods: they are era-blind and require explicit schemas, exactly as today.
* not hand-curated — is era-gated UNLESS the consumer supplied their own
* schema for it: inbound via the explicit-schema overload
* (`setRequestHandler(method, schemas, handler)`, tracked by
* `Protocol#_customSchemaRequestMethods`), outbound via `request(request,
* resultSchema, options)`. A TYPED spec handler/call for a deleted spec
* method (e.g. legacy `tasks/get`) still serves/sends only on the era that
* defines it — that's how `initialize` stays unreachable once a 2026-era
* connection has negotiated past it. But an explicit schema is the
* extension-authoring path, and some extensions (e.g. the Tasks extension,
* SEP-2663) intentionally reuse a name a past core revision used for an
* unrelated core method — the historical collision must not make the
* extension's own handler/call unreachable in either direction. Methods
* outside the spec universe were always consumer-owned extension methods:
* they are era-blind and require explicit schemas, exactly as today.
*
* Everything in `wire/` is internal to the bundled, `private: true` core —
* nothing per-revision is public surface, and nothing here may ever be
Expand Down
57 changes: 49 additions & 8 deletions packages/core-internal/test/wire/eraGates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@
* Registry membership is the deletion story, and these tests prove it at the
* protocol funnels, in both directions:
*
* - inbound: `tasks/get` on a modern-era instance gets −32601 BY ABSENCE —
* even with a handler registered (a custom handler cannot shadow a
* deleted spec method across eras); era-deleted spec notifications are
* - inbound: a TYPED spec handler (`setRequestHandler(method, handler)`)
* cannot shadow a deleted spec method across eras — `ping` on a
* modern-era instance still answers −32601 BY ABSENCE. An
* EXPLICIT-SCHEMA handler (`setRequestHandler(method, schemas, handler)`)
* for the same kind of name IS reachable regardless of era-registry
* absence — `tasks/get` served on a modern-era instance, because the
* consumer's own schema is the extension-authoring path (issue #2598:
* the Tasks extension, SEP-2663, reuses a name a past core revision also
* used for an unrelated core method). Era-deleted spec notifications are
* silently dropped even with a handler registered.
* - outbound: an era-mismatched spec method dies locally with
* `SdkErrorCode.MethodNotSupportedByProtocolVersion` before anything
Expand Down Expand Up @@ -109,31 +115,51 @@ const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record<

describe('inbound era gates — deletions are physical, era is instance state', () => {
const registerTasksGetHandler = (onRun: () => void) => (receiver: TestProtocol) => {
// A custom (3-arg) handler deliberately shadowing the deleted
// spec method: it may serve the 2025 era only.
// An explicit-schema (3-arg) handler: the consumer supplies their own
// schema for a name the era registry doesn't define as a core
// method, which is the extension-authoring path (#2598) — it is
// reachable on every era, not shadowed by the deletion gate.
receiver.setRequestHandler('tasks/get', { params: z.looseObject({ taskId: z.string() }) }, () => {
onRun();
return {} as Result;
});
};

test('a modern-era instance answers tasks/get with −32601 BY ABSENCE even with a handler registered', async () => {
test('a modern-era instance still serves tasks/get through an explicit-schema handler (#2598)', async () => {
let handlerRan = false;
const h = await harness({ era: '2026-07-28', setup: registerTasksGetHandler(() => (handlerRan = true)) });

// A matching modern classification rides along untouched — the
// handoff check accepts it; the era gate still answers by absence.
// handoff check accepts it; the era gate no longer answers by
// absence when an explicit-schema handler is registered.
h.deliver(
{ jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage,
MODERN
);
await h.flush();

expect(handlerRan).toBe(true);
expect(resultOf(h.sent[0])).toBeDefined();
});

test('a modern-era instance still answers −32601 BY ABSENCE for a deleted spec method with no handler registered', async () => {
const h = await harness({ era: '2026-07-28' });

h.deliver(
{ jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage,
MODERN
);
await h.flush();

expect(handlerRan).toBe(false);
expect(h.sent).toHaveLength(1);
expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' });
});

// The built-in `ping` handler (registered via the typed 2-arg overload
// in the `Protocol` constructor) demonstrates the typed path stays fully
// era-gated — see 'ping on a modern-era instance is −32601 by absence'
// below: only explicit-schema registrations are exempt.

test('a legacy-era instance (the default) serves tasks/get with that handler — era is fixed per instance', async () => {
let handlerRan = false;
const h = await harness({ setup: registerTasksGetHandler(() => (handlerRan = true)) });
Expand Down Expand Up @@ -534,6 +560,21 @@ describe('outbound era gates — typed local error before the transport', () =>
expect(h.sent).toHaveLength(0);
});

test('the public request() overload sends tasks/get with an explicit schema even on a 2026-era instance (#2598)', async () => {
const h = await harness({ era: '2026-07-28' });

const pending = h.receiver.request({ method: 'tasks/get', params: { taskId: 't-1' } }, z.looseObject({}));
await h.flush();

// Reached the transport (unlike the typed-dispatch case above, which
// never gets past the local era gate) — no peer is listening in this
// harness, so the request itself is left pending; asserting on
// `h.sent` is enough to prove it was NOT rejected locally.
expect(h.sent).toHaveLength(1);
expect(h.sent[0]).toMatchObject({ method: 'tasks/get', params: { taskId: 't-1' } });
pending.catch(() => {});
});

test('pre-negotiation bootstrap pins still route initialize to the 2025 era', async () => {
// An instance with NO negotiated version may always send the legacy
// handshake; setting a modern version afterwards closes it (the pin
Expand Down