diff --git a/.changeset/era-gate-explicit-schema-handlers.md b/.changeset/era-gate-explicit-schema-handlers.md new file mode 100644 index 0000000000..006026d419 --- /dev/null +++ b/.changeset/era-gate-explicit-schema-handlers.md @@ -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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 13e869c0f2..2b0e949151 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -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: @@ -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). diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 0a19770082..0f500f911f 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -559,6 +559,16 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = 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(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -994,11 +1004,27 @@ export abstract class Protocol { // 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; } @@ -1268,10 +1294,16 @@ export abstract class Protocol { ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { 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().`); @@ -1320,7 +1352,11 @@ export abstract class Protocol { * 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)) { @@ -1735,6 +1771,15 @@ export abstract class Protocol { 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)); } @@ -1771,6 +1816,7 @@ export abstract class Protocol { */ removeRequestHandler(method: RequestMethod | string): void { this._requestHandlers.delete(method); + this._customSchemaRequestMethods.delete(method); } /** diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 7672a8d64a..0510341eca 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -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 diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index fb2eb26d4e..6e3f04f072 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -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 @@ -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)) }); @@ -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