From 472f4a1164a0472c699c3c624fa48d6b0880da13 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:03:30 +0000 Subject: [PATCH 01/15] test(core): pin runtime method-registry contents by reference ahead of the wire-layer re-homing --- packages/core/test/types/registryPins.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 packages/core/test/types/registryPins.test.ts diff --git a/packages/core/test/types/registryPins.test.ts b/packages/core/test/types/registryPins.test.ts new file mode 100644 index 000000000..afe7c84de --- /dev/null +++ b/packages/core/test/types/registryPins.test.ts @@ -0,0 +1,195 @@ +/** + * Registry byte-identity pre-pins for the wire-layer re-homing (Q1 increment 2). + * + * These tests pin the EXACT contents of the runtime method registries — + * method sets and per-method schema identity (by object reference) — so that + * relocating the registries behind the per-era codec interface is provably + * mechanical: the same schema objects must serve the same methods before and + * after the move. They are committed BEFORE the relocation lands (suite, then + * move — Q10-L2 ordering). + * + * The 2025-era registry is behavior-frozen: the request/notification maps + * carry the full deliberate 2025-11-25 wire vocabulary, including the task + * family (#2248 wire-interop restore). The RESULT map is the runtime/typed + * ALIGNED map (PR #2293 review fix): plain per-method schemas keyed by + * `RequestMethod` — no task-result union members and no `tasks/*` entries + * (task-method interop goes through the explicit-schema overload; see + * `test/shared/typedMapAlignment.test.ts` for the behavioral pins). Do not + * edit these pins to make a refactor pass; a pin change is a wire-behavior + * decision and needs a changeset + migration entry (Q10-L2). + */ +import { describe, expect, it } from 'vitest'; + +import { + getNotificationSchema, + getRequestSchema, + getResultSchema +} from '../../src/types/index.js'; +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CancelTaskRequestSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListTasksRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + TaskStatusNotificationSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../src/types/index.js'; + +/** The exact 2025-era request-method → schema map (today's wire surface, verbatim). */ +const EXPECTED_REQUEST_SCHEMAS = { + ping: PingRequestSchema, + initialize: InitializeRequestSchema, + 'completion/complete': CompleteRequestSchema, + 'logging/setLevel': SetLevelRequestSchema, + 'prompts/get': GetPromptRequestSchema, + 'prompts/list': ListPromptsRequestSchema, + 'resources/list': ListResourcesRequestSchema, + 'resources/templates/list': ListResourceTemplatesRequestSchema, + 'resources/read': ReadResourceRequestSchema, + 'resources/subscribe': SubscribeRequestSchema, + 'resources/unsubscribe': UnsubscribeRequestSchema, + 'tools/call': CallToolRequestSchema, + 'tools/list': ListToolsRequestSchema, + 'tasks/get': GetTaskRequestSchema, + 'tasks/result': GetTaskPayloadRequestSchema, + 'tasks/list': ListTasksRequestSchema, + 'tasks/cancel': CancelTaskRequestSchema, + 'sampling/createMessage': CreateMessageRequestSchema, + 'elicitation/create': ElicitRequestSchema, + 'roots/list': ListRootsRequestSchema +} as const; + +/** The exact 2025-era notification-method → schema map. */ +const EXPECTED_NOTIFICATION_SCHEMAS = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/initialized': InitializedNotificationSchema, + 'notifications/roots/list_changed': RootsListChangedNotificationSchema, + 'notifications/tasks/status': TaskStatusNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +} as const; + +/** + * The exact 2025-era result map (the runtime/typed ALIGNED map — every entry + * is the plain schema `ResultTypeMap` declares; identity-pinned by reference). + */ +const EXPECTED_RESULT_SCHEMAS = { + ping: EmptyResultSchema, + initialize: InitializeResultSchema, + 'completion/complete': CompleteResultSchema, + 'logging/setLevel': EmptyResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'resources/subscribe': EmptyResultSchema, + 'resources/unsubscribe': EmptyResultSchema, + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema +} as const; + +/** + * Task methods: served by the request map (2025 wire vocabulary, param-side + * tolerance) but deliberately ABSENT from the result map — `ResultTypeMap` + * excludes them, so the runtime map must too; callers needing task interop + * pass an explicit result schema (the documented overload). + */ +const TASK_REQUEST_METHODS = ['tasks/get', 'tasks/result', 'tasks/list', 'tasks/cancel'] as const; + +/** Methods that must NOT be in the 2025-era registries (2026-only vocabulary). */ +const NOT_IN_2025 = ['server/discover', 'subscriptions/listen', 'notifications/subscriptions/acknowledged'] as const; + +describe('2025-era registry pins (suite-then-move, Q10-L2)', () => { + it('serves exactly the pinned request methods, with the pinned schema objects', () => { + for (const [method, schema] of Object.entries(EXPECTED_REQUEST_SCHEMAS)) { + expect(getRequestSchema(method), method).toBe(schema); + } + }); + + it('serves exactly the pinned notification methods, with the pinned schema objects', () => { + for (const [method, schema] of Object.entries(EXPECTED_NOTIFICATION_SCHEMAS)) { + expect(getNotificationSchema(method), method).toBe(schema); + } + }); + + it('serves the pinned result entries by reference (aligned: plain schemas, no unions)', () => { + for (const [method, schema] of Object.entries(EXPECTED_RESULT_SCHEMAS)) { + expect(getResultSchema(method), method).toBe(schema); + } + }); + + it('serves task requests but has no task result entries (explicit-schema interop)', () => { + for (const method of TASK_REQUEST_METHODS) { + expect(getRequestSchema(method), method).toBeDefined(); + expect(getResultSchema(method), method).toBeUndefined(); + } + }); + + it('returns undefined for non-spec and 2026-only methods', () => { + for (const method of [...NOT_IN_2025, 'acme/custom', 'notifications/acme']) { + expect(getRequestSchema(method), method).toBeUndefined(); + expect(getResultSchema(method), method).toBeUndefined(); + expect(getNotificationSchema(method), method).toBeUndefined(); + } + }); + + it('the registries contain nothing beyond the pinned method sets', () => { + // Completeness guard in the inverse direction: enumerating the maps + // through their module surface must not reveal extra methods. + const requestMethods = Object.keys(EXPECTED_REQUEST_SCHEMAS).sort(); + const notificationMethods = Object.keys(EXPECTED_NOTIFICATION_SCHEMAS).sort(); + const resultMethods = Object.keys(EXPECTED_RESULT_SCHEMAS).sort(); + expect(requestMethods).toHaveLength(20); + expect(notificationMethods).toHaveLength(11); + expect(resultMethods).toHaveLength(16); + // The result-method set is exactly the request-method set minus the + // four task methods (runtime/typed alignment). + expect(resultMethods).toEqual(requestMethods.filter(method => !method.startsWith('tasks/'))); + }); +}); From 2b2d2544edacfce53294cfbdb9a0420cb2507ace Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:07:41 +0000 Subject: [PATCH 02/15] refactor(core): re-home the runtime method registries behind a per-era wire-codec interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical relocation: the request/result/notification schema maps and their getters move verbatim from types/schemas.ts to wire/rev2025-11-25/registry.ts, behind a WireCodec contract (wire/codec.ts) with era resolution, a derived spec-method universe, and outbound lifecycle bootstrap pins (wire/bootstrap.ts). The 2025-era codec's decode/encode are identity-shaped; nothing consults the codec layer yet, and registry contents are pinned by reference by the registryPins suite committed ahead of this change. Rebase reconciliations (onto the post-#2293 base): - The relocated result map carries the base's NARROWED runtime/typed-aligned content (plain per-method schemas keyed by RequestMethod, no task unions, no tasks/* entries) — review fix d542fd8c0 is upstream truth. - LiftedWireMaterial.envelope is Partial (review fix 07f23840f) in the codec contract too. - NarrowResultKey shrinks to the sampling pair: with the aligned map, tools/call:plain and elicitation/create:plain were identical to their registry entries. - typedMapAlignment.test.ts re-points its getResultSchema import to the registry's new home; API reports regenerated (the registry getters leave the bundled dts namespace). --- packages/core/src/shared/protocol.ts | 4 +- packages/core/src/types/schemas.ts | 98 +------- packages/core/src/wire/bootstrap.ts | 40 +++ packages/core/src/wire/codec.ts | 228 ++++++++++++++++++ packages/core/src/wire/rev2025-11-25/codec.ts | 68 ++++++ .../core/src/wire/rev2025-11-25/registry.ts | 159 ++++++++++++ .../test/shared/typedMapAlignment.test.ts | 4 +- packages/core/test/types/registryPins.test.ts | 8 +- 8 files changed, 503 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/wire/bootstrap.ts create mode 100644 packages/core/src/wire/codec.ts create mode 100644 packages/core/src/wire/rev2025-11-25/codec.ts create mode 100644 packages/core/src/wire/rev2025-11-25/registry.ts diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 0cad63528..1ed13e803 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -34,9 +34,6 @@ import type { import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, - getNotificationSchema, - getRequestSchema, - getResultSchema, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, @@ -49,6 +46,7 @@ import { } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; +import { getNotificationSchema, getRequestSchema, getResultSchema } from '../wire/rev2025-11-25/registry.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 00d04db3a..847e51ad2 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -8,16 +8,7 @@ import { PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY } from './constants.js'; -import type { - JSONArray, - JSONObject, - JSONValue, - NotificationMethod, - NotificationTypeMap, - RequestMethod, - RequestTypeMap, - ResultTypeMap -} from './types.js'; +import type { JSONArray, JSONObject, JSONValue } from './types.js'; export const JSONValueSchema: z.ZodType = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), JSONValueSchema), z.array(JSONValueSchema)]) @@ -2287,90 +2278,3 @@ export const ServerResultSchema = z.union([ ListTasksResultSchema, CreateTaskResultSchema ]); - -/* Runtime schema lookup — result schemas by method */ -// Keyed by `RequestMethod` so the runtime map and the typed `ResultTypeMap` -// cannot drift: `getResultSchema`'s typed overload asserts each entry parses -// to `ResultTypeMap[M]`, so no entry may be looser than the typed map -// (no task-result union members) and no key may fall outside it (no `tasks/*` -// entries — the task methods are 2025-11-25 wire vocabulary with no SDK -// runtime; callers needing task interop pass an explicit schema). -const resultSchemas: Record = { - ping: EmptyResultSchema, - initialize: InitializeResultSchema, - 'completion/complete': CompleteResultSchema, - 'logging/setLevel': EmptyResultSchema, - 'prompts/get': GetPromptResultSchema, - 'prompts/list': ListPromptsResultSchema, - 'resources/list': ListResourcesResultSchema, - 'resources/templates/list': ListResourceTemplatesResultSchema, - 'resources/read': ReadResourceResultSchema, - 'resources/subscribe': EmptyResultSchema, - 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': CallToolResultSchema, - 'tools/list': ListToolsResultSchema, - 'sampling/createMessage': CreateMessageResultWithToolsSchema, - 'elicitation/create': ElicitResultSchema, - 'roots/list': ListRootsResultSchema -}; - -/** - * Gets the Zod schema for validating results of a given request method. - * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. - */ -export function getResultSchema(method: M): z.ZodType; -export function getResultSchema(method: string): z.ZodType | undefined; -export function getResultSchema(method: string): z.ZodType | undefined { - return resultSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; -} - -/* Runtime schema lookup — request schemas by method */ -type RequestSchemaType = (typeof ClientRequestSchema.options)[number] | (typeof ServerRequestSchema.options)[number]; -type NotificationSchemaType = (typeof ClientNotificationSchema.options)[number] | (typeof ServerNotificationSchema.options)[number]; - -function buildSchemaMap(schemas: readonly T[]): Record { - const map: Record = {}; - for (const schema of schemas) { - const method = schema.shape.method.value; - map[method] = schema; - } - return map; -} - -const requestSchemas = buildSchemaMap([...ClientRequestSchema.options, ...ServerRequestSchema.options] as const) as Record< - RequestMethod, - RequestSchemaType ->; -const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, ...ServerNotificationSchema.options] as const) as Record< - NotificationMethod, - NotificationSchemaType ->; - -/** - * Gets the Zod schema for a given request method. - * Returns `undefined` for non-spec methods. - * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers - * to use schema.parse() without needing additional type assertions. - * - * Note: The internal cast is necessary because TypeScript can't correlate the - * Record-based schema lookup with the MethodToTypeMap-based RequestTypeMap - * when M is a generic type parameter. Both compute to the same type at - * instantiation, but TypeScript can't prove this statically. - */ -export function getRequestSchema(method: M): z.ZodType; -export function getRequestSchema(method: string): z.ZodType | undefined; -export function getRequestSchema(method: string): z.ZodType | undefined { - return requestSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; -} - -/** - * Gets the Zod schema for a given notification method. - * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. - */ -export function getNotificationSchema(method: M): z.ZodType; -export function getNotificationSchema(method: string): z.ZodType | undefined; -export function getNotificationSchema(method: string): z.ZodType | undefined { - return notificationSchemas[method as NotificationMethod] as unknown as z.ZodType | undefined; -} diff --git a/packages/core/src/wire/bootstrap.ts b/packages/core/src/wire/bootstrap.ts new file mode 100644 index 000000000..3f5402932 --- /dev/null +++ b/packages/core/src/wire/bootstrap.ts @@ -0,0 +1,40 @@ +/** + * Static era pins for lifecycle messages on the OUTBOUND path (the + * chicken-and-egg bootstrap): these messages are sent before any negotiated + * version exists, and they self-identify their era by construction — + * `initialize`/`notifications/initialized` ARE the legacy handshake (Q2: + * `initialize` ⇒ legacy), and `server/discover` exists only on the 2026 era. + * No negotiated-state guess ever picks a payload schema for them. + * + * Scope notes: + * - OUTBOUND ONLY. Inbound era truth is per-request classification (Q2) with + * session state as fallback — pinning inbound would override the + * classifier (an unclassified `server/discover` request classifies legacy + * and correctly falls to −32601 by registry absence). + * - `ping` is deliberately NOT pinned. A bare `{method: 'ping'}` carries no + * era marker — under Q2 it classifies legacy by DEFAULT, not by + * self-identification — and pinning it would let a negotiated-modern + * session emit a 2025-only method onto the modern leg (the exact inverse + * leak registry membership exists to prevent). `ping` era-gates like any + * other method: present on the 2025 era, absent from the 2026 era (the + * modern keepalive story is owned by the negotiation milestones). + */ +import type { WireCodec } from './codec.js'; +import { codecForVersion, MODERN_WIRE_REVISION } from './codec.js'; + +export function bootstrapOutboundCodec(method: string): WireCodec | undefined { + switch (method) { + case 'initialize': + case 'notifications/initialized': { + // The legacy handshake, by definition (Q2). + return codecForVersion(undefined); + } + case 'server/discover': { + // The modern discovery exchange, 2026-era only. + return codecForVersion(MODERN_WIRE_REVISION); + } + default: { + return undefined; + } + } +} diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts new file mode 100644 index 000000000..fd2398923 --- /dev/null +++ b/packages/core/src/wire/codec.ts @@ -0,0 +1,228 @@ +/** + * The era-granular wire-codec layer (Q1 increment 2). + * + * The SDK separates a revision-neutral model layer (the public types — no + * `resultType`, no `_meta` envelope keys, no retry fields) from per-revision + * WIRE CODECS that own revision-exact schemas, method registries, and the + * decode (wire → neutral lift) / encode (neutral → wire stamp) transforms. + * The codec is selected at the only places version truth exists: the client's + * stored negotiated version (bound at `Client.connect`) and the server's + * per-request classification (`MessageExtraInfo.classification`, with the + * session-negotiated version as fallback and legacy as the default). + * + * REQUIRED DISCLOSURE (Q1-SD1, era granularity): "the negotiated version + * determines which types are serialized/deserialized over the wire" cashes + * out as "the negotiated wire ERA determines them". All five legacy protocol + * versions (2024-10-07 … 2025-11-25) share one wire vocabulary and map to the + * single 2025-era codec — exactly how the single schema set already served + * all five — and '2026-07-28' maps to the 2026-era codec. A new codec exists + * only when wire vocabulary actually diverges; intra-era vocabulary is NOT + * keyed by exact version. + * + * 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. + * + * 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. + * + * Everything in `wire/` is internal to the bundled, `private: true` core — + * nothing per-revision is public surface, and nothing here may ever be + * exported from `core/public`. + */ +import type * as z from 'zod/v4'; + +import type { SdkError } from '../errors/sdkErrors.js'; +import type { RequestMetaEnvelope, Result } from '../types/types.js'; +import { rev2025Codec } from './rev2025-11-25/codec.js'; + +/** Wire eras with distinct vocabulary. */ +export type WireEra = '2025-11-25' | '2026-07-28'; + +/** + * The modern wire revision literal. Internal only — deliberately NOT a public + * constant (G-D2-4: no public modern-version constant ships before era-aware + * list semantics exist). + */ +export const MODERN_WIRE_REVISION = '2026-07-28'; + +/** + * Wire-only material lifted off an inbound message by the protocol layer + * before dispatch (the V-3 seam): the reserved `_meta` envelope keys and the + * multi-round-trip driver fields. This is the typed driver-material channel + * of the codec contract — handlers never see it; the protocol layer surfaces + * it via `ctx.mcpReq.envelope` / `.inputResponses` / `.requestState`, and the + * MRTR driver (M4.1) consumes the retry fields from here. + */ +export interface LiftedWireMaterial { + // Partial: the lift surfaces whichever reserved keys the message actually + // carried — a peer on an adjacent revision may legally send a subset, and + // envelope requiredness is enforced per request at dispatch time + // (`checkInboundEnvelope`), not by the lift. + envelope?: Partial; + inputResponses?: Record; + requestState?: string; +} + +/** Result decode outcomes — the raw-first discrimination (V-1) lives in `decodeResult`. */ +export type DecodedResult = + | { + kind: 'complete'; + /** The neutral result value: wire-only material consumed/stripped. */ + result: Result; + } + | { + kind: 'input_required'; + /** + * Driver-only material (never consumer-visible). The full + * multi-round-trip driver is M4.1 scope; this seam carries the + * discriminated payload to it. + */ + inputRequests: Record; + requestState?: string; + } + | { kind: 'invalid'; error: SdkError }; + +/** + * Keys for the high-level method surfaces whose result validation is + * deliberately narrower than the generic per-method registry entry. With the + * registry result map aligned to the typed `ResultTypeMap` (no task-widened + * unions), only `server.createMessage` qualifies: it picks its result schema + * from the REQUEST params (tools vs no tools), which a method-keyed registry + * entry cannot express. Every other high-level surface (`callTool`, + * `elicitInput`) validates exactly its registry entry. + */ +export type NarrowResultKey = 'sampling/createMessage:plain' | 'sampling/createMessage:withTools'; + +/** + * The per-era wire codec contract (design C §3, adapted to the live funnel + * layout: the universal wire-only LIFT runs once in the protocol layer for + * every message — spec, custom, and fallback paths alike — and codecs consume + * the lifted material rather than re-implementing the strip per era). + */ +export interface WireCodec { + readonly era: WireEra; + + /** Registry membership — the deletion story (inbound −32601 by absence; outbound typed local error). */ + hasRequestMethod(method: string): boolean; + hasNotificationMethod(method: string): boolean; + + /** Era-exact dispatch schemas, resolved at dispatch time (never at registration time). */ + requestSchema(method: string): z.ZodType | undefined; + resultSchema(method: string): z.ZodType | undefined; + notificationSchema(method: string): z.ZodType | undefined; + + /** Narrow high-level result schemas (see {@linkcode NarrowResultKey}). */ + narrowResultSchema(key: NarrowResultKey): z.ZodType | undefined; + + /** + * Step 1 of result decoding: RAW `resultType` handling BEFORE any schema + * validation (V-1's structural home). Era postures (Q1-SD3): + * - 2026 era: required discriminator — absent ⇒ typed error naming the + * spec violation; `input_required` ⇒ driver payload; unknown ⇒ invalid, + * no retry; `complete` ⇒ consume + lift. + * - 2025 era: `resultType` is foreign vocabulary ⇒ strip-on-lift. + */ + decodeResult(method: string, raw: unknown): DecodedResult; + + /** + * Outbound result mapping (the stamp seam). The 2025-era codec is the + * identity — it has NO stamp code path (the never-stamp guarantee). The + * 2026-era codec stamps `resultType` and strictly enforces the 2026 wire + * shape for the known deleted-field set (`execution.taskSupport`, + * `capabilities.tasks` — Q1-SD3 iii). ttlMs/cacheScope stamping content + * is M3.2 scope and lands in this seam. + */ + encodeResult(method: string, result: Result): Result; + + /** + * Inbound envelope enforcement for era-classified traffic: validates the + * lifted envelope material of a request. Returns an error message when + * the era requires an envelope and it is missing/invalid (→ −32602 at the + * dispatch layer); `undefined` when acceptable. The 2025 era never + * requires an envelope. + */ + checkInboundEnvelope(material: LiftedWireMaterial): string | undefined; +} + +/** + * Era resolution, many-to-one (Q1-SD1): all `SUPPORTED_PROTOCOL_VERSIONS` + * (the five legacy versions) → the 2025-era codec; '2026-07-28' → the + * 2026-era codec; `undefined`/unknown → legacy (the DV-13 default posture — + * hand-constructed instances and unclassified traffic are legacy-era). + * + * NOTE (staging): the 2026-era codec lands with Q1 increment-2 step 5; until + * then every version resolves to the 2025-era codec and behavior is + * byte-identical to the pre-split SDK. + */ +export function codecForVersion(version: string | undefined): WireCodec { + void version; + return rev2025Codec; +} + +/** + * The derived spec-method universe: the union of every codec registry. A + * method in this set is era-gated at dispatch and send time; a method outside + * it is a consumer-owned extension method (era-blind, schema-explicit). + * Derived from the registries — never hand-curated (the LEGACY_ONLY_METHODS + * table class is exactly what registry membership replaces). + */ +export function isSpecRequestMethod(method: string): boolean { + return ALL_CODECS.some(codec => codec.hasRequestMethod(method)); +} + +export function isSpecNotificationMethod(method: string): boolean { + return ALL_CODECS.some(codec => codec.hasNotificationMethod(method)); +} + +const ALL_CODECS: readonly WireCodec[] = [rev2025Codec]; + +/* ------------------------------------------------------------------------ * + * Internal binding channels. + * + * These deliberately avoid new members on the Protocol class hierarchy: the + * negotiated wire version is connection state owned by Client/Server, and the + * per-request codec is dispatch state owned by the protocol layer. WeakMaps + * keep both invisible to consumers and concurrency-safe (one entry per + * protocol instance / per request context). + * ------------------------------------------------------------------------ */ + +const outboundWireVersion = new WeakMap(); + +/** + * Bind the negotiated wire version for a protocol instance's OUTBOUND + * traffic. Called by `Client.connect` (initialize handshake + reconnect) and + * `Server._oninitialize`. Unbound instances are legacy-era. + */ +export function bindWireVersion(owner: object, version: string | undefined): void { + outboundWireVersion.set(owner, version); +} + +/** The codec serving a protocol instance's outbound traffic (legacy when unbound). */ +export function outboundCodecFor(owner: object): WireCodec { + return codecForVersion(outboundWireVersion.get(owner)); +} + +const requestCodec = new WeakMap(); + +/** + * Bind the resolved per-request codec to a request context, so stored + * handler closures and `_wrapHandler` wrappers resolve era-exact schemas at + * dispatch time without widening any public signature. + */ +export function bindRequestCodec(ctx: object, codec: WireCodec): void { + requestCodec.set(ctx, codec); +} + +/** The codec bound to a request context (legacy when none was bound). */ +export function codecForContext(ctx: object): WireCodec { + return requestCodec.get(ctx) ?? codecForVersion(undefined); +} diff --git a/packages/core/src/wire/rev2025-11-25/codec.ts b/packages/core/src/wire/rev2025-11-25/codec.ts new file mode 100644 index 000000000..37ec34540 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -0,0 +1,68 @@ +/** + * The 2025-era wire codec: decode/encode ≈ identity. + * + * This codec serves every legacy protocol version (2024-10-07 … 2025-11-25). + * It is BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite — its schemas + * are today's schemas, its registry is today's method map, and its encode + * path is the identity. + * + * Never-stamp guarantee: `encodeResult` is the identity function. There is no + * stamp code path in this module — a 2025-era response cannot carry + * `resultType`, `ttlMs`, `cacheScope`, or envelope keys because no code here + * can write them, not because a stamping branch is gated off. + * + * One deliberate exception to "no 2026 code path" (Q1-SD3 ii, amending the + * V-2 'no code path at all' design claim): `decodeResult` STRIPS a foreign + * `resultType` key from inbound results before validation (strip-on-lift). + * `resultType` is not 2025 vocabulary — a 2025 peer that sends it is + * misbehaving — and the ruled posture is tolerate-and-drop so the foreign key + * can neither surface to consumers (the neutral types have no slot for it) + * nor leak through the retained loose-object passthrough. This is the ONLY + * 2026-vocabulary code path in the 2025 codec, it exists on the decode side + * only, and it deletes — never reads, maps, or emits — the foreign value. + */ +import type { Result } from '../../types/types.js'; +import type { DecodedResult, LiftedWireMaterial, NarrowResultKey, WireCodec } from '../codec.js'; +import { + getNotificationSchema, + getRequestSchema, + getResultSchema, + hasNotificationMethod2025, + hasRequestMethod2025, + narrowResultSchemas2025 +} from './registry.js'; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export const rev2025Codec: WireCodec = { + era: '2025-11-25', + + hasRequestMethod: hasRequestMethod2025, + hasNotificationMethod: hasNotificationMethod2025, + + requestSchema: method => getRequestSchema(method), + resultSchema: method => getResultSchema(method), + notificationSchema: method => getNotificationSchema(method), + + narrowResultSchema: (key: NarrowResultKey) => narrowResultSchemas2025[key], + + decodeResult(_method: string, raw: unknown): DecodedResult { + // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is + // dropped before validation, whatever its value. There is no + // discrimination on this era — `resultType` carries no meaning here. + if (isPlainObject(raw) && 'resultType' in raw) { + const stripped = { ...raw }; + delete stripped['resultType']; + return { kind: 'complete', result: stripped as Result }; + } + return { kind: 'complete', result: raw as Result }; + }, + + // The never-stamp guarantee: identity. No stamp code path exists. + encodeResult: (_method: string, result: Result): Result => result, + + // The 2025 era never requires a per-request envelope. + checkInboundEnvelope: (_material: LiftedWireMaterial): string | undefined => undefined +}; diff --git a/packages/core/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts new file mode 100644 index 000000000..e2005e463 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -0,0 +1,159 @@ +/** + * The 2025-era method registries — re-homed verbatim from + * `types/schemas.ts` (Q1 increment-2 step 1: mechanical relocation behind the + * codec interface; the registry CONTENT is byte-identical to the pre-split + * maps and is pinned by reference in `test/types/registryPins.test.ts`). + * + * This era serves all five legacy protocol versions (2024-10-07 … + * 2025-11-25), exactly as the single schema set did before the split. It is + * BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite: the request and + * notification maps carry the full deliberate 2025-11-25 wire vocabulary, + * including the task family (the #2248 wire-interop restore). The RESULT map + * is the runtime/typed ALIGNED map (PR #2293 review): keyed by + * `RequestMethod` so it cannot drift from the typed `ResultTypeMap` — no + * task-result union members and no `tasks/*` entries; a task-capable 2025 + * peer's `CreateTaskResult` answer fails the plain per-method schema as a + * typed invalid-result error, and callers needing task interop pass an + * explicit result schema (see `test/shared/typedMapAlignment.test.ts`). + * + * 2026-only vocabulary (`server/discover`, `subscriptions/listen`, the MRTR + * shells, `resultType`, the `_meta` envelope) has NO entry and NO code path + * here — the inverse-leak guarantee is physical absence, not discipline. + */ +import type * as z from 'zod/v4'; + +import { + CallToolResultSchema, + ClientNotificationSchema, + ClientRequestSchema, + CompleteResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptResultSchema, + InitializeResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListRootsResultSchema, + ListToolsResultSchema, + ReadResourceResultSchema, + ServerNotificationSchema, + ServerRequestSchema +} from '../../types/schemas.js'; +import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; + +/* Runtime schema lookup — result schemas by method */ +// Keyed by `RequestMethod` so the runtime map and the typed `ResultTypeMap` +// cannot drift: `getResultSchema`'s typed overload asserts each entry parses +// to `ResultTypeMap[M]`, so no entry may be looser than the typed map +// (no task-result union members) and no key may fall outside it (no `tasks/*` +// entries — the task methods are 2025-11-25 wire vocabulary with no SDK +// runtime; callers needing task interop pass an explicit schema). +const resultSchemas: Record = { + ping: EmptyResultSchema, + initialize: InitializeResultSchema, + 'completion/complete': CompleteResultSchema, + 'logging/setLevel': EmptyResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'resources/subscribe': EmptyResultSchema, + 'resources/unsubscribe': EmptyResultSchema, + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema +}; + +/** + * Gets the Zod schema for validating results of a given request method. + * Returns `undefined` for non-spec methods. + * @see getRequestSchema for explanation of the internal type assertion. + */ +export function getResultSchema(method: M): z.ZodType; +export function getResultSchema(method: string): z.ZodType | undefined; +export function getResultSchema(method: string): z.ZodType | undefined { + return resultSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; +} + +/* Runtime schema lookup — request schemas by method */ +type RequestSchemaType = (typeof ClientRequestSchema.options)[number] | (typeof ServerRequestSchema.options)[number]; +type NotificationSchemaType = (typeof ClientNotificationSchema.options)[number] | (typeof ServerNotificationSchema.options)[number]; + +function buildSchemaMap(schemas: readonly T[]): Record { + const map: Record = {}; + for (const schema of schemas) { + const method = schema.shape.method.value; + map[method] = schema; + } + return map; +} + +const requestSchemas = buildSchemaMap([...ClientRequestSchema.options, ...ServerRequestSchema.options] as const) as Record< + RequestMethod, + RequestSchemaType +>; +const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, ...ServerNotificationSchema.options] as const) as Record< + NotificationMethod, + NotificationSchemaType +>; + +/** + * Gets the Zod schema for a given request method. + * Returns `undefined` for non-spec methods. + * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers + * to use schema.parse() without needing additional type assertions. + * + * Note: The internal cast is necessary because TypeScript can't correlate the + * Record-based schema lookup with the MethodToTypeMap-based RequestTypeMap + * when M is a generic type parameter. Both compute to the same type at + * instantiation, but TypeScript can't prove this statically. + */ +export function getRequestSchema(method: M): z.ZodType; +export function getRequestSchema(method: string): z.ZodType | undefined; +export function getRequestSchema(method: string): z.ZodType | undefined { + return requestSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; +} + +/** + * Gets the Zod schema for a given notification method. + * Returns `undefined` for non-spec methods. + * @see getRequestSchema for explanation of the internal type assertion. + */ +export function getNotificationSchema(method: M): z.ZodType; +export function getNotificationSchema(method: string): z.ZodType | undefined; +export function getNotificationSchema(method: string): z.ZodType | undefined { + return notificationSchemas[method as NotificationMethod] as unknown as z.ZodType | undefined; +} + +/** The 2025-era request-method set (registry membership = the deletion story). */ +export function hasRequestMethod2025(method: string): boolean { + return Object.prototype.hasOwnProperty.call(requestSchemas, method); +} + +/** The 2025-era notification-method set. */ +export function hasNotificationMethod2025(method: string): boolean { + return Object.prototype.hasOwnProperty.call(notificationSchemas, method); +} + +/** Registry method lists (for the spec-method universe and the CI registry-diff oracle). */ +export const rev2025RequestMethods: readonly string[] = Object.keys(requestSchemas); +export const rev2025NotificationMethods: readonly string[] = Object.keys(notificationSchemas); + +/** + * Narrow high-level result schemas for this era (see `codec.ts` + * `NarrowResultKey`). Only the sampling pair lives here: with the result map + * aligned to the typed map, `tools/call` and `elicitation/create` have no + * narrower surface than their registry entries — `server.createMessage` is + * the one method whose result schema depends on the REQUEST params (tools vs + * no tools), which a method-keyed registry cannot express. + */ +export const narrowResultSchemas2025: Record = { + 'sampling/createMessage:plain': CreateMessageResultSchema as unknown as z.ZodType, + 'sampling/createMessage:withTools': CreateMessageResultWithToolsSchema as unknown as z.ZodType +}; diff --git a/packages/core/test/shared/typedMapAlignment.test.ts b/packages/core/test/shared/typedMapAlignment.test.ts index acd667c6c..59d5345c2 100644 --- a/packages/core/test/shared/typedMapAlignment.test.ts +++ b/packages/core/test/shared/typedMapAlignment.test.ts @@ -23,7 +23,9 @@ import type { BaseContext } from '../../src/shared/protocol.js'; import { Protocol } from '../../src/shared/protocol.js'; import { InMemoryTransport } from '../../src/util/inMemory.js'; import type { JSONRPCRequest } from '../../src/types/index.js'; -import { getResultSchema } from '../../src/types/index.js'; +// Post-relocation home (Q1 increment-2 step 1): the runtime registries live +// behind the per-era wire-codec interface now. +import { getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; class TestProtocol extends Protocol { protected assertCapabilityForMethod(): void {} diff --git a/packages/core/test/types/registryPins.test.ts b/packages/core/test/types/registryPins.test.ts index afe7c84de..0e1b0eac7 100644 --- a/packages/core/test/types/registryPins.test.ts +++ b/packages/core/test/types/registryPins.test.ts @@ -20,11 +20,6 @@ */ import { describe, expect, it } from 'vitest'; -import { - getNotificationSchema, - getRequestSchema, - getResultSchema -} from '../../src/types/index.js'; import { CallToolRequestSchema, CallToolResultSchema, @@ -71,6 +66,9 @@ import { ToolListChangedNotificationSchema, UnsubscribeRequestSchema } from '../../src/types/index.js'; +// Post-relocation home (Q1 increment-2 step 1): the pinned contents are +// unchanged — only the module housing the registries moved. +import { getNotificationSchema, getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; /** The exact 2025-era request-method → schema map (today's wire surface, verbatim). */ const EXPECTED_REQUEST_SCHEMAS = { From 976811dc570c96e1fa4c2fac977f67bc08430bb4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:13:01 +0000 Subject: [PATCH 03/15] feat(core): resolve the wire codec per exchange in the protocol funnels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _onrequest resolves a per-request codec (classification > session > legacy), era-gates spec methods by registry membership (-32601 by absence, before handler lookup), enforces era envelope requiredness on the lifted material, binds the codec to the request context, and routes the response through the codec's encodeResult stamp seam. - _onnotification gains the same per-message resolution and era gate (silent drop for era-mismatched spec notifications). - setRequestHandler/setNotificationHandler spec paths resolve their schemas at dispatch time from the serving era's registry instead of capturing them at registration time. - request()/notification()/ctx senders era-gate outbound spec methods with a typed local error (new SdkErrorCode.MethodNotSupportedByProtocolVersion) before anything reaches the transport; lifecycle messages keep their bootstrap era pins. - the response funnel gains the codec decodeResult hop ahead of schema validation. All resolution currently lands on the 2025-era codec, so behavior on existing connections is unchanged; the 2026-era codec arrives separately. Rebase reconciliations (onto the post-#2293 base): - The MessageClassification carrier (and MessageExtraInfo.classification) moves INTO this commit: the base removed it (review fix 210aaab73, zero producers/consumers there) and this funnel surgery is its first consumer (inboundCodecFor(extra?.classification)). Shapes recovered from the pre-rebase base; JSDoc updated — it IS consulted now. Its envelope member is Partial, matching the lift typing (07f23840f). - codecForClassification arrives here too (it reads the carrier). - The notification funnel keeps the base's envelope-keys-only lift scope (liftWireOnlyMaterial(message, 'notification') — review fix 8068629da). - API reports regenerated in this commit: the carrier's lines return (7 reports, +433 — it is again the reachability path for RequestMetaEnvelope and the JSON helper types in the subpath bundles). --- .changeset/codec-era-gates.md | 7 + packages/core/src/errors/sdkErrors.ts | 8 + packages/core/src/shared/protocol.ts | 225 +++++++++++++++--- packages/core/src/types/types.ts | 37 +++ packages/core/src/wire/codec.ts | 23 +- .../core/test/types/errorSurfacePins.test.ts | 1 + 6 files changed, 271 insertions(+), 30 deletions(-) create mode 100644 .changeset/codec-era-gates.md diff --git a/.changeset/codec-era-gates.md b/.changeset/codec-era-gates.md new file mode 100644 index 000000000..89f7a2604 --- /dev/null +++ b/.changeset/codec-era-gates.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add `SdkErrorCode.MethodNotSupportedByProtocolVersion`: a typed local error raised before anything reaches the transport when a spec method is sent toward a peer whose negotiated protocol version's wire era does not define it (for example `tasks/get` toward a 2026-07-28 peer). The protocol layer now resolves a per-era wire codec for every exchange — from the client's negotiated version, the server's per-request classification, or the legacy default — and resolves per-method schemas at dispatch time instead of registration time. Behavior on existing (2025-era) connections is unchanged. diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index 808841807..1f77d1fac 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -34,6 +34,14 @@ export enum SdkErrorCode { * `input_required`. The kind is carried in `data.resultType`. */ UnsupportedResultType = 'UNSUPPORTED_RESULT_TYPE', + /** + * The spec method being sent does not exist on the negotiated protocol + * version's wire era (e.g. `tasks/get` toward a 2026-07-28 peer, or + * `server/discover` toward a 2025-era peer). Raised locally, before + * anything reaches the transport. The method and era are carried in + * `data.method` / `data.era`. + */ + MethodNotSupportedByProtocolVersion = 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 1ed13e803..7b38eaab4 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -46,7 +46,16 @@ import { } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; -import { getNotificationSchema, getRequestSchema, getResultSchema } from '../wire/rev2025-11-25/registry.js'; +import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; +import type { WireCodec } from '../wire/codec.js'; +import { + bindRequestCodec, + codecForContext, + inboundCodecFor, + isSpecNotificationMethod, + isSpecRequestMethod, + outboundCodecFor +} from '../wire/codec.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -389,7 +398,7 @@ export abstract class Protocol { private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); private _requestHandlerAbortControllers: Map = new Map(); - private _notificationHandlers: Map Promise> = new Map(); + private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); private _progressHandlers: Map = new Map(); private _timeoutInfo: Map = new Map(); @@ -527,7 +536,7 @@ export abstract class Protocol { } else if (isJSONRPCRequest(message)) { this._onrequest(message, extra); } else if (isJSONRPCNotification(message)) { - this._onnotification(message); + this._onnotification(message, extra); } else { this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); } @@ -574,23 +583,39 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(rawNotification: JSONRPCNotification): void { + private _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { // Hide wire-only material from notification handlers too — but ONLY // the reserved envelope `_meta` keys (the retry params names are // reserved on requests, not notifications). There is no // per-notification context, so the lifted envelope keys are dropped, // not surfaced; the protocol layer owns them. const { message: notification } = liftWireOnlyMaterial(rawNotification, 'notification'); - const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; + + // Per-message codec resolution: classification wins, session state is + // the fallback, unclassified traffic is legacy-era. + const codec = inboundCodecFor(this, extra?.classification); + + // Era gate — deletions are physical: a spec notification that is not + // in this era's registry is dropped even when a handler is + // registered (notifications get no error response; silent drop is + // the protocol-correct outcome, matching today's unknown-method + // posture). Methods outside the spec universe are consumer-owned + // extension notifications and stay era-blind. + if (isSpecNotificationMethod(notification.method) && !codec.hasNotificationMethod(notification.method)) { + return; + } + + const handler = this._notificationHandlers.get(notification.method); + const fallback = this.fallbackNotificationHandler; // Ignore notifications not being subscribed to. - if (handler === undefined) { + if (handler === undefined && fallback === undefined) { return; } // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(notification)) + .then(() => (handler === undefined ? fallback!(notification) : handler(notification, codec))) .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); } @@ -599,26 +624,54 @@ export abstract class Protocol { // fallback handler and the per-method schema parse) see exactly the // 2025-era shape; the envelope and retry fields surface via ctx. const { message: request, lifted } = liftWireOnlyMaterial(rawRequest, 'request'); - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + // Per-request codec resolution: classification wins (Q2; this layer + // only CONSUMES MessageExtraInfo.classification), the session + // negotiated version is the fallback for hand-wired sessionful + // transports, and unclassified-unbound traffic is legacy-era. + const codec = inboundCodecFor(this, extra?.classification); // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; - const sendNotification = (notification: Notification, options?: NotificationOptions) => - this.notification(notification, { ...options, relatedRequestId: request.id }); - const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }); - - if (handler === undefined) { + const sendErrorResponse = (code: number, message: string) => { const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, - error: { - code: ProtocolErrorCode.MethodNotFound, - message: 'Method not found' - } + error: { code, message } }; capturedTransport?.send(errorResponse).catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); + }; + + // 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)) { + sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); + return; + } + + // Envelope enforcement: the 2026 era requires the per-request `_meta` + // envelope on every request (spec.types.2026-07-28 RequestParams). + // The lift extracted it above; the era codec validates requiredness. + const envelopeError = codec.checkInboundEnvelope(lifted); + if (envelopeError !== undefined) { + sendErrorResponse(ProtocolErrorCode.InvalidParams, envelopeError); + return; + } + + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + const sendNotification = (notification: Notification, options?: NotificationOptions) => + this._notificationViaCodec(codec, notification, { ...options, relatedRequestId: request.id }); + const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => + this._requestWithSchemaViaCodec(codec, r, resultSchema, { ...options, relatedRequestId: request.id }); + + if (handler === undefined) { + sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); return; } @@ -640,10 +693,14 @@ export abstract class Protocol { // that overloaded property type. The cast is sound: this impl dispatches both overload paths via the // isStandardSchema guard, and sendRequest validates the result against the resolved schema either way. send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions) => { + // Related requests ride the SAME era as the request being + // handled: the per-request codec era-gates and resolves + // schemas at dispatch time. + this._assertOutboundRequestInEra(codec, r.method); if (isStandardSchema(schemaOrOptions)) { return sendRequest(r, schemaOrOptions, maybeOptions); } - const resultSchema = getResultSchema(r.method); + const resultSchema = codec.resultSchema(r.method); if (!resultSchema) { throw new TypeError( `'${r.method}' is not a spec method; pass a result schema as the second argument to ctx.mcpReq.send().` @@ -656,6 +713,10 @@ export abstract class Protocol { http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined }; const ctx = this.buildContext(baseCtx, extra); + // Make the per-request codec resolvable from the context (stored + // handler closures and _wrapHandler wrappers resolve era-exact + // schemas through it at dispatch time). + bindRequestCodec(ctx, codec); // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() @@ -668,7 +729,12 @@ export abstract class Protocol { } const response: JSONRPCResponse = { - result, + // The outbound stamp seam: the era codec maps the + // neutral handler result to its wire shape. The + // 2025-era codec is the identity (never-stamp); the + // 2026-era codec stamps `resultType` and enforces the + // deleted-field set. + result: codec.encodeResult(request.method, result), jsonrpc: '2.0', id: request.id }; @@ -802,14 +868,36 @@ export abstract class Protocol { options?: RequestOptions ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { + // Outbound codec resolution: lifecycle messages are bootstrap-pinned + // (they precede negotiation and self-identify their era); everything + // else rides the instance's negotiated era (legacy when unbound). + const codec = bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this); + this._assertOutboundRequestInEra(codec, request.method); if (isStandardSchema(schemaOrOptions)) { - return this._requestWithSchema(request, schemaOrOptions, maybeOptions); + return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); } - const resultSchema = getResultSchema(request.method); + const resultSchema = codec.resultSchema(request.method); if (!resultSchema) { throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`); } - return this._requestWithSchema(request, resultSchema, schemaOrOptions); + return this._requestWithSchemaViaCodec(codec, request, resultSchema, schemaOrOptions); + } + + /** + * Era gate for outbound requests — deletions are physical in BOTH + * 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. + */ + private _assertOutboundRequestInEra(codec: WireCodec, method: string): void { + if (isSpecRequestMethod(method) && !codec.hasRequestMethod(method)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Method '${method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, + { method, era: codec.era } + ); + } } /** @@ -822,6 +910,25 @@ export abstract class Protocol { request: Request, resultSchema: T, options?: RequestOptions + ): Promise> { + return this._requestWithSchemaViaCodec( + bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this), + request, + resultSchema, + options + ); + } + + /** + * The request funnel proper, keyed by the resolved era codec: the codec + * owns result decoding (raw-first `resultType` discrimination — V-1 — + * and the era's lift posture) before the schema validation step. + */ + private _requestWithSchemaViaCodec( + codec: WireCodec, + request: Request, + resultSchema: T, + options?: RequestOptions ): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; @@ -949,6 +1056,28 @@ export abstract class Protocol { result = rest as Result; } + // Codec decode hop (the structural V-1 home): the era codec + // applies its raw-first posture before schema validation. + // NOTE (staging): the funnel block above predates the codec + // split and still runs first; it is removed when the + // 2026-era codec lands and the codecs own the postures. + const decoded = codec.decodeResult(request.method, result); + if (decoded.kind === 'invalid') { + return reject(decoded.error); + } + if (decoded.kind === 'input_required') { + // Driver seam: the multi-round-trip driver (M4.1) + // consumes this payload; until it lands, surface the + // discriminated kind as a typed local error, no retry. + return reject( + new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type 'input_required' for ${request.method}`, { + resultType: 'input_required', + method: request.method + }) + ); + } + result = decoded.result; + validateStandardSchema(resultSchema, result).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); @@ -989,10 +1118,29 @@ export abstract class Protocol { * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { + return this._notificationViaCodec(bootstrapOutboundCodec(notification.method) ?? outboundCodecFor(this), notification, options); + } + + /** + * The notification funnel proper, keyed by the resolved era codec + * (related notifications sent via `ctx.mcpReq.notify` ride the inbound + * request's codec; direct sends ride the instance's negotiated era). + */ + private async _notificationViaCodec(codec: WireCodec, notification: Notification, options?: NotificationOptions): Promise { if (!this._transport) { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } + // Era gate — outbound deletions are physical for notifications too: a + // spec notification the resolved era does not define dies locally. + if (isSpecNotificationMethod(notification.method) && !codec.hasNotificationMethod(notification.method)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Notification '${notification.method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, + { method: notification.method, era: codec.era } + ); + } + this.assertNotificationCapability(notification.method); const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', ...notification }; @@ -1074,13 +1222,23 @@ export abstract class Protocol { let stored: (request: JSONRPCRequest, ctx: ContextT) => Promise; if (typeof schemasOrHandler === 'function') { - const schema = getRequestSchema(method); - if (!schema) { + if (!isSpecRequestMethod(method)) { throw new TypeError( `'${method}' is not a spec request method; pass schemas as the second argument to setRequestHandler().` ); } - stored = (request, ctx) => Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + // Dispatch-time schema resolution: the request is parsed with the + // schema of the era SERVING THIS REQUEST (per-request codec bound + // to ctx), never with a schema captured at registration time. + stored = (request, ctx) => { + const schema = codecForContext(ctx).requestSchema(method); + if (!schema) { + // Unreachable: the dispatch era gate rejects era-mismatched + // spec methods with −32601 before any handler runs. + throw new ProtocolError(ProtocolErrorCode.InternalError, `No wire schema for ${method} in the resolved era`); + } + return Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + }; } else if (maybeHandler) { stored = async (request, ctx) => { const userParams = { ...request.params }; @@ -1157,13 +1315,22 @@ export abstract class Protocol { maybeHandler?: (params: unknown, notification: Notification) => void | Promise ): void { if (typeof schemasOrHandler === 'function') { - const schema = getNotificationSchema(method); - if (!schema) { + if (!isSpecNotificationMethod(method)) { throw new TypeError( `'${method}' is not a spec notification method; pass schemas as the second argument to setNotificationHandler().` ); } - this._notificationHandlers.set(method, notification => Promise.resolve(schemasOrHandler(schema.parse(notification)))); + // Dispatch-time schema resolution, same as setRequestHandler: the + // era serving the message picks the schema. + this._notificationHandlers.set(method, (notification, codec) => { + const schema = codec.notificationSchema(method); + if (!schema) { + // Unreachable: the dispatch era gate drops era-mismatched + // spec notifications before any handler runs. + throw new ProtocolError(ProtocolErrorCode.InternalError, `No wire schema for ${method} in the resolved era`); + } + return Promise.resolve(schemasOrHandler(schema.parse(notification))); + }); return; } diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index f2ca07ad3..0aa601ad0 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -588,6 +588,36 @@ export type ListChangedHandlers = { resources?: ListChangedOptions; }; +/** + * Protocol-era classification of an inbound message. + * + * Populated by transports that classify messages at the edge (e.g. an HTTP + * entry distinguishing 2025-era from 2026-era traffic). The protocol layer + * consults it during dispatch to resolve the wire codec serving the exchange + * (classification wins over the session-negotiated version; unclassified + * traffic falls back to the session version, then legacy). + */ +export interface MessageClassification { + /** + * The wire era the message was classified into: `legacy` for the + * 2025-11-25 family of revisions, `modern` for 2026-07-28 and later. + */ + era: 'legacy' | 'modern'; + + /** + * The exact protocol revision, when the classifier derived one. + */ + revision?: string; + + /** + * The per-request `_meta` envelope, when the classifier extracted it. + * Partial: whichever reserved keys the message actually carried — + * envelope requiredness is enforced per request at dispatch time, not at + * the classifying edge. + */ + envelope?: Partial; +} + /** * Extra information about a message. */ @@ -597,6 +627,13 @@ export interface MessageExtraInfo { */ request?: globalThis.Request; + /** + * Protocol-era classification of the message, when the transport + * classified it at the edge. Consulted by the protocol layer's + * per-exchange codec resolution. + */ + classification?: MessageClassification; + /** * The authentication information. */ diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index fd2398923..976089166 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -41,7 +41,7 @@ import type * as z from 'zod/v4'; import type { SdkError } from '../errors/sdkErrors.js'; -import type { RequestMetaEnvelope, Result } from '../types/types.js'; +import type { MessageClassification, RequestMetaEnvelope, Result } from '../types/types.js'; import { rev2025Codec } from './rev2025-11-25/codec.js'; /** Wire eras with distinct vocabulary. */ @@ -168,6 +168,18 @@ export function codecForVersion(version: string | undefined): WireCodec { return rev2025Codec; } +/** + * Resolve the codec for an inbound message from its per-request + * classification (Q2 — produced at the transport/entry edge; this layer only + * CONSUMES it), falling back to the session-negotiated version, then legacy. + */ +export function codecForClassification(classification: MessageClassification | undefined, sessionVersion: string | undefined): WireCodec { + if (classification?.revision !== undefined) return codecForVersion(classification.revision); + if (classification?.era === 'modern') return codecForVersion(MODERN_WIRE_REVISION); + if (classification?.era === 'legacy') return codecForVersion(undefined); + return codecForVersion(sessionVersion); +} + /** * The derived spec-method universe: the union of every codec registry. A * method in this set is era-gated at dispatch and send time; a method outside @@ -211,6 +223,15 @@ export function outboundCodecFor(owner: object): WireCodec { return codecForVersion(outboundWireVersion.get(owner)); } +/** + * Resolve the codec for an INBOUND message: per-request classification wins + * (Q2), the instance's bound session version is the fallback for hand-wired + * sessionful transports, and unclassified-unbound traffic is legacy-era. + */ +export function inboundCodecFor(owner: object, classification: MessageClassification | undefined): WireCodec { + return codecForClassification(classification, outboundWireVersion.get(owner)); +} + const requestCodec = new WeakMap(); /** diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index b7985ae2c..bb5fb6432 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -75,6 +75,7 @@ describe('SdkErrorCode', () => { SendFailed: 'SEND_FAILED', InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', + MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', From 6d895c1129315d6ad604d1ec12d829d7a9e33812 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:22:26 +0000 Subject: [PATCH 04/15] refactor(client,server): re-bind positional schema call sites to era-resolved method-keyed resolution - Client/Server high-level methods (ping, complete, setLevel, prompts/*, resources/*, tools/*, roots/list, initialize) now go through the method-keyed request() path; the wire codec resolves the result schema at dispatch time. - The three deliberately narrower surfaces (callTool plain CallToolResult, createMessage params-dependent, elicitInput plain ElicitResult) resolve their narrow schemas from the same codec instead of importing them positionally. - Client.connect/reconnect and Server initialize bind the negotiated wire version, so the codec rides connection state. - Both _wrapHandler validators resolve their request/result schemas from the per-request codec instead of module-level schema imports. Same schema objects serve the 2025 era, so behavior is unchanged (full e2e matrix byte-stable at 2282 passed / 188 expected-fail). Rebase reconciliations (onto the post-#2293 base): - tools/call and elicitation/create no longer go through narrowResultSchema: with the result map aligned to the typed map (d542fd8c0), their narrow keys were identical to the era registry entries in BOTH eras, so callTool and elicitInput use the method-keyed request() path and the handler wraps validate codec.resultSchema(method). _requestWithNarrowSchema remains for the sampling pair only (params-dependent schema choice). - API reports regenerated (NarrowResultKey + the protected narrow sender enter the Protocol surface). --- packages/client/src/client/client.ts | 107 +++++++++++++++++---------- packages/core/src/index.ts | 5 ++ packages/core/src/shared/protocol.ts | 25 ++++++- packages/server/src/server/server.ts | 57 +++++++++----- 4 files changed, 134 insertions(+), 60 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index c181133c8..a15211799 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -9,6 +9,9 @@ import type { ClientRequest, CompleteRequest, CompleteResult, + CreateMessageRequest, + ElicitRequest, + ElicitResult, EmptyResult, GetPromptRequest, GetPromptResult, @@ -43,28 +46,15 @@ import type { UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - CompleteResultSchema, - CreateMessageRequestSchema, - CreateMessageResultSchema, - CreateMessageResultWithToolsSchema, - ElicitRequestSchema, - ElicitResultSchema, - EmptyResultSchema, - GetPromptResultSchema, - InitializeResultSchema, + bindWireVersion, + codecForContext, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, mergeCapabilities, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, - ReadResourceResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; @@ -302,7 +292,19 @@ export class Client extends Protocol { ): (request: JSONRPCRequest, ctx: ClientContext) => Promise { if (method === 'elicitation/create') { return async (request, ctx) => { - const validatedRequest = parseSchema(ElicitRequestSchema, request); + // Era-exact validation: the schemas are resolved from the + // request's codec at dispatch time (the era gate guarantees + // the method exists on the serving era before we get here). + const codec = codecForContext(ctx); + const elicitRequestSchema = codec.requestSchema('elicitation/create'); + // The era registry entry IS the plain ElicitResult schema + // (the result map is aligned to the typed map — no widened + // unions), so no narrower surface is needed. + const elicitResultSchema = codec.resultSchema('elicitation/create'); + if (!elicitRequestSchema || !elicitResultSchema) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for elicitation/create in the resolved era'); + } + const validatedRequest = parseSchema(elicitRequestSchema, request); if (!validatedRequest.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -310,7 +312,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`); } - const { params } = validatedRequest.data; + const { params } = validatedRequest.data as ElicitRequest; params.mode = params.mode ?? 'form'; const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); @@ -324,7 +326,7 @@ export class Client extends Protocol { const result = await handler(request, ctx); - const validationResult = parseSchema(ElicitResultSchema, result); + const validationResult = parseSchema(elicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -332,7 +334,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`); } - const validatedResult = validationResult.data; + const validatedResult = validationResult.data as ElicitResult; const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; if ( @@ -355,19 +357,36 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + // Era-exact validation via the request's codec (see above). + const codec = codecForContext(ctx); + const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); + if (!samplingRequestSchema) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'No wire schema for sampling/createMessage in the resolved era' + ); + } + const validatedRequest = parseSchema(samplingRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); } - const { params } = validatedRequest.data; + const { params } = validatedRequest.data as CreateMessageRequest; const result = await handler(request, ctx); const hasTools = params.tools || params.toolChoice; - const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const resultSchema = codec.narrowResultSchema( + hasTools ? 'sampling/createMessage:withTools' : 'sampling/createMessage:plain' + ); + if (!resultSchema) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'No wire schema for sampling/createMessage in the resolved era' + ); + } const validationResult = parseSchema(resultSchema, result); if (!validationResult.success) { const errorMessage = @@ -375,7 +394,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); } - return validationResult.data; + return validationResult.data as Result; }; } @@ -423,13 +442,16 @@ export class Client extends Protocol { // Restore the protocol version negotiated during the original initialize handshake // so HTTP transports include the required mcp-protocol-version header, but skip re-init. if (transport.sessionId !== undefined) { - if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { - transport.setProtocolVersion(this._negotiatedProtocolVersion); + if (this._negotiatedProtocolVersion !== undefined) { + // Reconnection restores the original negotiation: re-bind the + // wire codec alongside the transport header. + bindWireVersion(this, this._negotiatedProtocolVersion); + transport.setProtocolVersion?.(this._negotiatedProtocolVersion); } return; } try { - const result = await this._requestWithSchema( + const result = await this.request( { method: 'initialize', params: { @@ -438,7 +460,6 @@ export class Client extends Protocol { clientInfo: this._clientInfo } }, - InitializeResultSchema, options ); @@ -453,6 +474,10 @@ export class Client extends Protocol { this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; this._negotiatedProtocolVersion = result.protocolVersion; + // The negotiated version selects the wire codec for everything + // this connection sends/receives from here on (the negotiated + // version cashes out as the negotiated wire ERA - Q1-SD1). + bindWireVersion(this, result.protocolVersion); // HTTP transports must set the protocol version in each header after initialization. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.protocolVersion); @@ -638,22 +663,22 @@ export class Client extends Protocol { } async ping(options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); + return this.request({ method: 'ping' }, options); } /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ async complete(params: CompleteRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); + return this.request({ method: 'completion/complete', params }, options); } /** Sets the minimum severity level for log messages sent by the server. */ async setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + return this.request({ method: 'logging/setLevel', params: { level } }, options); } /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'prompts/get', params }, GetPromptResultSchema, options); + return this.request({ method: 'prompts/get', params }, options); } /** @@ -683,7 +708,7 @@ export class Client extends Protocol { console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); return { prompts: [] }; } - return this._requestWithSchema({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + return this.request({ method: 'prompts/list', params }, options); } /** @@ -713,7 +738,7 @@ export class Client extends Protocol { console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); return { resources: [] }; } - return this._requestWithSchema({ method: 'resources/list', params }, ListResourcesResultSchema, options); + return this.request({ method: 'resources/list', params }, options); } /** @@ -733,22 +758,22 @@ export class Client extends Protocol { ); return { resourceTemplates: [] }; } - return this._requestWithSchema({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + return this.request({ method: 'resources/templates/list', params }, options); } /** Reads the contents of a resource by URI. */ async readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); + return this.request({ method: 'resources/read', params }, options); } /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + return this.request({ method: 'resources/subscribe', params }, options); } /** Unsubscribes from change notifications for a resource. */ async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + return this.request({ method: 'resources/unsubscribe', params }, options); } /** @@ -789,7 +814,11 @@ export class Client extends Protocol { * ``` */ async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { - const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); + // The method-keyed request() path validates the era registry's plain + // CallToolResult schema — with the result map aligned to the typed + // map there is no wider union to narrow away (Q1-SD2 holds by + // construction). + const result = await this.request({ method: 'tools/call', params }, options); // Check if the tool has an outputSchema const validator = this.getToolOutputValidator(params.name); @@ -879,7 +908,7 @@ export class Client extends Protocol { console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); return { tools: [] }; } - const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options); + const result = await this.request({ method: 'tools/list', params }, options); // Cache the tools and their output schemas for future validation this.cacheToolMetadata(result.tools); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a704267ee..a7981b42a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,9 +10,14 @@ export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; export * from './types/index.js'; export * from './util/inMemory.js'; +// Wire-codec internals: ONLY the connection-state binding and per-request +// resolution hooks the sibling packages need. Nothing per-revision (schemas, +// registries, codec objects) is ever exported — not even on this internal +// barrel — so per-era vocabulary cannot leak toward the public surface. export * from './util/schema.js'; export * from './util/standardSchema.js'; export * from './util/zodCompat.js'; +export { bindWireVersion, codecForContext } from './wire/codec.js'; // Validator providers are type-only here — import the runtime classes from the explicit // `@modelcontextprotocol/{core,client,server}/validators/{ajv,cf-worker}` subpaths to customise. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 7b38eaab4..21a3184c9 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -47,7 +47,7 @@ import { import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; -import type { WireCodec } from '../wire/codec.js'; +import type { NarrowResultKey, WireCodec } from '../wire/codec.js'; import { bindRequestCodec, codecForContext, @@ -900,6 +900,29 @@ export abstract class Protocol { } } + /** + * Sends a spec-method request whose result validation deliberately uses a + * NARROWER era schema than the generic per-method registry entry. With + * the result map aligned to the typed map, the only such surface is + * `server.createMessage`, whose result schema depends on the REQUEST + * params (tools vs no tools) — something a method-keyed registry entry + * cannot express. The schema is resolved from the outbound era codec at + * dispatch time, like every other method-keyed binding. + */ + protected _requestWithNarrowSchema(request: Request, narrow: NarrowResultKey, options?: RequestOptions): Promise { + const codec = bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this); + this._assertOutboundRequestInEra(codec, request.method); + const schema = codec.narrowResultSchema(narrow); + if (!schema) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Method '${request.method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, + { method: request.method, era: codec.era } + ); + } + return this._requestWithSchemaViaCodec(codec, request, schema as unknown as StandardSchemaV1, options) as Promise; + } + /** * Sends a request and waits for a response, using the provided schema for validation. * diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 1925e5ced..39a8994e4 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -34,14 +34,9 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { - CallToolRequestSchema, - CallToolResultSchema, - CreateMessageResultSchema, - CreateMessageResultWithToolsSchema, - ElicitResultSchema, - EmptyResultSchema, + bindWireVersion, + codecForContext, LATEST_PROTOCOL_VERSION, - ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, parseSchema, @@ -190,7 +185,19 @@ export class Server extends Protocol { return handler; } return async (request, ctx) => { - const validatedRequest = parseSchema(CallToolRequestSchema, request); + // Era-exact validation: the request and result schemas come from + // the request's codec, resolved at dispatch time (the era gate + // guarantees tools/call exists on the serving era). + const codec = codecForContext(ctx); + const callToolRequestSchema = codec.requestSchema('tools/call'); + // The era registry entry IS the plain CallToolResult schema (the + // result map is aligned to the typed map — no widened unions), + // so no narrower surface is needed. + const callToolResultSchema = codec.resultSchema('tools/call'); + if (!callToolRequestSchema || !callToolResultSchema) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for tools/call in the resolved era'); + } + const validatedRequest = parseSchema(callToolRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); @@ -199,14 +206,14 @@ export class Server extends Protocol { const result = await handler(request, ctx); - const validationResult = parseSchema(CallToolResultSchema, result); + const validationResult = parseSchema(callToolResultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); } - return validationResult.data; + return validationResult.data as Result; }; } @@ -365,6 +372,10 @@ export class Server extends Protocol { : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); this._negotiatedProtocolVersion = protocolVersion; + // Session-level wire-era binding: the fallback codec source for + // hand-wired sessionful transports (per-request classification wins + // when present). + bindWireVersion(this, protocolVersion); this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -406,7 +417,7 @@ export class Server extends Protocol { } async ping(): Promise { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); + return this.request({ method: 'ping' }); } /** @@ -486,9 +497,17 @@ export class Server extends Protocol { // Use different schemas based on whether tools are provided if (params.tools) { - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + return this._requestWithNarrowSchema( + { method: 'sampling/createMessage', params }, + 'sampling/createMessage:withTools', + options + ); } - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + return this._requestWithNarrowSchema( + { method: 'sampling/createMessage', params }, + 'sampling/createMessage:plain', + options + ); } /** @@ -508,7 +527,9 @@ export class Server extends Protocol { } const urlParams = params as ElicitRequestURLParams; - return this._requestWithSchema({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + // Method-keyed request(): the era registry's plain + // ElicitResult schema is exactly the narrow surface. + return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { if (!this._clientCapabilities?.elicitation?.form) { @@ -518,11 +539,7 @@ export class Server extends Protocol { const formParams: ElicitRequestFormParams = params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; - const result = await this._requestWithSchema( - { method: 'elicitation/create', params: formParams }, - ElicitResultSchema, - options - ); + const result = await this.request({ method: 'elicitation/create', params: formParams }, options); if (result.action === 'accept' && result.content && formParams.requestedSchema) { try { @@ -579,7 +596,7 @@ export class Server extends Protocol { } async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); + return this.request({ method: 'roots/list', params }, options); } /** From d60a7abf83ce7a4c77c32ab9ef63781419bc6587 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:39:41 +0000 Subject: [PATCH 05/15] feat(core)!: cut resultType from the neutral schemas and move era vocabulary into the codec modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consumer-visible alpha break of the codec split, as one changeset (codec-split-wire-break) with migration entries for every ledgered item: - resultType removed from the base ResultSchema: the masking surface that accepted 2026 vocabulary on every legacy-leg parse is gone. EmptyResultSchema (strict) flips accept->reject for {resultType} bodies; the member now exists only in the 2026-era codec module. - content.default([]) removed from CallToolResultSchema and ToolResultContentSchema: content-less results fail loudly instead of parsing as silent empty successes (the T6 width-leak root); handler results must include content (-32602 otherwise). - custom (3-arg) handlers now receive _meta present-minus-reserved instead of having it deleted before params validation. - RequestMetaEnvelopeSchema moved to wire/rev2026-07-28/schemas.ts (shape unchanged); the task message schemas and the era-faithful role unions moved to wire/rev2025-11-25/schemas.ts; the neutral role aggregates no longer carry task vocabulary (deprecated Task* types stay importable). - specTypeSchemas re-scoped to the neutral model (task message validators and RequestMetaEnvelope left the public set; SpecTypeName narrowed); guards documented as consumer-side neutral-shape checks. - codemod spec-schema map regenerated; API reports regenerated deliberately (the diff is the enumerated break: resultType schema members gone, moved declarations, SdkErrorCode/NarrowResultKey additions). Every net-test update carries its ledger annotation inline. Full e2e matrix and all three conformance legs are byte-stable. Rebase reconciliations (onto the post-#2293 base): - The @deprecated tags the base added to the full task wire surface (6ebe7e98c) travel WITH the moved schemas into wire/rev2025-11-25/schemas.ts; the wireOnlyHiding scan test now sweeps both schema homes (combined >= 19, every export tagged). - typedMapAlignment's tools/call carve-out pin FLIPS in this commit, as part of the content-default ledger item: with content required, a CreateTaskResult body on tools/call no longer parses as {content: []} — it is a typed INVALID_RESULT error like sampling/elicit. The 'Honest pin' comment records the flip. - migration.md / migration-SKILL.md merge the base's review-fix wording (Partial envelope, notification-drop caveat, retry-name collision note) with this commit's era-codec entries; the classification bullet returns (the carrier is back since the funnel commit). - mcpServerBehaviorPins.test.ts no longer exists on the base (pin consolidation); its content-default flip pin is superseded by the typedMapAlignment flip and the eraGates suite. - codemod spec-schema map regenerated via the package prebuild (output byte-identical to the original commit's); API reports regenerated. --- .changeset/codec-split-wire-break.md | 15 + docs/migration-SKILL.md | 15 +- docs/migration.md | 44 +++ .../codemod/src/generated/specSchemaMap.ts | 15 - packages/core/src/shared/protocol.ts | 15 +- packages/core/src/types/guards.ts | 6 + packages/core/src/types/schemas.ts | 265 ++------------ packages/core/src/types/specTypeSchema.ts | 24 +- packages/core/src/types/types.ts | 36 +- .../core/src/wire/rev2025-11-25/registry.ts | 7 +- .../core/src/wire/rev2025-11-25/schemas.ts | 326 ++++++++++++++++++ .../core/src/wire/rev2026-07-28/schemas.ts | 65 ++++ packages/core/test/corpus/specCorpus.test.ts | 21 +- .../core/test/shared/customMethods.test.ts | 39 ++- .../test/shared/rawResultTypeFirst.test.ts | 40 +-- .../test/shared/typedMapAlignment.test.ts | 29 +- .../core/test/spec.types.2025-11-25.test.ts | 25 +- packages/core/test/types.test.ts | 63 ++-- packages/core/test/types/registryPins.test.ts | 15 +- .../test/types/schemaBoundaryPins.test.ts | 44 ++- .../core/test/types/specTypeSchema.test.ts | 34 +- .../core/test/types/wireOnlyHiding.test.ts | 37 +- 22 files changed, 759 insertions(+), 421 deletions(-) create mode 100644 .changeset/codec-split-wire-break.md create mode 100644 packages/core/src/wire/rev2025-11-25/schemas.ts create mode 100644 packages/core/src/wire/rev2026-07-28/schemas.ts diff --git a/.changeset/codec-split-wire-break.md b/.changeset/codec-split-wire-break.md new file mode 100644 index 000000000..2a20452ab --- /dev/null +++ b/.changeset/codec-split-wire-break.md @@ -0,0 +1,15 @@ +--- +'@modelcontextprotocol/core': major +'@modelcontextprotocol/client': major +'@modelcontextprotocol/server': major +--- + +Split the wire layer into per-era codecs and make protocol-revision deletions physical. Deliberate wire/schema behavior changes (see docs/migration.md "Per-era wire codecs"): + +- `resultType` is no longer modeled by any neutral wire schema: `EmptyResultSchema` (strict) now rejects `{resultType}` bodies; on 2025-era connections a foreign `resultType` is stripped before validation instead of rejected; the member exists only inside the 2026-era codec, which requires it. +- `CallToolResult.content` / `ToolResultContent.content` are required at the wire boundary (`content.default([])` removed): handler results without `content` are rejected with `-32602` instead of silently defaulted, and content-less wire results fail the client parse loudly. +- Custom (3-arg) handlers now receive `_meta` minus the reserved envelope keys instead of having it deleted before params validation. +- `specTypeSchemas` re-scoped to the neutral model: result validators no longer accept `resultType`; task message-type validators and `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed). +- Role aggregate types/schemas (`ClientRequest`, `ServerResult`, …) no longer carry task vocabulary; the deprecated `Task*` types remain importable unchanged. +- Era-mismatched spec methods fail physically: inbound era-deleted methods get `-32601` even with a handler registered; outbound sends throw `SdkErrorCode.MethodNotSupportedByProtocolVersion` locally. +- Value guards (`isCallToolResult`, …) are documented as neutral-shape consumer checks, not wire validators. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 312229a8c..6ff0e63c6 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -515,7 +515,20 @@ Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTy | `Result['resultType']` type reference | remove; the member is no longer declared | | return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | -Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. A response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). +Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; `MessageExtraInfo.classification` is consumed by dispatch to select the serving wire era. Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). + +## 12c. Per-era wire codecs (physical deletions + stricter wire schemas) + +The wire layer is split into per-era codecs (2025-era = 2024-10-07 … 2025-11-25; 2026-era = 2026-07-28). Era-mismatched spec methods fail physically: inbound -> `-32601` even with a handler registered; outbound -> `SdkError` code `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` before the transport. + +| Pattern in v2-alpha code | Mechanical fix | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| tool handler returns without `content` | add `content: []` (or real content) — results without it are rejected `-32602`, no longer defaulted | +| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | +| strict custom-handler params schema (3-arg `setRequestHandler`/`setNotification…`) | add optional `_meta` to the schema (or strip it) — `_meta` is now passed through minus reserved keys | +| `specTypeSchemas`/`SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (types remain importable) | +| `ClientRequest`/`ServerResult`/… aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | +| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | ## 13. Behavioral Changes diff --git a/docs/migration.md b/docs/migration.md index afef43025..5793a9ab1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -919,6 +919,7 @@ The protocol layer enforces the same boundary at runtime: - **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` envelope keys are lifted out of `params._meta` before handlers run, so handler params are byte-equal to the 2025-era shape under 2026-era traffic. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial` — only the keys the request actually carried are present); for notifications there is no per-message context, so lifted envelope keys are dropped, not surfaced. On requests only, the multi-round-trip retry fields are likewise lifted out of top-level params and surfaced verbatim at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. - **What this means for 2025-era peers.** The `_meta` side of the lift is invisible to conforming 2025-era traffic: the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too, so a conforming 2025 peer never puts application data under those keys. The retry-field lift is the one collision to know about: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that happens to use them as ordinary top-level params will have them lifted out of the handler's view (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`, just no longer in `request.params`). Spec-method requests are unaffected (no 2025 spec method defines params with those names), as are all notifications. - **Raw-first result discrimination.** The client funnel inspects a response's raw `resultType` before schema validation: `'complete'` is consumed (stripped) and the result parses as the public shape; any other kind (e.g. `input_required`) rejects with a typed local error — `SdkError` with the new code `SdkErrorCode.UnsupportedResultType` and the kind in `error.data.resultType` — instead of being masked into a hollow success by tolerant result schemas. Full multi-round-trip support will replace that error arm. +- **`MessageExtraInfo.classification`** is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge. Dispatch consumes it to select the wire era serving each request (see the next section). **Before (v2 alpha):** @@ -938,6 +939,49 @@ const result = await client.callTool({ name: 'echo', arguments: {} }); console.log(result.content); ``` +### Per-era wire codecs: physical deletions and stricter wire schemas + +The wire layer is now split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The negotiated protocol version selects the codec for a client connection; servers resolve it per request from edge classification, falling back to the session's negotiated version, then to the 2025 era. Methods deleted by a protocol revision are now PHYSICALLY absent from that era's registry: an inbound `tasks/get` on a 2026-classified exchange gets `-32601` even if a handler is registered, and sending an era-mismatched spec method (for example `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws a typed local error — `SdkError` with the new code `SdkErrorCode.MethodNotSupportedByProtocolVersion` — before anything reaches the transport. + +Alongside the split, the following deliberate wire-behavior changes ship (each is invisible to conforming peers but observable to direct schema consumers and misbehaving peers): + +- **`resultType` is no longer modeled by any neutral wire schema.** The base `ResultSchema` (and every result schema derived from it) no longer declares the optional `resultType` member. Consequences: + - `EmptyResultSchema` (strict) now REJECTS `{resultType: ...}` bodies where it previously accepted them. On the protocol path nothing changes for conforming peers: the 2026-era codec consumes the field, and the 2025-era codec strips a foreign `resultType` before validation (tolerate-and-drop — a 2025-era peer that sends it is misbehaving). + - On a 2025-era connection, a response carrying a non-`'complete'` `resultType` is no longer rejected with `UnsupportedResultType`: the field is foreign vocabulary on that era and is stripped before validation (the result then passes or fails validation on its actual content, loudly). On a 2026-era exchange the discrimination is stricter than before: `resultType` is REQUIRED, an absent value is a spec violation surfaced as a typed error, and `input_required` / unknown kinds reject with `UnsupportedResultType` / `InvalidResult`. +- **`CallToolResult.content` and `ToolResultContent.content` are required at the wire boundary.** The `content.default([])` affordance was removed (it could silently convert unrecognized result shapes into hollow `{content: []}` successes). Tool handlers MUST include `content` in their results (the TypeScript surface always required it — `content: []` is fine); a handler result without it is now rejected with `-32602 Invalid tools/call result` instead of being silently defaulted, and a content-less wire result fails the client-side parse loudly. +- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` / `setNotificationHandler(method, {params}, handler)` used to DELETE `params._meta` before validating with your schema. They now pass it through minus the reserved `io.modelcontextprotocol/*` envelope keys (which the protocol layer lifts out), making custom methods consistent with spec methods. If your params schema is strict (rejects unknown keys), add an optional `_meta` member or strip it yourself. +- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept/declare `resultType`; the validators for the 2025-only task message types (`Task`, `TaskStatus`, `GetTask*`, `ListTasks*`, `CancelTask*`, `CreateTaskResult`, `TaskStatusNotification*`, `TaskCreationParams`) and for `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). Per-revision wire validators are planned to return as versioned `zod-schemas/` exports. +- **Role aggregate types no longer carry task vocabulary.** `ClientRequest`, `ClientResult`, `ClientNotification`, `ServerRequest`, `ServerResult`, and `ServerNotification` (and their union schemas) are now the neutral message sets; the task members moved into the internal 2025-era wire module. The individual `Task*` types remain importable (deprecated) exactly as before. +- **Value guards are consumer-side checks, not wire validators.** `isCallToolResult` and friends now validate the neutral shapes; a raw wire object carrying `resultType` still passes them through the loose index signature. Validate raw wire traffic with a transport-level parse, not the guards. + +**Before:** + +```typescript +// A handler omitting content was silently defaulted on the wire: +server.setRequestHandler('tools/call', async () => { + return { structuredContent: { ok: true } } as CallToolResult; // wire: content [] +}); + +// Custom handlers never saw _meta: +protocol.setRequestHandler('acme/op', { params: z.strictObject({ x: z.number() }) }, async params => ({})); +``` + +**After:** + +```typescript +// content is required (as the spec always said): +server.setRequestHandler('tools/call', async () => { + return { content: [], structuredContent: { ok: true } }; +}); + +// Custom handlers receive _meta minus the reserved envelope keys: +protocol.setRequestHandler( + 'acme/op', + { params: z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }) }, + async params => ({}) +); +``` + ## Enhancements ### Automatic JSON Schema validator selection by runtime diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts index 77f3d3dfc..99d8f84df 100644 --- a/packages/codemod/src/generated/specSchemaMap.ts +++ b/packages/codemod/src/generated/specSchemaMap.ts @@ -8,8 +8,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'CallToolRequestParamsSchema', 'CallToolRequestSchema', 'CallToolResultSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'CancelledNotificationParamsSchema', 'CancelledNotificationSchema', 'ClientCapabilitiesSchema', @@ -25,7 +23,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'CreateMessageRequestSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'DiscoverRequestSchema', 'DiscoverResultSchema', @@ -42,10 +39,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'GetPromptRequestParamsSchema', 'GetPromptRequestSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -72,8 +65,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ListResourcesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -114,7 +105,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ReadResourceResultSchema', 'RelatedTaskMetadataSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'RequestSchema', 'ResourceContentsSchema', @@ -144,12 +134,7 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'SubscribeRequestParamsSchema', 'SubscribeRequestSchema', 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', 'TaskMetadataSchema', - 'TaskSchema', - 'TaskStatusNotificationParamsSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 21a3184c9..ec381ee65 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1264,9 +1264,12 @@ export abstract class Protocol { }; } else if (maybeHandler) { stored = async (request, ctx) => { - const userParams = { ...request.params }; - delete userParams._meta; - const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + // Custom handlers receive `_meta` present-minus-reserved: the + // wire-only lift already removed the reserved envelope keys, + // and the remaining metadata (progressToken, extension keys) + // is handler material — consistent with the spec-method path. + // (Behavior migration: `_meta` used to be deleted here.) + const parsed = await validateStandardSchema(schemasOrHandler.params, { ...request.params }); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); } @@ -1361,9 +1364,9 @@ export abstract class Protocol { throw new TypeError('setNotificationHandler: handler is required'); } this._notificationHandlers.set(method, async notification => { - const userParams = { ...notification.params }; - delete userParams._meta; - const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + // `_meta` present-minus-reserved, matching the custom request + // path (the lift already removed the reserved envelope keys). + const parsed = await validateStandardSchema(schemasOrHandler.params, { ...notification.params }); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for notification ${method}: ${parsed.error}`); } diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index dd5c4765a..8091b962c 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -72,6 +72,12 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J /** * Checks if a value is a valid {@linkcode CallToolResult}. + * + * This is a consumer-side VALUE check against the neutral model, not a wire + * validator: a raw wire object that additionally carries wire-only members + * (e.g. `resultType`) still passes through the loose index signature. Use a + * transport-level parse to validate raw wire traffic. + * * @param value - The value to check. * * @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise. diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 847e51ad2..105fca6dc 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1,13 +1,6 @@ import * as z from 'zod/v4'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - JSONRPC_VERSION, - LOG_LEVEL_META_KEY, - PROTOCOL_VERSION_META_KEY, - RELATED_TASK_META_KEY -} from './constants.js'; +import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js'; import type { JSONArray, JSONObject, JSONValue } from './types.js'; export const JSONValueSchema: z.ZodType = z.lazy(() => @@ -25,23 +18,6 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); -/** - * Task creation parameters, used to ask that the server create a task to represent a request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskCreationParamsSchema = z.looseObject({ - /** - * Requested duration in milliseconds to retain task from creation. - */ - ttl: z.number().optional(), - - /** - * Time in milliseconds to wait between task status requests. - */ - pollInterval: z.number().optional() -}); - /** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskMetadataSchema = z.object({ ttl: z.number().optional() @@ -118,14 +94,13 @@ export const ResultSchema = z.looseObject({ * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on `_meta` usage. */ - _meta: RequestMetaSchema.optional(), - /** - * Indicates the type of the result, allowing the receiver to determine how to - * parse the result object. Servers implementing protocol revision 2026-07-28 or - * later always include this field; results from earlier revisions omit it, and - * an absent value must be treated as `"complete"`. - */ - resultType: z.string().optional() + _meta: RequestMetaSchema.optional() + // `resultType` is wire-only vocabulary (protocol revision 2026-07-28) and + // is deliberately NOT modeled here: the neutral result schemas carry no + // slot for it. It exists only inside the 2026-era wire codec, which + // consumes it on decode and stamps it on encode. (Q1 increment 2 - the + // former optional member here was the masking surface that let modern + // vocabulary leak through every legacy-leg parse.) }); /** @@ -673,145 +648,6 @@ export const PaginatedResultSchema = ResultSchema.extend({ nextCursor: CursorSchema.optional() }); -/** - * The status of a task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - * */ -export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); - -/* Tasks */ -/** - * A pollable state object associated with a request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskSchema = z.object({ - taskId: z.string(), - status: TaskStatusSchema, - /** - * Time in milliseconds to keep task results available after completion. - * If `null`, the task has unlimited lifetime until manually cleaned up. - */ - ttl: z.union([z.number(), z.null()]), - /** - * ISO 8601 timestamp when the task was created. - */ - createdAt: z.string(), - /** - * ISO 8601 timestamp when the task was last updated. - */ - lastUpdatedAt: z.string(), - pollInterval: z.optional(z.number()), - /** - * Optional diagnostic message for failed tasks or other status information. - */ - statusMessage: z.optional(z.string()) -}); - -/** - * Result returned when a task is created, containing the task data wrapped in a `task` field. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const CreateTaskResultSchema = ResultSchema.extend({ - task: TaskSchema -}); - -/** - * Parameters for task status notification. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); - -/** - * A notification sent when a task's status changes. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskStatusNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/tasks/status'), - params: TaskStatusNotificationParamsSchema -}); - -/** - * A request to get the state of a specific task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/get'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode GetTaskRequest | tasks/get} request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); - -/** - * A request to get the result of a specific task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskPayloadRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/result'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a `tasks/result` request. - * The structure matches the result type of the original request. - * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskPayloadResultSchema = ResultSchema.loose(); - -/** - * A request to list tasks. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ - method: z.literal('tasks/list') -}); - -/** - * The response to a {@linkcode ListTasksRequest | tasks/list} request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const ListTasksResultSchema = PaginatedResultSchema.extend({ - tasks: z.array(TaskSchema) -}); - -/** - * A request to cancel a specific task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const CancelTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/cancel'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); - /* Resources */ /** * The contents of a specific resource or sub-resource. @@ -1451,9 +1287,9 @@ export const CallToolResultSchema = ResultSchema.extend({ * A list of content objects that represent the result of the tool call. * * If the `Tool` does not define an outputSchema, this field MUST be present in the result. - * For backwards compatibility, this field is always present, but it may be empty. + * Required on the wire per the specification (it may be an empty array). */ - content: z.array(ContentBlockSchema).default([]), + content: z.array(ContentBlockSchema), /** * An object containing structured tool output. @@ -1591,48 +1427,6 @@ export const LoggingMessageNotificationSchema = NotificationSchema.extend({ params: LoggingMessageNotificationParamsSchema }); -/* Per-request `_meta` envelope */ -/** - * The per-request `_meta` envelope carried by every request under protocol revision - * 2026-07-28: the protocol version governing the request, the client implementation - * info, and the client's capabilities — declared per request rather than once at - * initialization — plus the optional log-level opt-in. - * - * This schema models the complete envelope on its own. The base request schemas - * ({@linkcode RequestMetaSchema}) deliberately stay lenient so the same wire schemas - * parse requests from earlier protocol revisions (no envelope) as well; envelope - * requiredness is enforced per request at dispatch time, not here. - */ -export const RequestMetaEnvelopeSchema = z.looseObject({ - /** - * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - */ - progressToken: ProgressTokenSchema.optional(), - /** - * The MCP protocol version being used for this request. For the HTTP transport, - * the value must match the `MCP-Protocol-Version` header. - */ - [PROTOCOL_VERSION_META_KEY]: z.string(), - /** - * Identifies the client software making the request. - */ - [CLIENT_INFO_META_KEY]: ImplementationSchema, - /** - * The client's capabilities for this specific request. An empty object means the - * client supports no optional capabilities. Servers must not infer capabilities - * from prior requests. - */ - [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilitiesSchema, - /** - * The desired log level for this request. When absent, the server must not send - * `notifications/message` notifications for the request. - * - * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains - * in the specification for at least twelve months. - */ - [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() -}); - /* Sampling */ /** * Hints to use for model selection. @@ -1686,7 +1480,7 @@ export const ToolChoiceSchema = z.object({ export const ToolResultContentSchema = z.object({ type: z.literal('tool_result'), toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'), - content: z.array(ContentBlockSchema).default([]), + content: z.array(ContentBlockSchema), structuredContent: z.object({}).loose().optional(), isError: z.boolean().optional(), @@ -2200,6 +1994,12 @@ export const RootsListChangedNotificationSchema = NotificationSchema.extend({ }); /* Client messages */ +// NOTE (Q1 increment 2): the role unions below are the NEUTRAL message sets. +// The 2025-era task vocabulary (tasks/* methods, task results, the task +// status notification) is 2025-only WIRE vocabulary and now lives in +// `wire/rev2025-11-25/schemas.ts`, which also exports the era's full wire +// role unions. The deprecated Task* types remain importable from the types +// barrel (Q1-SD2); they appear in no role aggregate and no API signature. export const ClientRequestSchema = z.union([ PingRequestSchema, InitializeRequestSchema, @@ -2213,19 +2013,14 @@ export const ClientRequestSchema = z.union([ SubscribeRequestSchema, UnsubscribeRequestSchema, CallToolRequestSchema, - ListToolsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema + ListToolsRequestSchema ]); export const ClientNotificationSchema = z.union([ CancelledNotificationSchema, ProgressNotificationSchema, InitializedNotificationSchema, - RootsListChangedNotificationSchema, - TaskStatusNotificationSchema + RootsListChangedNotificationSchema ]); export const ClientResultSchema = z.union([ @@ -2233,23 +2028,11 @@ export const ClientResultSchema = z.union([ CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, - ListRootsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListRootsResultSchema ]); /* Server messages */ -export const ServerRequestSchema = z.union([ - PingRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); +export const ServerRequestSchema = z.union([PingRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema]); export const ServerNotificationSchema = z.union([ CancelledNotificationSchema, @@ -2259,7 +2042,6 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); @@ -2273,8 +2055,5 @@ export const ServerResultSchema = z.union([ ListResourceTemplatesResultSchema, ReadResourceResultSchema, CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListToolsResultSchema ]); diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 9da6a2f4a..de66e9941 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -41,8 +41,6 @@ const SPEC_SCHEMA_KEYS = [ 'CallToolResultSchema', 'CancelledNotificationSchema', 'CancelledNotificationParamsSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'ClientCapabilitiesSchema', 'ClientNotificationSchema', 'ClientRequestSchema', @@ -56,7 +54,6 @@ const SPEC_SCHEMA_KEYS = [ 'CreateMessageRequestParamsSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'DiscoverRequestSchema', 'DiscoverResultSchema', @@ -73,10 +70,6 @@ const SPEC_SCHEMA_KEYS = [ 'GetPromptRequestSchema', 'GetPromptRequestParamsSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -103,8 +96,6 @@ const SPEC_SCHEMA_KEYS = [ 'ListResourceTemplatesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -135,7 +126,6 @@ const SPEC_SCHEMA_KEYS = [ 'RelatedTaskMetadataSchema', 'RequestSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'ResourceSchema', 'ResourceContentsSchema', @@ -163,13 +153,8 @@ const SPEC_SCHEMA_KEYS = [ 'StringSchemaSchema', 'SubscribeRequestSchema', 'SubscribeRequestParamsSchema', - 'TaskSchema', 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', 'TaskMetadataSchema', - 'TaskStatusSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusNotificationParamsSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', @@ -224,10 +209,11 @@ export type SpecTypeName = StripSchemaSuffix; * Maps each {@linkcode SpecTypeName} to its TypeScript type. * * `SpecTypes['Tool']` is equivalent to importing the `Tool` type directly. - * These are WIRE validator outputs: result entries additionally carry the - * wire-only `resultType` member, which the public result types do not declare - * (the SDK consumes it at the protocol layer and strips it before results - * reach consumers). + * These validators cover the NEUTRAL model — the consumer-facing shapes with + * no wire-only members (`resultType`, the reserved `_meta` envelope keys). + * Per-revision WIRE validators are deliberately not public surface; they are + * planned to return as versioned `zod-schemas/` exports for + * consumers who validate raw wire traffic themselves. */ export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 0aa601ad0..8450aed88 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -4,6 +4,27 @@ import type * as z from 'zod/v4'; +// Wire-module schema imports, TYPE-ONLY (erased at runtime): the deprecated +// task vocabulary and the per-request envelope are wire-era artifacts whose +// schemas live in the codec modules; their inferred TYPES stay importable +// from this neutral layer (Q1-SD2). +import type { + CancelTaskRequestSchema, + CancelTaskResultSchema, + CreateTaskResultSchema, + GetTaskPayloadRequestSchema, + GetTaskPayloadResultSchema, + GetTaskRequestSchema, + GetTaskResultSchema, + ListTasksRequestSchema, + ListTasksResultSchema, + TaskCreationParamsSchema, + TaskSchema, + TaskStatusNotificationParamsSchema, + TaskStatusNotificationSchema, + TaskStatusSchema +} from '../wire/rev2025-11-25/schemas.js'; +import type { RequestMetaEnvelopeSchema } from '../wire/rev2026-07-28/schemas.js'; import type { INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR } from './constants.js'; import type { AnnotationsSchema, @@ -17,8 +38,6 @@ import type { CallToolResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, @@ -32,7 +51,6 @@ import type { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, CursorSchema, DiscoverRequestSchema, DiscoverResultSchema, @@ -49,10 +67,6 @@ import type { GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, - GetTaskPayloadRequestSchema, - GetTaskPayloadResultSchema, - GetTaskRequestSchema, - GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, @@ -74,8 +88,6 @@ import type { ListResourceTemplatesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, - ListTasksRequestSchema, - ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, @@ -106,7 +118,6 @@ import type { ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, - RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, @@ -136,12 +147,7 @@ import type { SubscribeRequestParamsSchema, SubscribeRequestSchema, TaskAugmentedRequestParamsSchema, - TaskCreationParamsSchema, TaskMetadataSchema, - TaskSchema, - TaskStatusNotificationParamsSchema, - TaskStatusNotificationSchema, - TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, diff --git a/packages/core/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts index e2005e463..8e8c708fa 100644 --- a/packages/core/src/wire/rev2025-11-25/registry.ts +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -24,8 +24,6 @@ import type * as z from 'zod/v4'; import { CallToolResultSchema, - ClientNotificationSchema, - ClientRequestSchema, CompleteResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -38,11 +36,10 @@ import { ListResourceTemplatesResultSchema, ListRootsResultSchema, ListToolsResultSchema, - ReadResourceResultSchema, - ServerNotificationSchema, - ServerRequestSchema + ReadResourceResultSchema } from '../../types/schemas.js'; import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas.js'; /* Runtime schema lookup — result schemas by method */ // Keyed by `RequestMethod` so the runtime map and the typed `ResultTypeMap` diff --git a/packages/core/src/wire/rev2025-11-25/schemas.ts b/packages/core/src/wire/rev2025-11-25/schemas.ts new file mode 100644 index 000000000..3c62d7f90 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/schemas.ts @@ -0,0 +1,326 @@ +/** + * 2025-era wire schemas: the task family (protocol revision 2025-11-25) and + * the era's full wire role unions. + * + * Everything here is 2025-only WIRE vocabulary, physically absent from the + * neutral model layer and from the 2026-era codec (Q1 increment 2 - deletions + * are physical). The task message surface was restored types-only by #2248 + * for interop with task-capable 2025 peers and is parsed ONLY through this + * era's registry; the deprecated Task* TYPES remain importable from the types + * barrel (Q1-SD2: nameability is constant, runtime availability is + * version-keyed) but appear in no API signature. + * + * Shared-tier adjudications (documented deviations from a full relocation; + * each would otherwise change frozen 2025 parse behavior, Q10-L2): + * - `RelatedTaskMetadataSchema` stays in the neutral `RequestMetaSchema`: + * `io.modelcontextprotocol/related-task` is NORMATIVE 2025-11-25 `_meta` + * vocabulary, not a leak, and the wire-only lift deliberately exempts it. + * - `TaskMetadataSchema`/`TaskAugmentedRequestParamsSchema` stay neutral: + * they are the (deprecated) `task` param member composed into the shared + * request-param schemas; removing the declared key would change strip-mode + * parsing for 2025 peers. + * - The `tasks` capability sub-schemas stay on the shared capability + * schemas for the same reason; the 2026-era codec strips `capabilities.tasks` + * on encode instead (Q1-SD3 iii). + */ +import * as z from 'zod/v4'; + +import { + BaseRequestParamsSchema, + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + ClientNotificationSchema as NeutralClientNotificationSchema, + ClientRequestSchema as NeutralClientRequestSchema, + ClientResultSchema as NeutralClientResultSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + NotificationSchema, + NotificationsParamsSchema, + PaginatedRequestSchema, + PaginatedResultSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + RequestSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + ResultSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../types/schemas.js'; + +/** + * Task creation parameters, used to ask that the server create a task to represent a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskCreationParamsSchema = z.looseObject({ + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl: z.number().optional(), + + /** + * Time in milliseconds to wait between task status requests. + */ + pollInterval: z.number().optional() +}); + +/** + * The status of a task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); + +/* Tasks */ +/** + * A pollable state object associated with a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskSchema = z.object({ + taskId: z.string(), + status: TaskStatusSchema, + /** + * Time in milliseconds to keep task results available after completion. + * If `null`, the task has unlimited lifetime until manually cleaned up. + */ + ttl: z.union([z.number(), z.null()]), + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: z.string(), + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: z.string(), + pollInterval: z.optional(z.number()), + /** + * Optional diagnostic message for failed tasks or other status information. + */ + statusMessage: z.optional(z.string()) +}); + +/** + * Result returned when a task is created, containing the task data wrapped in a `task` field. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CreateTaskResultSchema = ResultSchema.extend({ + task: TaskSchema +}); + +/** + * Parameters for task status notification. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); + +/** + * A notification sent when a task's status changes. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/tasks/status'), + params: TaskStatusNotificationParamsSchema +}); + +/** + * A request to get the state of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/get'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode GetTaskRequest | tasks/get} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); + +/** + * A request to get the result of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskPayloadRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/result'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a `tasks/result` request. + * The structure matches the result type of the original request. + * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. + * + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskPayloadResultSchema = ResultSchema.loose(); + +/** + * A request to list tasks. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('tasks/list') +}); + +/** + * The response to a {@linkcode ListTasksRequest | tasks/list} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const ListTasksResultSchema = PaginatedResultSchema.extend({ + tasks: z.array(TaskSchema) +}); + +/** + * A request to cancel a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CancelTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/cancel'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); + +/* The 2025-era wire role unions: the neutral message sets PLUS the task + * vocabulary. These are the era-faithful aggregates (what a 2025-11-25 peer + * may legally put on the wire, per role) and the source the era registry is + * built from. Member order preserves the pre-split unions (task members + * last for requests/results; notification members are method-discriminated, + * so ordering is not observable). */ +export const ClientRequestSchema = z.union([ + PingRequestSchema, + InitializeRequestSchema, + CompleteRequestSchema, + SetLevelRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, + CallToolRequestSchema, + ListToolsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ClientNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + InitializedNotificationSchema, + RootsListChangedNotificationSchema, + TaskStatusNotificationSchema +]); + +export const ClientResultSchema = z.union([ + EmptyResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitResultSchema, + ListRootsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +export const ServerRequestSchema = z.union([ + PingRequestSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + ListRootsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ServerNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + LoggingMessageNotificationSchema, + ResourceUpdatedNotificationSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + TaskStatusNotificationSchema, + ElicitationCompleteNotificationSchema +]); + +export const ServerResultSchema = z.union([ + EmptyResultSchema, + InitializeResultSchema, + CompleteResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ReadResourceResultSchema, + CallToolResultSchema, + ListToolsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +// Reference the imported neutral aggregates so the relationship is explicit +// for readers and tooling: the wire unions above are strict supersets. +void NeutralClientRequestSchema; +void NeutralClientNotificationSchema; +void NeutralClientResultSchema; diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts new file mode 100644 index 000000000..aaf03ab38 --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -0,0 +1,65 @@ +/** + * 2026-era wire schemas (protocol revision 2026-07-28). + * + * This module is the only place the per-request `_meta` envelope is modeled. + * The envelope is wire-only vocabulary: the protocol layer lifts it off + * inbound requests before any handler runs and surfaces it at + * `ctx.mcpReq.envelope`; the 2026-era codec enforces its requiredness at + * dispatch time (`checkInboundEnvelope`) - the former neutral-schema JSDoc + * deferral ("enforced per request at dispatch time, not here") is now + * discharged by that codec step. + * + * No 2025-era traffic ever touches this module, so requiredness here is + * bare and spec-exact (the shared-schema `.catch` hazards do not apply). + */ +import * as z from 'zod/v4'; + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY +} from '../../types/constants.js'; +import { ClientCapabilitiesSchema, ImplementationSchema, LoggingLevelSchema, ProgressTokenSchema } from '../../types/schemas.js'; + +/* Per-request `_meta` envelope */ +/** + * The per-request `_meta` envelope carried by every request under protocol revision + * 2026-07-28: the protocol version governing the request, the client implementation + * info, and the client's capabilities — declared per request rather than once at + * initialization — plus the optional log-level opt-in. + * + * This schema models the complete envelope on its own (loose: foreign keys + * pass through - the lift extracts exactly the reserved keys, so enforcement + * never sees extension material). Requiredness is enforced per request at + * dispatch time by the 2026-era codec's `checkInboundEnvelope` step. + */ +export const RequestMetaEnvelopeSchema = z.looseObject({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: ProgressTokenSchema.optional(), + /** + * The MCP protocol version being used for this request. For the HTTP transport, + * the value must match the `MCP-Protocol-Version` header. + */ + [PROTOCOL_VERSION_META_KEY]: z.string(), + /** + * Identifies the client software making the request. + */ + [CLIENT_INFO_META_KEY]: ImplementationSchema, + /** + * The client's capabilities for this specific request. An empty object means the + * client supports no optional capabilities. Servers must not infer capabilities + * from prior requests. + */ + [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilitiesSchema, + /** + * The desired log level for this request. When absent, the server must not send + * `notifications/message` notifications for the request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. + */ + [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() +}); diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts index d04470948..06fc311ab 100644 --- a/packages/core/test/corpus/specCorpus.test.ts +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -38,6 +38,12 @@ import { JSONRPCResultResponseSchema } from '../../src/types/schemas.js'; import * as schemas from '../../src/types/schemas.js'; +// Era routing (Q1 increment 2): each corpus revision resolves through its own +// wire-era module first — 2025 fixtures may use 2025-only vocabulary (tasks), +// 2026 fixtures use 2026-only vocabulary (envelope, discover) — then falls +// back to the shared neutral payload schemas. +import * as wire2025 from '../../src/wire/rev2025-11-25/schemas.js'; +import * as wire2026 from '../../src/wire/rev2026-07-28/schemas.js'; const FIXTURES_ROOT = join(__dirname, 'fixtures'); @@ -78,7 +84,12 @@ const PENDING_2026_FILES: Record = { type AnyZod = z.ZodType; -function schemaFor(dir: string, fixture: unknown): AnyZod | undefined { +const ERA_SCHEMAS: Record> = { + '2025-11-25': wire2025 as Record, + '2026-07-28': wire2026 as Record +}; + +function schemaFor(revision: string, dir: string, fixture: unknown): AnyZod | undefined { if (ERROR_OBJECT_DIRS.has(dir)) { // The upstream error examples mix bare `{code, message, data?}` objects // with full JSON-RPC error responses — pick by shape. @@ -91,6 +102,8 @@ function schemaFor(dir: string, fixture: unknown): AnyZod | undefined { // tool-use array content); an example instance may be either. return z.union([CreateMessageResultSchema, CreateMessageResultWithToolsSchema]) as AnyZod; } + const eraSchema = ERA_SCHEMAS[revision]?.[`${dir}Schema`]; + if (eraSchema !== undefined) return eraSchema as AnyZod; return (schemas as Record)[`${dir}Schema`] as AnyZod | undefined; } @@ -118,12 +131,12 @@ describe.each(['2025-11-25', '2026-07-28'] as const)('spec example corpus %s', r const pendingFiles = revision === '2026-07-28' ? PENDING_2026_FILES : {}; test('every example directory is mapped to a schema or explicitly pending', () => { - const unmapped = typeDirs.filter(dir => !(dir in pending) && schemaFor(dir, {}) === undefined); + const unmapped = typeDirs.filter(dir => !(dir in pending) && schemaFor(revision, dir, {}) === undefined); expect(unmapped, 'unmapped example directories — map them or add a documented pending entry').toEqual([]); }); test('pending entries are not stale (their vocabulary is still unmodeled)', () => { - const stale = Object.keys(pending).filter(dir => schemaFor(dir, {}) !== undefined); + const stale = Object.keys(pending).filter(dir => schemaFor(revision, dir, {}) !== undefined); expect(stale, 'pending entries whose schema now exists — wire the fixtures and remove the entry').toEqual([]); // Pending entries must refer to directories that actually exist. const missing = Object.keys(pending).filter(dir => !typeDirs.includes(dir)); @@ -141,7 +154,7 @@ describe.each(['2025-11-25', '2026-07-28'] as const)('spec example corpus %s', r describe.each(mappedDirs)('%s', dir => { test.each(listFixtures(revision, dir))('%s parses', file => { const fixture = loadFixture(revision, dir, file); - const schema = schemaFor(dir, fixture); + const schema = schemaFor(revision, dir, fixture); expect(schema).toBeDefined(); const parsed = schema!.safeParse(fixture); const pendingReason = pendingFiles[`${dir}/${file}`]; diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index ffee5b9a7..b50c37679 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -42,7 +42,15 @@ describe('Protocol custom-method support', () => { expect(result.items).toEqual(['result for hello']); }); - it('strips _meta from params before validation', async () => { + it('passes _meta to custom-handler validation, minus the reserved envelope keys (deliberate flip)', async () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): custom handlers + // used to have _meta DELETED before their params validation. They + // now receive it present-minus-reserved — the wire-only lift has + // already removed the io.modelcontextprotocol/* envelope keys — + // making the custom path consistent with the spec-method path. + // Strict consumer schemas that reject unknown keys must now model + // (or strip) _meta. Changeset: codec-split-wire-break; + // docs/migration.md "custom handlers receive _meta". const [a, b] = await pair(); const Strict = z.strictObject({ x: z.number() }); b.setRequestHandler('acme/strict', { params: Strict }, async params => { @@ -50,8 +58,20 @@ describe('Protocol custom-method support', () => { return {}; }); - const result = await a.request({ method: 'acme/strict', params: { x: 1, _meta: { progressToken: 't' } } }, z.object({})); - expect(result).toEqual({}); + // A strict schema now sees the metadata and rejects it… + await expect( + a.request({ method: 'acme/strict', params: { x: 1, _meta: { progressToken: 't' } } }, z.object({})) + ).rejects.toThrow(ProtocolError); + + // …while a schema that models _meta receives it verbatim. + const WithMeta = z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }); + let seenParams: unknown; + b.setRequestHandler('acme/withMeta', { params: WithMeta }, async params => { + seenParams = params; + return {}; + }); + await a.request({ method: 'acme/withMeta', params: { x: 2, _meta: { progressToken: 't' } } }, z.object({})); + expect(seenParams).toEqual({ x: 2, _meta: { progressToken: 't' } }); }); it('rejects invalid params with ProtocolError(InvalidParams)', async () => { @@ -112,17 +132,22 @@ describe('Protocol custom-method support', () => { expect(seen).toEqual([{ stage: 'fetch', pct: 0.5 }]); }); - it('passes the raw notification (with _meta) as the second handler argument', async () => { + it('passes _meta through custom-notification validation, minus reserved keys (deliberate flip)', async () => { + // Same behavior migration as the request path: _meta is no longer + // deleted before the consumer schema runs (ledgered; changeset: + // codec-split-wire-break). const [a, b] = await pair(); - const Strict = z.strictObject({ stage: z.string() }); + const WithMeta = z.strictObject({ stage: z.string(), _meta: z.record(z.string(), z.unknown()).optional() }); + let seenParams: unknown; let seenMeta: unknown; - b.setNotificationHandler('acme/searchProgress', { params: Strict }, (params, notification) => { - expect(params).toEqual({ stage: 'fetch' }); + b.setNotificationHandler('acme/searchProgress', { params: WithMeta }, (params, notification) => { + seenParams = params; seenMeta = notification.params?._meta; }); await a.notification({ method: 'acme/searchProgress', params: { stage: 'fetch', _meta: { traceId: 't1' } } }); await new Promise(r => setTimeout(r, 0)); + expect(seenParams).toEqual({ stage: 'fetch', _meta: { traceId: 't1' } }); expect(seenMeta).toEqual({ traceId: 't1' }); }); }); diff --git a/packages/core/test/shared/rawResultTypeFirst.test.ts b/packages/core/test/shared/rawResultTypeFirst.test.ts index 6ccec21b9..b9710b6e4 100644 --- a/packages/core/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core/test/shared/rawResultTypeFirst.test.ts @@ -84,35 +84,25 @@ describe('raw-first resultType discrimination in the request funnel', () => { await protocol.close(); }); - test('a non-string resultType can never surface as a success (rejected at message classification)', async () => { - // A response whose resultType is not a string fails the JSON-RPC - // envelope classification (the wire schema types the member), so it - // is reported out-of-band and never reaches the result funnel — and - // can therefore never be masked into a success. The funnel keeps a - // defensive raw-type check for the day classification loosens. + test('a non-string resultType can never surface as a success (rejected in the funnel)', async () => { + // Pre-codec-split, a non-string resultType died at JSON-RPC envelope + // classification because the SHARED wire schema typed the member as + // an optional string. With resultType cut from the neutral schemas + // (Q1 increment 2 — the masking surface is gone), the loose envelope + // passes the foreign key through and the funnel's defensive raw-type + // arm rejects it IN-BAND with a typed error. Either way it can never + // be masked into a success — which is the V-1 invariant this test + // exists to pin. const protocol = await wireWithRawResult({ resultType: 42, content: [] }); - const outOfBand: Error[] = []; - protocol.onerror = error => void outOfBand.push(error); - - let settled: unknown; - const pending = protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }).then( - result => { - settled = { resolved: result }; - }, - error => { - settled = { rejected: error }; - } - ); - await new Promise(resolve => setTimeout(resolve, 50)); - expect(settled, 'must not resolve as a success').toBeUndefined(); - expect(outOfBand.length).toBeGreaterThan(0); - expect(String(outOfBand[0]?.message)).toContain('Unknown message type'); + const rejection = await protocol + .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + expect((rejection as SdkError).data).toMatchObject({ resultType: 42 }); - // Teardown settles the in-flight request (connection closed). await protocol.close(); - await pending; - expect(settled).toHaveProperty('rejected'); }); test("resultType 'complete' is consumed: the result resolves without the wire member", async () => { diff --git a/packages/core/test/shared/typedMapAlignment.test.ts b/packages/core/test/shared/typedMapAlignment.test.ts index 59d5345c2..1cd836d3d 100644 --- a/packages/core/test/shared/typedMapAlignment.test.ts +++ b/packages/core/test/shared/typedMapAlignment.test.ts @@ -93,20 +93,25 @@ describe('task-shaped result bodies against the narrowed runtime map', () => { await protocol.close(); }); - test('tools/call: the tolerant result schema still accepts the body (pre-existing; the old union member was unreachable)', async () => { - // Honest pin, not an endorsement: CallToolResultSchema defaults - // `content` to [] and is loose, so it accepts ANY object — including - // a task body. That made the old union's CreateTaskResultSchema - // member unreachable for tools/call (first member always matched), - // so the narrowing changes nothing observable here; the body parses - // as a content-empty CallToolResult with `task` passing through the - // loose index signature, exactly as before. Rejecting it is a result- - // schema-strictness question, out of scope for the map alignment. + test('tools/call: a CreateTaskResult body is now a typed invalid-result error too (content-default removal flip)', async () => { + // FLIPPED PIN (Q1 increment 2, ledgered with the content-default + // removal — changeset: codec-split-wire-break). The previous "Honest + // pin, not an endorsement" recorded that CallToolResultSchema's + // content.default([]) swallowed ANY object — including a task body — + // as a content-empty success, which made the old union member + // unreachable and the map narrowing observationally invisible for + // tools/call. With `content` now REQUIRED at the wire boundary the + // masking surface is gone: a task body has no `content`, fails the + // plain schema, and surfaces as the same typed invalid-result error + // as sampling/elicit. The result-schema-strictness question the old + // pin deferred is hereby resolved: loud rejection. const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); - const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); - expect(result.content).toEqual([]); - expect((result as Record).task).toEqual(CREATE_TASK_RESULT_BODY.task); + const rejection = await protocol + .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); await protocol.close(); }); diff --git a/packages/core/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index 45adde80e..40b14a43c 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -14,6 +14,19 @@ import path from 'node:path'; import type * as SpecTypes from '../src/types/spec.types.2025-11-25.js'; import type * as SDKTypes from '../src/types/index.js'; +// The era-faithful 2025 wire role unions (Q1 increment 2): the NEUTRAL role +// aggregates no longer carry task vocabulary — the 2025-era wire module does. +// Role-union comparisons against this FROZEN revision's anchor therefore +// target the wire-era artifacts. +import type * as Wire2025 from '../src/wire/rev2025-11-25/schemas.js'; +import type * as z4 from 'zod/v4'; + +type Wire2025ClientRequest = z4.infer; +type Wire2025ClientNotification = z4.infer; +type Wire2025ClientResult = z4.infer; +type Wire2025ServerRequest = z4.infer; +type Wire2025ServerNotification = z4.infer; +type Wire2025ServerResult = z4.infer; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -220,15 +233,15 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ClientResult: (sdk: SDKTypes.ClientResult, spec: SpecTypes.ClientResult) => { + ClientResult: (sdk: Wire2025ClientResult, spec: SpecTypes.ClientResult) => { sdk = spec; spec = sdk; }, - ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { sdk = spec; spec = sdk; }, - ServerResult: (sdk: SDKTypes.ServerResult, spec: SpecTypes.ServerResult) => { + ServerResult: (sdk: Wire2025ServerResult, spec: SpecTypes.ServerResult) => { sdk = spec; spec = sdk; }, @@ -502,12 +515,12 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object` (via the InitializeRequest member); the SDK follows the 2026-07-28 schema's JSONObject sdk = spec; spec = sdk; }, - ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { + ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above sdk = spec; // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above @@ -517,7 +530,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { + ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { sdk = spec; spec = sdk; }, diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts index a92615bce..70b0b02a8 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -18,7 +18,6 @@ import { LOG_LEVEL_META_KEY, PromptMessageSchema, PROTOCOL_VERSION_META_KEY, - RequestMetaEnvelopeSchema, ResourceLinkSchema, ResultSchema, SamplingMessageSchema, @@ -28,6 +27,12 @@ import { ToolSchema, ToolUseContentSchema } from '../src/types/index.js'; +// Wire-era modules (Q1 increment 2): the per-request envelope lives in the +// 2026-era schemas; the era-faithful 2025 role unions (incl. tasks) live in +// the 2025-era schemas. +import { getRequestSchema } from '../src/wire/rev2025-11-25/registry.js'; +import { ClientRequestSchema as Wire2025ClientRequestSchema } from '../src/wire/rev2025-11-25/schemas.js'; +import { RequestMetaEnvelopeSchema } from '../src/wire/rev2026-07-28/schemas.js'; describe('Types', () => { test('should have correct latest protocol version', () => { @@ -291,10 +296,13 @@ describe('Types', () => { } }); - test('should validate empty content array with default', () => { - const toolResult = {}; - - const result = CallToolResultSchema.safeParse(toolResult); + test('requires content: the empty-object result no longer parses (deliberate flip)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): content.default([]) + // was removed from the wire schema (the T6 silent-empty-success + // masking root). Content is spec-required in every revision. + // Changeset: codec-split-wire-break. + expect(CallToolResultSchema.safeParse({}).success).toBe(false); + const result = CallToolResultSchema.safeParse({ content: [] }); expect(result.success).toBe(true); if (result.success) { expect(result.data.content).toEqual([]); @@ -567,6 +575,9 @@ describe('Types', () => { const toolResult = { type: 'tool_result', toolUseId: 'call_123', + // content is spec-required (the wire default([]) was removed — + // Q1 increment 2, ledgered; changeset: codec-split-wire-break). + content: [], structuredContent: { temperature: 72, condition: 'sunny' } }; @@ -583,6 +594,7 @@ describe('Types', () => { const toolResult = { type: 'tool_result', toolUseId: 'call_456', + content: [], structuredContent: { error: 'API_ERROR', message: 'Service unavailable' }, isError: true }; @@ -1025,9 +1037,15 @@ describe('Types', () => { }); describe('2025-11-25 task wire interop (task feature removed; wire types remain)', () => { - test('tasks/get parses through the client request union', () => { - const result = ClientRequestSchema.safeParse({ method: 'tasks/get', params: { taskId: 'task-123' } }); + test('tasks/get parses through the 2025-era wire request union and registry', () => { + // The task wire surface moved into the 2025-era codec module (Q1 + // increment 2): interop with task-capable 2025 peers is served by the + // era registry, and the NEUTRAL ClientRequestSchema no longer carries + // task vocabulary (deletions are physical on the 2026 era). + const result = Wire2025ClientRequestSchema.safeParse({ method: 'tasks/get', params: { taskId: 'task-123' } }); expect(result.success).toBe(true); + expect(getRequestSchema('tasks/get')).toBeDefined(); + expect(ClientRequestSchema.options.some(option => (option.shape.method.value as string) === 'tasks/get')).toBe(false); }); test('task-augmented tools/call params parse and retain the task field', () => { @@ -1148,26 +1166,25 @@ describe('2026-07-28 wire shapes', () => { }); }); - describe('Result resultType passthrough', () => { - test('accepts results with and without resultType (absent means "complete")', () => { + describe('Result resultType (cut from the neutral schemas — Q1 increment 2, ledgered)', () => { + test('the base ResultSchema no longer declares resultType; the key is loose passthrough only', () => { + // BEHAVIOR MIGRATION: the optional resultType member — the + // masking surface that let 2026 vocabulary through every + // legacy-leg parse — is gone. The wire member lives only in the + // 2026-era codec module. A foreign resultType still transits the + // loose base parse as an UNDECLARED sibling (it can no longer + // type-check, and the protocol path strips/consumes it per era). const withIt = ResultSchema.safeParse({ resultType: 'complete' }); expect(withIt.success).toBe(true); - if (withIt.success) { - expect(withIt.data.resultType).toBe('complete'); - } - const withoutIt = ResultSchema.safeParse({}); - expect(withoutIt.success).toBe(true); - if (withoutIt.success) { - expect(withoutIt.data.resultType).toBeUndefined(); - } - }); - - test('rejects a non-string resultType', () => { - expect(ResultSchema.safeParse({ resultType: 42 }).success).toBe(false); + // Non-string values are no longer schema-rejected here (the + // member is undeclared): era handling owns the raw value. + expect(ResultSchema.safeParse({ resultType: 42 }).success).toBe(true); + expect(Object.keys(ResultSchema.shape)).toEqual(['_meta']); }); - test('EmptyResult accepts resultType but still rejects unknown keys', () => { - expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(true); + test('EmptyResult rejects resultType like any unknown key (deliberate flip)', () => { + // Changeset: codec-split-wire-break. + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(false); expect(EmptyResultSchema.safeParse({ unexpected: true }).success).toBe(false); }); }); diff --git a/packages/core/test/types/registryPins.test.ts b/packages/core/test/types/registryPins.test.ts index 0e1b0eac7..73222b8eb 100644 --- a/packages/core/test/types/registryPins.test.ts +++ b/packages/core/test/types/registryPins.test.ts @@ -24,7 +24,6 @@ import { CallToolRequestSchema, CallToolResultSchema, CancelledNotificationSchema, - CancelTaskRequestSchema, CompleteRequestSchema, CompleteResultSchema, CreateMessageRequestSchema, @@ -35,8 +34,6 @@ import { EmptyResultSchema, GetPromptRequestSchema, GetPromptResultSchema, - GetTaskPayloadRequestSchema, - GetTaskRequestSchema, InitializedNotificationSchema, InitializeRequestSchema, InitializeResultSchema, @@ -48,7 +45,6 @@ import { ListResourceTemplatesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, - ListTasksRequestSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingMessageNotificationSchema, @@ -62,13 +58,22 @@ import { RootsListChangedNotificationSchema, SetLevelRequestSchema, SubscribeRequestSchema, - TaskStatusNotificationSchema, ToolListChangedNotificationSchema, UnsubscribeRequestSchema } from '../../src/types/index.js'; // Post-relocation home (Q1 increment-2 step 1): the pinned contents are // unchanged — only the module housing the registries moved. import { getNotificationSchema, getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; +// The 2025-only task wire vocabulary now lives in the era's schema module +// (Q1 increment-2 step 4); the schema OBJECTS serving the registry are the +// same — these pins still hold by reference. +import { + CancelTaskRequestSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + ListTasksRequestSchema, + TaskStatusNotificationSchema +} from '../../src/wire/rev2025-11-25/schemas.js'; /** The exact 2025-era request-method → schema map (today's wire surface, verbatim). */ const EXPECTED_REQUEST_SCHEMAS = { diff --git a/packages/core/test/types/schemaBoundaryPins.test.ts b/packages/core/test/types/schemaBoundaryPins.test.ts index 5cb1f5ccc..0f18151be 100644 --- a/packages/core/test/types/schemaBoundaryPins.test.ts +++ b/packages/core/test/types/schemaBoundaryPins.test.ts @@ -23,9 +23,11 @@ import { JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResultResponseSchema, - RequestMetaEnvelopeSchema, ResultSchema } from '../../src/types/index.js'; +// The per-request envelope is wire-only vocabulary and now lives in the +// 2026-era wire module (Q1 increment 2); its accept/reject line is unchanged. +import { RequestMetaEnvelopeSchema } from '../../src/wire/rev2026-07-28/schemas.js'; import type { CallToolResult, CompleteResult, @@ -80,10 +82,17 @@ describe('EmptyResultSchema is strict', () => { expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); }); - test('the declared _meta and resultType members are accepted', () => { + test('the declared _meta member is accepted; resultType now rejects (deliberate flip)', () => { expect(EmptyResultSchema.safeParse({}).success).toBe(true); expect(EmptyResultSchema.safeParse({ _meta: { note: 'x' } }).success).toBe(true); - expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(true); + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `resultType` was cut + // from the base ResultSchema, so the strict empty-result ack now + // REJECTS `{resultType}` bodies at the schema level. On the protocol + // path this is invisible for conforming peers: the era codec consumes + // (2026) or strips (2025, Q1-SD3 ii) the wire member before any + // schema validation runs. Changeset: codec-split-wire-break; + // docs/migration.md "Wire schemas no longer model resultType". + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(false); }); }); @@ -99,10 +108,18 @@ describe('typed request params strip unknown siblings', () => { }); describe('typed result schemas are loose', () => { - test('the base ResultSchema declares resultType and passes unknown siblings through', () => { + test('the base ResultSchema no longer declares resultType (the masking surface is gone)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): the optional + // `resultType` member that every legacy-leg parse silently accepted + // is cut. The key still passes the loose parse as a FOREIGN sibling + // (guards are consumer-side value checks, not wire validators), but + // no neutral schema declares it; on the protocol path the 2025-era + // codec strips it on lift (Q1-SD3 ii) and the 2026-era codec consumes + // it. Changeset: codec-split-wire-break. const parsed = ResultSchema.parse({ resultType: 'complete', futureField: 'kept' }); - expect(parsed.resultType).toBe('complete'); + expect('resultType' in parsed).toBe(true); // loose passthrough, undeclared expect((parsed as Record).futureField).toBe('kept'); + expect(Object.keys(ResultSchema.shape)).toEqual(['_meta']); }); test('unknown top-level siblings on a tools/call result survive the parse', () => { @@ -112,15 +129,20 @@ describe('typed result schemas are loose', () => { ttlMs: 5 }); expect(parsed.content).toEqual([{ type: 'text', text: 'metered' }]); - expect(parsed.resultType).toBe('complete'); + expect((parsed as Record).resultType).toBe('complete'); // undeclared foreign key, loose passthrough expect((parsed as Record).ttlMs).toBe(5); }); - test('CallToolResult content defaults to the empty array when absent', () => { - // A tool result may carry only structuredContent; the parse then supplies - // content: [] for backwards compatibility. Removing the default would be a - // consumer-visible change for every result that omits content. - const parsed = CallToolResultSchema.parse({ structuredContent: { ok: true } }); + test('CallToolResult requires content on the wire (the silent-empty-success default is gone)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `content.default([])` + // was removed from the wire schema. The default was the T6 width-leak + // root: a task-shaped (or otherwise content-less) body parsed as a + // silent `{content: []}` success. Content is required by the spec in + // every revision; a content-less body now fails the parse LOUDLY. + // Changeset: codec-split-wire-break; docs/migration.md + // "tools/call results must include content". + expect(CallToolResultSchema.safeParse({ structuredContent: { ok: true } }).success).toBe(false); + const parsed = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } }); expect(parsed.content).toEqual([]); expect(parsed.structuredContent).toEqual({ ok: true }); }); diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 85e7d4c19..7a077717c 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -90,15 +90,20 @@ describe('isSpecType', () => { } }); - it('narrows to the input type, not the output type, for schemas with defaults', () => { - const v: unknown = {}; + it('CallToolResult requires content at the boundary (the wire default was removed)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): CallToolResultSchema + // lost `content.default([])` — the silent-empty-success masking root. + // The guard's input shape now requires content, matching the spec in + // every revision. Changeset: codec-split-wire-break. + const empty: unknown = {}; + expect(isSpecType.CallToolResult(empty)).toBe(false); + const v: unknown = { content: [] }; expect(isSpecType.CallToolResult(v)).toBe(true); if (isSpecType.CallToolResult(v)) { - // CallToolResultSchema has `content: z.array(...).default([])`, so the input type - // permits `content` to be absent. The guard narrows to that input shape. - expectTypeOf(v.content).toEqualTypeOf(); - expectTypeOf(v).not.toEqualTypeOf(); + expectTypeOf(v.content).toEqualTypeOf(); + expectTypeOf(v.content).not.toEqualTypeOf(); } + void (0 as unknown as CallToolResult); }); it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => { @@ -134,13 +139,16 @@ describe('SpecTypeName / SpecTypes (type-level)', () => { }); it('SpecTypes[K] matches the named export type', () => { - // Result entries are WIRE validator outputs: they carry the wire-only - // `resultType` member that the public result types deliberately do not - // declare. Stripping it must yield exactly the public type — pinned in - // both directions (the wire schema keeps modeling the member). - type StripWireOnly = { [K in keyof T as K extends 'resultType' ? never : K]: T[K] }; - expectTypeOf>().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + // RE-SCOPE (Q1 increment 2, ledgered): specTypeSchemas now validate + // the NEUTRAL model. Result entries no longer carry the wire-only + // `resultType` member — the strip-then-equal pin from the public-face + // cut reverts to plain equality, and per-revision wire validators are + // deliberately NOT public surface (addable later via the versioned + // zod-schemas exports). Changeset: codec-split-wire-break. + expectTypeOf().toEqualTypeOf(); + type KnownKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + type DeclaresResultType = 'resultType' extends KnownKeys ? true : false; + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/core/test/types/wireOnlyHiding.test.ts b/packages/core/test/types/wireOnlyHiding.test.ts index 8b2ea7526..1a71e600c 100644 --- a/packages/core/test/types/wireOnlyHiding.test.ts +++ b/packages/core/test/types/wireOnlyHiding.test.ts @@ -79,9 +79,14 @@ describe('wire-only members are hidden from the public result types', () => { expect(handlerBuilt).toBeDefined(); }); - test('the wire schemas keep modeling resultType internally', () => { - expectTypeOf>>().toEqualTypeOf(); - expectTypeOf>>().toEqualTypeOf(); + test('no neutral schema models resultType any more (the masking surface is dead)', () => { + // Q1 increment 2 (ledgered): the shared schema set carried an + // optional resultType on every result parse — the masking surface. + // Post-split, NO neutral schema declares it; the member exists only + // inside the 2026-era wire codec module. Changeset: + // codec-split-wire-break. + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); }); }); @@ -125,15 +130,25 @@ describe('task vocabulary is importable but in no API signature', () => { test('the task Zod schemas and the related-task meta key carry @deprecated too', () => { // The migration docs claim the FULL task wire surface is deprecated — - // schemas and constants included, not just the inferred types. - const schemas = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'schemas.ts'), 'utf8'); - const schemaExports = [...schemas.matchAll(/export const (\w*Tasks?\w*Schema) /g)].map(match => match[1]); - expect(schemaExports.length).toBeGreaterThanOrEqual(19); - for (const name of schemaExports) { - const declaration = schemas.indexOf(`export const ${name} `); - const preceding = schemas.slice(Math.max(0, declaration - 400), declaration); - expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + // schemas and constants included, not just the inferred types. The + // task MESSAGE schemas live in the 2025-era wire module since the + // codec split (Q1 increment 2); the param-side carriers stay in the + // neutral file. Both homes are scanned — the combined surface is the + // same ≥19 schemas the docs claim covers. + const neutral = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'schemas.ts'), 'utf8'); + const wire2025 = readFileSync(join(__dirname, '..', '..', 'src', 'wire', 'rev2025-11-25', 'schemas.ts'), 'utf8'); + let total = 0; + for (const schemas of [neutral, wire2025]) { + const schemaExports = [...schemas.matchAll(/export const (\w*Tasks?\w*Schema) /g)].map(match => match[1]); + total += schemaExports.length; + for (const name of schemaExports) { + const declaration = schemas.indexOf(`export const ${name} `); + const preceding = schemas.slice(Math.max(0, declaration - 400), declaration); + expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + } } + expect(total).toBeGreaterThanOrEqual(19); + const schemas = neutral; // The `tasks` capability keys on both capability objects. for (const member of ['tasks: ClientTasksCapabilitySchema.optional()', 'tasks: ServerTasksCapabilitySchema.optional()']) { From b6b3c0d6b15721f27442dd95bb6f49b6215f20f1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 16:13:39 +0000 Subject: [PATCH 06/15] test(integration): tools/call fixtures include the now-required content member The wire default([]) removal (ledgered; changeset codec-split-wire-break) means handler results must carry content; the outputSchema-validation fakes returned structuredContent only. --- test/integration/test/client/client.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index a7613b24e..a151a1405 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -1769,6 +1769,9 @@ describe('outputSchema validation', () => { server.setRequestHandler('tools/call', async request => { if (request.params.name === 'test-tool') { return { + // content is spec-required (the wire default([]) was removed + // - ledgered; changeset codec-split-wire-break) + content: [], structuredContent: { result: 'success', count: 42 } }; } @@ -1844,6 +1847,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'test-tool') { // Return invalid structured content (count is string instead of number) return { + content: [], structuredContent: { result: 'success', count: 'not a number' } }; } @@ -2071,6 +2075,7 @@ describe('outputSchema validation', () => { server.setRequestHandler('tools/call', async request => { if (request.params.name === 'complex-tool') { return { + content: [], structuredContent: { name: 'John Doe', age: 30, @@ -2156,6 +2161,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'strict-tool') { // Return structured content with extra property return { + content: [], structuredContent: { name: 'John', extraField: 'not allowed' From 8ffda0d0fbbb6bc1d3566d493242410704e60ca4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 18:42:26 +0000 Subject: [PATCH 07/15] fix(client): clear the outbound wire-era binding on fresh connect The negotiated wire-era binding is connection state, but it survived close(): an instance that once negotiated 2026-07-28 could never re-run a fresh initialize handshake, because the stale modern binding resolved the outbound codec before the bootstrap pin and 'initialize' is physically absent from the modern registry. Clear the binding at the start of a fresh connect (no sessionId), so the handshake rides the pre-negotiation bootstrap pin and the connection can re-negotiate. The resume path is untouched: it re-binds the originally negotiated version instead of clearing. --- packages/client/src/client/client.ts | 11 ++++++- packages/core/src/index.ts | 2 +- packages/core/src/wire/codec.ts | 13 ++++++++ test/integration/test/client/client.test.ts | 35 +++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a15211799..aa7985578 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -56,7 +56,8 @@ import { ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode + SdkErrorCode, + unbindWireVersion } from '@modelcontextprotocol/core'; /** @@ -450,6 +451,14 @@ export class Client extends Protocol { } return; } + // Fresh connect: a wire-era binding left over from a previous + // connection is stale connection state — clear it so the initialize + // handshake below rides the pre-negotiation bootstrap pin (legacy + // era) instead of a dead session's era. Without this, an instance + // that once negotiated a modern era could never re-run a fresh + // handshake: `initialize` is physically absent from the modern + // registry. (The resume branch above re-binds instead of clearing.) + unbindWireVersion(this); try { const result = await this.request( { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a7981b42a..887b76ea8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,7 +17,7 @@ export * from './util/inMemory.js'; export * from './util/schema.js'; export * from './util/standardSchema.js'; export * from './util/zodCompat.js'; -export { bindWireVersion, codecForContext } from './wire/codec.js'; +export { bindWireVersion, codecForContext, unbindWireVersion } from './wire/codec.js'; // Validator providers are type-only here — import the runtime classes from the explicit // `@modelcontextprotocol/{core,client,server}/validators/{ajv,cf-worker}` subpaths to customise. diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 976089166..613eea24c 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -218,6 +218,19 @@ export function bindWireVersion(owner: object, version: string | undefined): voi outboundWireVersion.set(owner, version); } +/** + * Clear a protocol instance's outbound wire-version binding. Called at the + * start of a FRESH connect (no session to resume): the binding is connection + * state, and a binding left over from a previous connection must not route + * the new connection's lifecycle messages — an unbound instance is + * legacy-era and its `initialize` rides the bootstrap pin. The resume path + * (transport with a sessionId) deliberately does NOT clear: it re-binds the + * originally negotiated version instead. + */ +export function unbindWireVersion(owner: object): void { + outboundWireVersion.delete(owner); +} + /** The codec serving a protocol instance's outbound traffic (legacy when unbound). */ export function outboundCodecFor(owner: object): WireCodec { return codecForVersion(outboundWireVersion.get(owner)); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index a151a1405..d9fa7265d 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -171,6 +171,41 @@ test('should restore negotiated protocol version on transport when reconnecting expect(reconnectSetProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); }); +/*** + * Test: The wire-era binding is connection state — it must not survive into a fresh connect. + * A client whose previous connection negotiated the modern revision (2026-07-28) must still be + * able to run a FRESH initialize handshake: `initialize` is legacy-era vocabulary by definition + * (it is physically absent from the modern registry), so a binding left over from the dead + * connection would otherwise kill the handshake locally before it reaches the transport. + */ +test('should run a fresh initialize handshake after close() when the previous connection negotiated the modern era', async () => { + const MODERN_REVISION = '2026-07-28'; + const supportedProtocolVersions = [MODERN_REVISION, ...SUPPORTED_PROTOCOL_VERSIONS]; + + const connectModern = async (client: Client) => { + const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }; + + const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions }); + + // First connection negotiates the modern revision and binds the modern wire era. + await connectModern(client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + + await client.close(); + + // Fresh connect (new transport, no sessionId): the stale binding is cleared, the + // handshake rides the pre-negotiation bootstrap pin (legacy era), and the connection + // can re-negotiate the modern revision. + await connectModern(client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + + await client.close(); +}); + /*** * Test: Reject Unsupported Protocol Version */ From 9e9f97ec867193114421be5a55f721893710e698 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 18:51:49 +0000 Subject: [PATCH 08/15] fix(core): a throw inside codec.encodeResult answers -32603 instead of stranding the peer encodeResult runs in the success arm of the response dispatch, where a throw was uncatchable by the error arm and fell through to onerror without ever answering the peer - the remote request hung until its timeout. Wrap the encode hop: a throw now reports locally AND sends a -32603 error response, and the connection stays serviceable. (The seam gains ttlMs/cacheScope stamping content in M3.2, so this hardens before that lands.) --- packages/core/src/shared/protocol.ts | 24 ++++++++--- packages/core/test/shared/protocol.test.ts | 46 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index ec381ee65..1f9e0ad1c 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -728,13 +728,25 @@ export abstract class Protocol { return; } + // The outbound stamp seam: the era codec maps the neutral + // handler result to its wire shape. The 2025-era codec is + // the identity (never-stamp); the 2026-era codec stamps + // `resultType` and enforces the deleted-field set. A throw + // here is a NEW failure mode between handler success and + // the transport send (and the seam grows ttlMs/cacheScope + // stamping content in M3.2) — it must answer the peer with + // −32603 rather than stranding the request until timeout. + let encoded: Result; + try { + encoded = codec.encodeResult(request.method, result); + } catch (error) { + this._onerror(new Error(`Failed to encode result for ${request.method}: ${error}`)); + sendErrorResponse(ProtocolErrorCode.InternalError, 'Internal error'); + return; + } + const response: JSONRPCResponse = { - // The outbound stamp seam: the era codec maps the - // neutral handler result to its wire shape. The - // 2025-era codec is the identity (never-stamp); the - // 2026-era codec stamps `resultType` and enforces the - // deleted-field set. - result: codec.encodeResult(request.method, result), + result: encoded, jsonrpc: '2.0', id: request.id }; diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 6e77430d6..10af74056 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -22,6 +22,8 @@ import type { } from '../../src/types/index.js'; import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec.js'; // Test Protocol subclass for testing class TestProtocolImpl extends Protocol { @@ -910,3 +912,47 @@ describe('mergeCapabilities', () => { expect(merged).toEqual({}); }); }); + +describe('codec-seam hardening in the protocol funnels', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + test('a throw inside codec.encodeResult answers −32603 on the wire — the peer is never stranded', async () => { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = createTestProtocol(); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => ({ ok: true }) as Result); + await protocol.connect(protocolTx); + + // The encode hop is the only throw-capable step between handler + // success and the transport send (and it grows stamping content in + // M3.2). Force it to throw once. + vi.spyOn(rev2025Codec, 'encodeResult').mockImplementationOnce(() => { + throw new Error('stamp exploded'); + }); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/op', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.InternalError }); + // Surfaced locally too. + expect(errors.some(error => error.message.includes('Failed to encode result'))).toBe(true); + + // The connection stays serviceable: the next request round-trips. + await peerTx.send({ jsonrpc: '2.0', id: 2, method: 'acme/op', params: {} }); + await flush(); + expect(sent).toHaveLength(2); + expect((sent[1] as JSONRPCResultResponse).result).toMatchObject({ ok: true }); + + await protocol.close(); + }); +}); From bff23c747793de206efafab56083ce02f9faaf5f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 18:44:06 +0000 Subject: [PATCH 09/15] fix(core): unknown-method -32601 takes precedence over envelope -32602 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request to a genuinely unknown method that also lacked the era's required _meta envelope answered -32602 (invalid params) instead of -32601 (method not found): the envelope check ran before the handler-existence check. Move it after — method existence outranks parameter validity. The era gate (-32601 by registry absence) stays first; the canonical precedence table for the full inbound validation ladder arrives with the validation-ladder milestone. --- packages/core/src/shared/protocol.ts | 20 +++++--- packages/core/test/shared/protocol.test.ts | 53 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 1f9e0ad1c..f22070ccf 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -654,27 +654,33 @@ export abstract class Protocol { return; } + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + if (handler === undefined) { + sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); + return; + } + // Envelope enforcement: the 2026 era requires the per-request `_meta` // envelope on every request (spec.types.2026-07-28 RequestParams). // The lift extracted it above; the era codec validates requiredness. + // Deliberately AFTER the era gate and the handler-existence check: + // an unknown method answers −32601 even when the envelope is also + // missing — method existence outranks parameter validity. (The + // canonical precedence table for the full inbound validation ladder + // arrives with the validation-ladder milestone; this site encodes + // only the −32601-over-−32602 rule.) const envelopeError = codec.checkInboundEnvelope(lifted); if (envelopeError !== undefined) { sendErrorResponse(ProtocolErrorCode.InvalidParams, envelopeError); return; } - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; - const sendNotification = (notification: Notification, options?: NotificationOptions) => this._notificationViaCodec(codec, notification, { ...options, relatedRequestId: request.id }); const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => this._requestWithSchemaViaCodec(codec, r, resultSchema, { ...options, relatedRequestId: request.id }); - if (handler === undefined) { - sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); - return; - } - const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 10af74056..29463b4f0 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -956,3 +956,56 @@ describe('codec-seam hardening in the protocol funnels', () => { await protocol.close(); }); }); + +describe('inbound validation precedence: −32601 outranks envelope −32602', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + async function wireWithFailingEnvelope(setup?: (protocol: TestProtocolImpl) => void) { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = createTestProtocol(); + setup?.(protocol); + await protocol.connect(protocolTx); + + // Force the era's envelope check to fail for every request, so the + // test pins WHERE in the ladder it runs, independent of era wiring. + vi.spyOn(rev2025Codec, 'checkInboundEnvelope').mockImplementation(() => 'Request is missing the required _meta envelope'); + + return { peerTx, sent, flush }; + } + + test('a genuinely unknown method answers −32601 even when the envelope check would also fail', async () => { + const { peerTx, sent } = await wireWithFailingEnvelope(); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/no-such-method', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.MethodNotFound, + message: 'Method not found' + }); + }); + + test('a served method still answers −32602 when the envelope check fails', async () => { + const { peerTx, sent } = await wireWithFailingEnvelope(protocol => { + protocol.setRequestHandler('acme/known', { params: z.looseObject({}) }, () => ({}) as Result); + }); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/known', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: 'Request is missing the required _meta envelope' + }); + }); +}); From d5c8452179090ad53f923d6a81bf7290a34dd4f9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 18:44:40 +0000 Subject: [PATCH 10/15] test(server): pin the tools/call handler-result validation arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration guide promises that a tools/call handler result without content is rejected with -32602 'Invalid tools/call result' (the content.default([]) affordance was removed). The producer exists in the server's wrapped handler, but no server-side test pinned it — the existing suites cover the client-decode side only. Pin both arms: structured-only result answers -32602 on the wire; an authored-content result passes through untouched. --- packages/server/test/server/server.test.ts | 65 +++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0307681f4..4ca198535 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { CallToolResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; import { InitializeResultSchema, InMemoryTransport, @@ -154,4 +154,67 @@ describe('Server', () => { await server.close(); }); }); + + describe('tools/call handler-result validation (required content)', () => { + // Server-side pin for the documented wire break (docs/migration.md, + // "CallToolResult.content … required at the wire boundary"): with the + // content.default([]) affordance removed, a handler result without + // `content` is rejected with -32602 `Invalid tools/call result` — + // never silently defaulted onto the wire — while an authored-content + // result passes through the wrapped handler untouched. + async function callToolOnServer(result: CallToolResult): Promise { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', () => result); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const received: JSONRPCMessage[] = []; + clientTransport.onmessage = message => void received.push(message); + await server.connect(serverTransport); + await clientTransport.start(); + + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + }); + await clientTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await clientTransport.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: {} } }); + await new Promise(resolve => setTimeout(resolve, 10)); + await server.close(); + + const response = received.find(message => (message as { id?: unknown }).id === 2); + if (!response) { + throw new Error('no tools/call response received'); + } + return response; + } + + it('rejects a structured-only handler result (no content) with -32602 Invalid tools/call result', async () => { + const response = await callToolOnServer({ structuredContent: { ok: true } } as unknown as CallToolResult); + + const error = (response as { error?: { code: number; message: string } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + expect(error!.message).toContain('Invalid tools/call result'); + }); + + it('passes an authored-content result through to the wire', async () => { + const response = await callToolOnServer({ + content: [{ type: 'text', text: 'hi' }], + structuredContent: { ok: true } + }); + + if (!isJSONRPCResultResponse(response)) { + throw new Error(`Expected a result response, got: ${JSON.stringify(response)}`); + } + const result = response.result as { content: unknown; structuredContent: unknown }; + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + expect(result.structuredContent).toEqual({ ok: true }); + }); + }); }); From ea01ff09757ab5b43bb7d510b091aad0efb4c65b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 18:44:59 +0000 Subject: [PATCH 11/15] refactor(core): single LiftedWireMaterial declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol.ts redeclared the LiftedWireMaterial interface that wire/codec.ts already exports as part of the codec contract. Import the canonical type instead — the M4.1 driver extends this seam, and two structurally-equal declarations invite silent drift. --- packages/core/src/shared/protocol.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index f22070ccf..308eecd43 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -47,7 +47,7 @@ import { import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; -import type { NarrowResultKey, WireCodec } from '../wire/codec.js'; +import type { LiftedWireMaterial, NarrowResultKey, WireCodec } from '../wire/codec.js'; import { bindRequestCodec, codecForContext, @@ -164,15 +164,6 @@ const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ */ const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; -interface LiftedWireMaterial { - // Partial: the lift surfaces whichever reserved keys the request actually - // carried — a peer on an adjacent revision may legally send a subset, and - // envelope requiredness is enforced per request at dispatch time, not here. - envelope?: Partial; - inputResponses?: Record; - requestState?: string; -} - /** * Lift wire-only material out of an inbound message so handlers see exactly * the 2025-era shape, and surface it for the protocol layer (requests: via From fdf616c70f1375671cfec95be0cc86f94dd3d1c3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 09:41:28 +0000 Subject: [PATCH 12/15] refactor(core): derive the wire codec from connection state instead of side-table bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The negotiated protocol version is now a single private field on Protocol (consolidating the per-role copies Client and Server kept), reached by the role classes, tests, and the future server entry through a package-internal accessor pair on the core internal barrel. Era resolution is a pure function of that state: outbound traffic uses the bootstrap method pins only while the version is unset, and everything on an established connection — requests, responses, notifications, ctx.mcpReq.send/notify, and the instance-level senders — resolves through the instance era. The WeakMap binding channels (bindWireVersion/unbindWireVersion/outboundCodecFor/hasBoundWireVersion/ inboundCodecFor/bindRequestCodec/codecForContext) are deleted. MessageExtraInfo.classification narrows to the edge-to-instance handoff: the funnel validates a classified inbound message against the instance era and treats a mismatch as an entry/routing error (typed -32004 rejection for requests, a drop for notifications, plus onerror) instead of switching codecs per message. Unclassified traffic on legacy instances is byte-identical to before. The client clears the stored version at the start of a fresh connect and sets it at handshake completion (after the initialized notification), so a fresh handshake after close always runs on the legacy era; the resume path keeps the original negotiation. The server stores it at _oninitialize as before. Era-gate tests are reshaped to the instance-era model (the modern arm uses the internal hook the entry will use) and gain coverage for the classification-mismatch handoff. --- .changeset/codec-era-gates.md | 2 +- docs/migration-SKILL.md | 2 +- docs/migration.md | 4 +- packages/client/src/client/client.ts | 61 +++--- packages/core/src/index.ts | 12 +- packages/core/src/shared/protocol.ts | 216 +++++++++++++++----- packages/core/src/types/types.ts | 15 +- packages/core/src/wire/codec.ts | 92 ++------- packages/server/src/server/server.ts | 23 +-- test/integration/test/client/client.test.ts | 15 +- 10 files changed, 257 insertions(+), 185 deletions(-) diff --git a/.changeset/codec-era-gates.md b/.changeset/codec-era-gates.md index 89f7a2604..30855b7f8 100644 --- a/.changeset/codec-era-gates.md +++ b/.changeset/codec-era-gates.md @@ -4,4 +4,4 @@ '@modelcontextprotocol/server': minor --- -Add `SdkErrorCode.MethodNotSupportedByProtocolVersion`: a typed local error raised before anything reaches the transport when a spec method is sent toward a peer whose negotiated protocol version's wire era does not define it (for example `tasks/get` toward a 2026-07-28 peer). The protocol layer now resolves a per-era wire codec for every exchange — from the client's negotiated version, the server's per-request classification, or the legacy default — and resolves per-method schemas at dispatch time instead of registration time. Behavior on existing (2025-era) connections is unchanged. +Add `SdkErrorCode.MethodNotSupportedByProtocolVersion`: a typed local error raised before anything reaches the transport when a spec method is sent toward a peer whose negotiated protocol version's wire era does not define it (for example `tasks/get` toward a 2026-07-28 peer). The protocol layer now resolves a per-era wire codec from the connection's negotiated protocol version (instance state on `Client`/`Server`, with the legacy era as the pre-negotiation default) and resolves per-method schemas at dispatch time instead of registration time; an edge classification on an inbound message is validated against that instance era, and a mismatch is rejected as an entry/routing error. Behavior on existing (2025-era) connections is unchanged. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 6ff0e63c6..c906b0bc7 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -515,7 +515,7 @@ Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTy | `Result['resultType']` type reference | remove; the member is no longer declared | | return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | -Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; `MessageExtraInfo.classification` is consumed by dispatch to select the serving wire era. Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). +Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; the serving wire era is the instance's negotiated protocol version (connection state), and `MessageExtraInfo.classification` is only validated against it at dispatch (a mismatch is rejected as an entry/routing error). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). ## 12c. Per-era wire codecs (physical deletions + stricter wire schemas) diff --git a/docs/migration.md b/docs/migration.md index 5793a9ab1..764203ec2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -919,7 +919,7 @@ The protocol layer enforces the same boundary at runtime: - **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` envelope keys are lifted out of `params._meta` before handlers run, so handler params are byte-equal to the 2025-era shape under 2026-era traffic. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial` — only the keys the request actually carried are present); for notifications there is no per-message context, so lifted envelope keys are dropped, not surfaced. On requests only, the multi-round-trip retry fields are likewise lifted out of top-level params and surfaced verbatim at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. - **What this means for 2025-era peers.** The `_meta` side of the lift is invisible to conforming 2025-era traffic: the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too, so a conforming 2025 peer never puts application data under those keys. The retry-field lift is the one collision to know about: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that happens to use them as ordinary top-level params will have them lifted out of the handler's view (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`, just no longer in `request.params`). Spec-method requests are unaffected (no 2025 spec method defines params with those names), as are all notifications. - **Raw-first result discrimination.** The client funnel inspects a response's raw `resultType` before schema validation: `'complete'` is consumed (stripped) and the result parses as the public shape; any other kind (e.g. `input_required`) rejects with a typed local error — `SdkError` with the new code `SdkErrorCode.UnsupportedResultType` and the kind in `error.data.resultType` — instead of being masked into a hollow success by tolerant result schemas. Full multi-round-trip support will replace that error arm. -- **`MessageExtraInfo.classification`** is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge. Dispatch consumes it to select the wire era serving each request (see the next section). +- **`MessageExtraInfo.classification`** is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge. The wire era itself is connection state (the negotiated protocol version held by the `Client`/`Server` instance); dispatch validates a classified message against that era and treats a mismatch as an entry/routing error (see the next section). **Before (v2 alpha):** @@ -941,7 +941,7 @@ console.log(result.content); ### Per-era wire codecs: physical deletions and stricter wire schemas -The wire layer is now split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The negotiated protocol version selects the codec for a client connection; servers resolve it per request from edge classification, falling back to the session's negotiated version, then to the 2025 era. Methods deleted by a protocol revision are now PHYSICALLY absent from that era's registry: an inbound `tasks/get` on a 2026-classified exchange gets `-32601` even if a handler is registered, and sending an era-mismatched spec method (for example `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws a typed local error — `SdkError` with the new code `SdkErrorCode.MethodNotSupportedByProtocolVersion` — before anything reaches the transport. +The wire layer is now split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is connection state on the `Client`/`Server` instance: the client stores it when its initialize handshake completes, the server stores it when it answers `initialize`, and instances with no negotiated version default to the 2025 era (with the pre-negotiation lifecycle messages routed by method: `initialize`/`notifications/initialized` are 2025-era vocabulary, `server/discover` is 2026-era vocabulary). An edge classification (`MessageExtraInfo.classification`) no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as an entry/routing error (`-32004 Unsupported protocol version` for requests, a drop plus `onerror` for notifications). Methods deleted by a protocol revision are now 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 (for example `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws a typed local error — `SdkError` with the new code `SdkErrorCode.MethodNotSupportedByProtocolVersion` — before anything reaches the transport. Alongside the split, the following deliberate wire-behavior changes ship (each is invisible to conforming peers but observable to direct schema consumers and misbehaving peers): diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index aa7985578..07de78267 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -46,18 +46,18 @@ import type { UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - bindWireVersion, - codecForContext, + codecForVersion, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, mergeCapabilities, + negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, - unbindWireVersion + setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; /** @@ -210,7 +210,6 @@ export type ClientOptions = ProtocolOptions & { export class Client extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -294,9 +293,9 @@ export class Client extends Protocol { if (method === 'elicitation/create') { return async (request, ctx) => { // Era-exact validation: the schemas are resolved from the - // request's codec at dispatch time (the era gate guarantees - // the method exists on the serving era before we get here). - const codec = codecForContext(ctx); + // instance era at dispatch time (the era gate guarantees the + // method exists on the serving era before we get here). + const codec = codecForVersion(negotiatedProtocolVersionOf(this)); const elicitRequestSchema = codec.requestSchema('elicitation/create'); // The era registry entry IS the plain ElicitResult schema // (the result map is aligned to the typed map — no widened @@ -358,8 +357,8 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - // Era-exact validation via the request's codec (see above). - const codec = codecForContext(ctx); + // Era-exact validation via the instance era (see above). + const codec = codecForVersion(negotiatedProtocolVersionOf(this)); const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); if (!samplingRequestSchema) { throw new ProtocolError( @@ -443,22 +442,24 @@ export class Client extends Protocol { // Restore the protocol version negotiated during the original initialize handshake // so HTTP transports include the required mcp-protocol-version header, but skip re-init. if (transport.sessionId !== undefined) { - if (this._negotiatedProtocolVersion !== undefined) { - // Reconnection restores the original negotiation: re-bind the - // wire codec alongside the transport header. - bindWireVersion(this, this._negotiatedProtocolVersion); - transport.setProtocolVersion?.(this._negotiatedProtocolVersion); + const negotiatedProtocolVersion = negotiatedProtocolVersionOf(this); + if (negotiatedProtocolVersion !== undefined) { + // Resuming keeps the original negotiation: the instance still + // holds the negotiated version (and with it the wire era) — + // only the new transport needs the header pushed again. + transport.setProtocolVersion?.(negotiatedProtocolVersion); } return; } - // Fresh connect: a wire-era binding left over from a previous - // connection is stale connection state — clear it so the initialize - // handshake below rides the pre-negotiation bootstrap pin (legacy - // era) instead of a dead session's era. Without this, an instance - // that once negotiated a modern era could never re-run a fresh - // handshake: `initialize` is physically absent from the modern - // registry. (The resume branch above re-binds instead of clearing.) - unbindWireVersion(this); + // Fresh connect: the negotiated protocol version is connection state — + // a value left over from a previous connection must not survive into a + // new handshake. Clearing it puts the instance back in the + // pre-negotiation phase, so the initialize exchange below rides the + // bootstrap method pins (legacy era) instead of a dead session's era. + // Without this, an instance that once negotiated a modern era could + // never re-run a fresh handshake: `initialize` is physically absent + // from the modern registry. (The resume branch above keeps it instead.) + setNegotiatedProtocolVersion(this, undefined); try { const result = await this.request( { @@ -482,11 +483,6 @@ export class Client extends Protocol { this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; - this._negotiatedProtocolVersion = result.protocolVersion; - // The negotiated version selects the wire codec for everything - // this connection sends/receives from here on (the negotiated - // version cashes out as the negotiated wire ERA - Q1-SD1). - bindWireVersion(this, result.protocolVersion); // HTTP transports must set the protocol version in each header after initialization. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.protocolVersion); @@ -498,6 +494,15 @@ export class Client extends Protocol { method: 'notifications/initialized' }); + // Handshake completion: the negotiated version becomes the + // instance's connection state, and with it the wire era for + // everything this connection sends/receives from here on (the + // negotiated version cashes out as the negotiated wire ERA — + // Q1-SD1). Set AFTER the initialized notification: the initialize + // EXCHANGE is the legacy handshake by definition and completes on + // that era. + setNegotiatedProtocolVersion(this, result.protocolVersion); + // Set up list changed handlers now that we know server capabilities if (this._pendingListChangedConfig) { this._setupListChangedHandlers(this._pendingListChangedConfig); @@ -530,7 +535,7 @@ export class Client extends Protocol { * value to the new transport so it continues sending the required `mcp-protocol-version` header. */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; + return negotiatedProtocolVersionOf(this); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 887b76ea8..fc022586f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,14 +10,16 @@ export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; export * from './types/index.js'; export * from './util/inMemory.js'; -// Wire-codec internals: ONLY the connection-state binding and per-request -// resolution hooks the sibling packages need. Nothing per-revision (schemas, -// registries, codec objects) is ever exported — not even on this internal -// barrel — so per-era vocabulary cannot leak toward the public surface. +// Wire-codec internals: ONLY the version→codec resolver the sibling packages +// need (era state itself lives on Protocol and is reached through the +// package-internal accessors exported by shared/protocol.ts). Nothing +// per-revision (schemas, registries, codec objects) is ever exported — not +// even on this internal barrel — so per-era vocabulary cannot leak toward the +// public surface. export * from './util/schema.js'; export * from './util/standardSchema.js'; export * from './util/zodCompat.js'; -export { bindWireVersion, codecForContext, unbindWireVersion } from './wire/codec.js'; +export { codecForVersion } from './wire/codec.js'; // Validator providers are type-only here — import the runtime classes from the explicit // `@modelcontextprotocol/{core,client,server}/validators/{ajv,cf-worker}` subpaths to customise. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 308eecd43..5d2c670f2 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -48,14 +48,7 @@ import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; import type { LiftedWireMaterial, NarrowResultKey, WireCodec } from '../wire/codec.js'; -import { - bindRequestCodec, - codecForContext, - inboundCodecFor, - isSpecNotificationMethod, - isSpecRequestMethod, - outboundCodecFor -} from '../wire/codec.js'; +import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod } from '../wire/codec.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -377,6 +370,45 @@ type TimeoutInfo = { onTimeout: () => void; }; +/* + * Package-internal access to Protocol's negotiated-protocol-version state. + * + * The negotiated version is a TS-private field on Protocol (it is connection + * state, not public surface — it never appears in the published declaration + * reports). The role classes (Client/Server), tests, and the modern-era + * server entry still need to read and write it at their lifecycle points, so + * Protocol's static initializer hands these module-scoped closures privileged + * access and the two functions below re-export them on the core INTERNAL + * barrel only. This is the F-2-style package-internal hook — deliberately not + * public API. + */ +let readNegotiatedProtocolVersion: (instance: Protocol) => string | undefined; +let writeNegotiatedProtocolVersion: (instance: Protocol, version: string | undefined) => void; + +/** + * Package-internal read channel for the protocol version a {@linkcode Protocol} + * instance has negotiated (`undefined` before negotiation). Exported on the + * core internal barrel only — never public API. + */ +export function negotiatedProtocolVersionOf(instance: Protocol): string | undefined { + return readNegotiatedProtocolVersion(instance); +} + +/** + * Package-internal write channel for a {@linkcode Protocol} instance's + * negotiated protocol version — the single era set/clear point outside the + * class itself. Called by `Client.connect` (fresh-connect clear + handshake + * completion), `Server._oninitialize`, tests, and the (future) modern-era + * server entry when it marks a factory instance modern at binding time. + * Exported on the core internal barrel only — never public API. + */ +export function setNegotiatedProtocolVersion( + instance: Protocol, + version: string | undefined +): void { + writeNegotiatedProtocolVersion(instance, version); +} + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. @@ -395,6 +427,31 @@ export abstract class Protocol { private _timeoutInfo: Map = new Map(); private _pendingDebouncedNotifications = new Set(); + /** + * The protocol version negotiated for the current connection — the single + * source of truth for the wire era this instance speaks (Q1-SD1: the + * negotiated version cashes out as the negotiated wire ERA). + * + * Ordinary connection state, no side tables: + * - `Client.connect` clears it at the start of a fresh connect (the + * handshake itself runs pre-negotiation) and sets it once the handshake + * completes; the resume path keeps the original negotiation. + * - `Server._oninitialize` sets it when answering the legacy handshake; + * modern-era server instances get it set at instance binding through + * the package-internal hook ({@linkcode setNegotiatedProtocolVersion}). + * + * `undefined` = not negotiated yet: outbound lifecycle messages ride the + * bootstrap method pins and everything else defaults to the legacy era. + */ + private _negotiatedProtocolVersion?: string; + + static { + readNegotiatedProtocolVersion = instance => instance._negotiatedProtocolVersion; + writeNegotiatedProtocolVersion = (instance, version) => { + instance._negotiatedProtocolVersion = version; + }; + } + protected _supportedProtocolVersions: string[]; /** @@ -582,9 +639,27 @@ export abstract class Protocol { // not surfaced; the protocol layer owns them. const { message: notification } = liftWireOnlyMaterial(rawNotification, 'notification'); - // Per-message codec resolution: classification wins, session state is - // the fallback, unclassified traffic is legacy-era. - const codec = inboundCodecFor(this, extra?.classification); + // Era is instance state: the negotiated protocol version selects the + // codec for everything this connection receives (legacy until + // negotiated). Classification is no longer a per-message era switch — + // it is validated against the instance era below. + const codec = this._negotiatedWireCodec(); + + // Edge→instance handoff check: a classification that disagrees with + // the instance era means the entry routed another era's traffic onto + // this instance. That is a routing error — drop the notification and + // surface it out of band; never serve it on a guessed era. + if (extra?.classification !== undefined) { + const classified = classifiedWireEra(extra.classification); + if (classified !== codec.era) { + this._onerror( + new Error( + `Era mismatch on inbound notification '${notification.method}': classified as ${classified} but this instance serves ${codec.era}` + ) + ); + return; + } + } // Era gate — deletions are physical: a spec notification that is not // in this era's registry is dropped even when a handler is @@ -616,24 +691,47 @@ export abstract class Protocol { // 2025-era shape; the envelope and retry fields surface via ctx. const { message: request, lifted } = liftWireOnlyMaterial(rawRequest, 'request'); - // Per-request codec resolution: classification wins (Q2; this layer - // only CONSUMES MessageExtraInfo.classification), the session - // negotiated version is the fallback for hand-wired sessionful - // transports, and unclassified-unbound traffic is legacy-era. - const codec = inboundCodecFor(this, extra?.classification); + // Era is instance state: the negotiated protocol version selects the + // codec for everything this connection receives (legacy until + // negotiated). Classification (Q2; this layer only CONSUMES + // MessageExtraInfo.classification) is no longer a per-message era + // switch — it is validated against the instance era below. Hand-wired + // legacy transports never classify, so their behavior is untouched. + const codec = this._negotiatedWireCodec(); // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; - const sendErrorResponse = (code: number, message: string) => { + const sendErrorResponse = (code: number, message: string, data?: unknown) => { const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, - error: { code, message } + error: { code, message, ...(data !== undefined && { data }) } }; capturedTransport?.send(errorResponse).catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); }; + // Edge→instance handoff check: a classification that disagrees with + // the instance era means the entry routed another era's traffic onto + // this instance. That is a routing error: answer with the typed era + // error (−32004 Unsupported protocol version) and surface it out of + // band — never serve the request on a guessed era. + if (extra?.classification !== undefined) { + const classified = classifiedWireEra(extra.classification); + if (classified !== codec.era) { + this._onerror( + new Error( + `Era mismatch on inbound request '${request.method}': classified as ${classified} but this instance serves ${codec.era}` + ) + ); + sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${classified}`, { + supported: [codec.era], + requested: classified + }); + return; + } + } + // 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 @@ -667,10 +765,19 @@ export abstract class Protocol { return; } + // Related sends resolve through the SAME instance era as every other + // sender (the per-request/instance asymmetry is deliberately gone): + // the codec is resolved at send time from the connection state. const sendNotification = (notification: Notification, options?: NotificationOptions) => - this._notificationViaCodec(codec, notification, { ...options, relatedRequestId: request.id }); + this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, { + ...options, + relatedRequestId: request.id + }); const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchemaViaCodec(codec, r, resultSchema, { ...options, relatedRequestId: request.id }); + this._requestWithSchemaViaCodec(this._resolveOutboundCodec(r.method), r, resultSchema, { + ...options, + relatedRequestId: request.id + }); const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); @@ -690,14 +797,15 @@ export abstract class Protocol { // that overloaded property type. The cast is sound: this impl dispatches both overload paths via the // isStandardSchema guard, and sendRequest validates the result against the resolved schema either way. send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions) => { - // Related requests ride the SAME era as the request being - // handled: the per-request codec era-gates and resolves - // schemas at dispatch time. - this._assertOutboundRequestInEra(codec, r.method); + // Related requests resolve through the instance era at + // send time, exactly like direct sends: era-gate first, + // then method-keyed schema resolution. + const sendCodec = this._resolveOutboundCodec(r.method); + this._assertOutboundRequestInEra(sendCodec, r.method); if (isStandardSchema(schemaOrOptions)) { return sendRequest(r, schemaOrOptions, maybeOptions); } - const resultSchema = codec.resultSchema(r.method); + const resultSchema = sendCodec.resultSchema(r.method); if (!resultSchema) { throw new TypeError( `'${r.method}' is not a spec method; pass a result schema as the second argument to ctx.mcpReq.send().` @@ -710,10 +818,6 @@ export abstract class Protocol { http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined }; const ctx = this.buildContext(baseCtx, extra); - // Make the per-request codec resolvable from the context (stored - // handler closures and _wrapHandler wrappers resolve era-exact - // schemas through it at dispatch time). - bindRequestCodec(ctx, codec); // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() @@ -877,10 +981,7 @@ export abstract class Protocol { options?: RequestOptions ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { - // Outbound codec resolution: lifecycle messages are bootstrap-pinned - // (they precede negotiation and self-identify their era); everything - // else rides the instance's negotiated era (legacy when unbound). - const codec = bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this); + const codec = this._resolveOutboundCodec(request.method); this._assertOutboundRequestInEra(codec, request.method); if (isStandardSchema(schemaOrOptions)) { return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); @@ -892,6 +993,31 @@ export abstract class Protocol { return this._requestWithSchemaViaCodec(codec, request, resultSchema, schemaOrOptions); } + /** + * The wire codec for this instance's negotiated era — the phase-2 truth: + * everything an established connection sends and receives resolves + * through it. Legacy until a version has been negotiated. + */ + private _negotiatedWireCodec(): WireCodec { + return codecForVersion(this._negotiatedProtocolVersion); + } + + /** + * Outbound codec resolution: while the negotiated version is still unset + * (the negotiation window), lifecycle messages are bootstrap-pinned BY + * METHOD — they self-identify their era (`initialize` IS the legacy + * handshake, `server/discover` IS the modern probe). Once a version has + * been negotiated, the instance era is authoritative for everything — a + * negotiated session never re-routes a method onto the other era. + */ + private _resolveOutboundCodec(method: string): WireCodec { + if (this._negotiatedProtocolVersion === undefined) { + const pinned = bootstrapOutboundCodec(method); + if (pinned) return pinned; + } + return this._negotiatedWireCodec(); + } + /** * Era gate for outbound requests — deletions are physical in BOTH * directions: sending a spec method that the resolved era does not define @@ -919,7 +1045,7 @@ export abstract class Protocol { * dispatch time, like every other method-keyed binding. */ protected _requestWithNarrowSchema(request: Request, narrow: NarrowResultKey, options?: RequestOptions): Promise { - const codec = bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this); + const codec = this._resolveOutboundCodec(request.method); this._assertOutboundRequestInEra(codec, request.method); const schema = codec.narrowResultSchema(narrow); if (!schema) { @@ -943,12 +1069,7 @@ export abstract class Protocol { resultSchema: T, options?: RequestOptions ): Promise> { - return this._requestWithSchemaViaCodec( - bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this), - request, - resultSchema, - options - ); + return this._requestWithSchemaViaCodec(this._resolveOutboundCodec(request.method), request, resultSchema, options); } /** @@ -1150,13 +1271,13 @@ export abstract class Protocol { * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - return this._notificationViaCodec(bootstrapOutboundCodec(notification.method) ?? outboundCodecFor(this), notification, options); + return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); } /** - * The notification funnel proper, keyed by the resolved era codec - * (related notifications sent via `ctx.mcpReq.notify` ride the inbound - * request's codec; direct sends ride the instance's negotiated era). + * The notification funnel proper, keyed by the resolved era codec — + * direct sends and related notifications (`ctx.mcpReq.notify`) alike + * resolve through the instance's negotiated era at send time. */ private async _notificationViaCodec(codec: WireCodec, notification: Notification, options?: NotificationOptions): Promise { if (!this._transport) { @@ -1260,10 +1381,11 @@ export abstract class Protocol { ); } // Dispatch-time schema resolution: the request is parsed with the - // schema of the era SERVING THIS REQUEST (per-request codec bound - // to ctx), never with a schema captured at registration time. + // schema of the era serving this connection (the instance era at + // dispatch time), never with a schema captured at registration + // time. stored = (request, ctx) => { - const schema = codecForContext(ctx).requestSchema(method); + const schema = this._negotiatedWireCodec().requestSchema(method); if (!schema) { // Unreachable: the dispatch era gate rejects era-mismatched // spec methods with −32601 before any handler runs. diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 8450aed88..e0a9b0430 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -598,10 +598,12 @@ export type ListChangedHandlers = { * Protocol-era classification of an inbound message. * * Populated by transports that classify messages at the edge (e.g. an HTTP - * entry distinguishing 2025-era from 2026-era traffic). The protocol layer - * consults it during dispatch to resolve the wire codec serving the exchange - * (classification wins over the session-negotiated version; unclassified - * traffic falls back to the session version, then legacy). + * entry distinguishing 2025-era from 2026-era traffic). The wire era itself + * is connection state (the negotiated protocol version held by the + * `Client`/`Server` instance); the protocol layer validates a classified + * message against that instance era at dispatch — a mismatch is treated as + * an entry/routing error, never a per-message era switch. Unclassified + * traffic is dispatched on the instance era unchanged. */ export interface MessageClassification { /** @@ -635,8 +637,9 @@ export interface MessageExtraInfo { /** * Protocol-era classification of the message, when the transport - * classified it at the edge. Consulted by the protocol layer's - * per-exchange codec resolution. + * classified it at the edge. Validated by the protocol layer against the + * instance's negotiated era at dispatch (the edge→instance handoff + * check); it does not select the era itself. */ classification?: MessageClassification; diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 613eea24c..32cc34c30 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -5,10 +5,13 @@ * `resultType`, no `_meta` envelope keys, no retry fields) from per-revision * WIRE CODECS that own revision-exact schemas, method registries, and the * decode (wire → neutral lift) / encode (neutral → wire stamp) transforms. - * The codec is selected at the only places version truth exists: the client's - * stored negotiated version (bound at `Client.connect`) and the server's - * per-request classification (`MessageExtraInfo.classification`, with the - * session-negotiated version as fallback and legacy as the default). + * The codec is a pure function of the negotiated protocol version, which is + * ordinary connection state on the `Protocol` instance: the client stores it + * when its handshake completes, the server stores it at `_oninitialize` (and + * modern-era server instances get it set at instance binding by the entry). + * There is no side table — era resolution is `codecForVersion()`, with the pre-negotiation window covered by the outbound method + * pins in `bootstrap.ts`. * * REQUIRED DISCLOSURE (Q1-SD1, era granularity): "the negotiated version * determines which types are serialized/deserialized over the wire" cashes @@ -169,15 +172,16 @@ export function codecForVersion(version: string | undefined): WireCodec { } /** - * Resolve the codec for an inbound message from its per-request - * classification (Q2 — produced at the transport/entry edge; this layer only - * CONSUMES it), falling back to the session-negotiated version, then legacy. + * The wire era an edge classification names (Q2 — produced at the + * transport/entry edge; this layer only CONSUMES it). The dispatch funnel no + * longer resolves a codec FROM the classification: era is instance state, and + * a classified inbound message is VALIDATED against the instance era — a + * mismatch is an entry/routing error, never a per-message era switch. The + * exact `revision` wins over the coarse era flag when both are present. */ -export function codecForClassification(classification: MessageClassification | undefined, sessionVersion: string | undefined): WireCodec { - if (classification?.revision !== undefined) return codecForVersion(classification.revision); - if (classification?.era === 'modern') return codecForVersion(MODERN_WIRE_REVISION); - if (classification?.era === 'legacy') return codecForVersion(undefined); - return codecForVersion(sessionVersion); +export function classifiedWireEra(classification: MessageClassification): WireEra { + if (classification.revision !== undefined) return codecForVersion(classification.revision).era; + return classification.era === 'modern' ? MODERN_WIRE_REVISION : rev2025Codec.era; } /** @@ -196,67 +200,3 @@ export function isSpecNotificationMethod(method: string): boolean { } const ALL_CODECS: readonly WireCodec[] = [rev2025Codec]; - -/* ------------------------------------------------------------------------ * - * Internal binding channels. - * - * These deliberately avoid new members on the Protocol class hierarchy: the - * negotiated wire version is connection state owned by Client/Server, and the - * per-request codec is dispatch state owned by the protocol layer. WeakMaps - * keep both invisible to consumers and concurrency-safe (one entry per - * protocol instance / per request context). - * ------------------------------------------------------------------------ */ - -const outboundWireVersion = new WeakMap(); - -/** - * Bind the negotiated wire version for a protocol instance's OUTBOUND - * traffic. Called by `Client.connect` (initialize handshake + reconnect) and - * `Server._oninitialize`. Unbound instances are legacy-era. - */ -export function bindWireVersion(owner: object, version: string | undefined): void { - outboundWireVersion.set(owner, version); -} - -/** - * Clear a protocol instance's outbound wire-version binding. Called at the - * start of a FRESH connect (no session to resume): the binding is connection - * state, and a binding left over from a previous connection must not route - * the new connection's lifecycle messages — an unbound instance is - * legacy-era and its `initialize` rides the bootstrap pin. The resume path - * (transport with a sessionId) deliberately does NOT clear: it re-binds the - * originally negotiated version instead. - */ -export function unbindWireVersion(owner: object): void { - outboundWireVersion.delete(owner); -} - -/** The codec serving a protocol instance's outbound traffic (legacy when unbound). */ -export function outboundCodecFor(owner: object): WireCodec { - return codecForVersion(outboundWireVersion.get(owner)); -} - -/** - * Resolve the codec for an INBOUND message: per-request classification wins - * (Q2), the instance's bound session version is the fallback for hand-wired - * sessionful transports, and unclassified-unbound traffic is legacy-era. - */ -export function inboundCodecFor(owner: object, classification: MessageClassification | undefined): WireCodec { - return codecForClassification(classification, outboundWireVersion.get(owner)); -} - -const requestCodec = new WeakMap(); - -/** - * Bind the resolved per-request codec to a request context, so stored - * handler closures and `_wrapHandler` wrappers resolve era-exact schemas at - * dispatch time without widening any public signature. - */ -export function bindRequestCodec(ctx: object, codec: WireCodec): void { - requestCodec.set(ctx, codec); -} - -/** The codec bound to a request context (legacy when none was bound). */ -export function codecForContext(ctx: object): WireCodec { - return requestCodec.get(ctx) ?? codecForVersion(undefined); -} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 39a8994e4..b9b302daf 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -34,17 +34,18 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { - bindWireVersion, - codecForContext, + codecForVersion, LATEST_PROTOCOL_VERSION, LoggingLevelSchema, mergeCapabilities, + negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode + SdkErrorCode, + setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -80,7 +81,6 @@ export type ServerOptions = ProtocolOptions & { export class Server extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -186,9 +186,9 @@ export class Server extends Protocol { } return async (request, ctx) => { // Era-exact validation: the request and result schemas come from - // the request's codec, resolved at dispatch time (the era gate + // the instance era, resolved at dispatch time (the era gate // guarantees tools/call exists on the serving era). - const codec = codecForContext(ctx); + const codec = codecForVersion(negotiatedProtocolVersionOf(this)); const callToolRequestSchema = codec.requestSchema('tools/call'); // The era registry entry IS the plain CallToolResult schema (the // result map is aligned to the typed map — no widened unions), @@ -371,11 +371,10 @@ export class Server extends Protocol { ? requestedVersion : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); - this._negotiatedProtocolVersion = protocolVersion; - // Session-level wire-era binding: the fallback codec source for - // hand-wired sessionful transports (per-request classification wins - // when present). - bindWireVersion(this, protocolVersion); + // The negotiated version is the instance's connection state — it IS + // the wire-era selection for everything this instance sends and + // receives from here on (legacy handshake ⇒ a legacy-era version). + setNegotiatedProtocolVersion(this, protocolVersion); this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -406,7 +405,7 @@ export class Server extends Protocol { * `undefined` before initialization. */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; + return negotiatedProtocolVersionOf(this); } /** diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index d9fa7265d..89ea643ed 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -172,11 +172,12 @@ test('should restore negotiated protocol version on transport when reconnecting }); /*** - * Test: The wire-era binding is connection state — it must not survive into a fresh connect. - * A client whose previous connection negotiated the modern revision (2026-07-28) must still be - * able to run a FRESH initialize handshake: `initialize` is legacy-era vocabulary by definition - * (it is physically absent from the modern registry), so a binding left over from the dead - * connection would otherwise kill the handshake locally before it reaches the transport. + * Test: The negotiated protocol version (and with it the wire era) is connection state — it must + * not survive into a fresh connect. A client whose previous connection negotiated the modern + * revision (2026-07-28) must still be able to run a FRESH initialize handshake: `initialize` is + * legacy-era vocabulary by definition (it is physically absent from the modern registry), so a + * negotiated version left over from the dead connection would otherwise kill the handshake + * locally before it reaches the transport. */ test('should run a fresh initialize handshake after close() when the previous connection negotiated the modern era', async () => { const MODERN_REVISION = '2026-07-28'; @@ -191,13 +192,13 @@ test('should run a fresh initialize handshake after close() when the previous co const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions }); - // First connection negotiates the modern revision and binds the modern wire era. + // First connection negotiates the modern revision: the instance now speaks the modern wire era. await connectModern(client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); await client.close(); - // Fresh connect (new transport, no sessionId): the stale binding is cleared, the + // Fresh connect (new transport, no sessionId): the stale negotiated version is cleared, the // handshake rides the pre-negotiation bootstrap pin (legacy era), and the connection // can re-negotiate the modern revision. await connectModern(client); From 4df655a82cfb28871c77db223354c4328e6b33e2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 09:46:53 +0000 Subject: [PATCH 13/15] refactor(core): fold _requestWithNarrowSchema into _requestWithSchema The params-dependent sampling result schema was the only surviving narrow case, so it now goes through the existing explicit-schema path: Server.createMessage passes CreateMessageResultSchema / CreateMessageResultWithToolsSchema to _requestWithSchema, and the client-side sampling handler wrapper picks the same pair directly. _requestWithSchema gains the outbound era gate, so an explicit schema can never smuggle a deleted spec method onto the wire (createMessage on a modern-era instance still fails with the typed era error before the transport). The NarrowResultKey type, the WireCodec.narrowResultSchema member, and the per-era narrowResultSchemas tables are removed; the regenerated API reports drop the protected _requestWithNarrowSchema member and the NarrowResultKey type from the client and server declaration surfaces. --- packages/client/src/client/client.ts | 17 ++++---- packages/core/src/shared/protocol.ts | 40 ++++++------------- packages/core/src/wire/codec.ts | 14 ------- packages/core/src/wire/rev2025-11-25/codec.ts | 13 +----- .../core/src/wire/rev2025-11-25/registry.ts | 14 ------- packages/server/src/server/server.ts | 21 ++++++---- 6 files changed, 36 insertions(+), 83 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 07de78267..46514d097 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -47,6 +47,8 @@ import type { } from '@modelcontextprotocol/core'; import { codecForVersion, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, mergeCapabilities, @@ -377,16 +379,13 @@ export class Client extends Protocol { const result = await handler(request, ctx); + // The result schema depends on the REQUEST params (tools vs + // no tools) — something a method-keyed registry entry cannot + // express, so the pair is picked here. The era gate keeps + // this era-correct: sampling/createMessage is only ever + // dispatched on an era whose registry defines it. const hasTools = params.tools || params.toolChoice; - const resultSchema = codec.narrowResultSchema( - hasTools ? 'sampling/createMessage:withTools' : 'sampling/createMessage:plain' - ); - if (!resultSchema) { - throw new ProtocolError( - ProtocolErrorCode.InternalError, - 'No wire schema for sampling/createMessage in the resolved era' - ); - } + const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; const validationResult = parseSchema(resultSchema, result); if (!validationResult.success) { const errorMessage = diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 5d2c670f2..0effa2767 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -47,7 +47,7 @@ import { import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; -import type { LiftedWireMaterial, NarrowResultKey, WireCodec } from '../wire/codec.js'; +import type { LiftedWireMaterial, WireCodec } from '../wire/codec.js'; import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod } from '../wire/codec.js'; import type { Transport, TransportSendOptions } from './transport.js'; @@ -1036,40 +1036,24 @@ export abstract class Protocol { } /** - * Sends a spec-method request whose result validation deliberately uses a - * NARROWER era schema than the generic per-method registry entry. With - * the result map aligned to the typed map, the only such surface is - * `server.createMessage`, whose result schema depends on the REQUEST - * params (tools vs no tools) — something a method-keyed registry entry - * cannot express. The schema is resolved from the outbound era codec at - * dispatch time, like every other method-keyed binding. - */ - protected _requestWithNarrowSchema(request: Request, narrow: NarrowResultKey, options?: RequestOptions): Promise { - const codec = this._resolveOutboundCodec(request.method); - this._assertOutboundRequestInEra(codec, request.method); - const schema = codec.narrowResultSchema(narrow); - if (!schema) { - throw new SdkError( - SdkErrorCode.MethodNotSupportedByProtocolVersion, - `Method '${request.method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, - { method: request.method, era: codec.era } - ); - } - return this._requestWithSchemaViaCodec(codec, request, schema as unknown as StandardSchemaV1, options) as Promise; - } - - /** - * Sends a request and waits for a response, using the provided schema for validation. + * Sends a request and waits for a response, using the provided schema for + * validation instead of the era registry's method-keyed entry. * - * This is the internal implementation used by SDK methods that need to specify - * a particular result schema (e.g., for compatibility schemas). + * This is the internal implementation used by SDK methods whose result + * schema cannot be expressed as a method-keyed registry entry — the one + * surviving case is `server.createMessage`, whose result schema depends + * on the REQUEST params (tools vs no tools) — and by callers passing + * explicit compatibility schemas. Spec methods are still era-gated here: + * an explicit schema never smuggles a deleted method onto the wire. */ protected _requestWithSchema( request: Request, resultSchema: T, options?: RequestOptions ): Promise> { - return this._requestWithSchemaViaCodec(this._resolveOutboundCodec(request.method), request, resultSchema, options); + const codec = this._resolveOutboundCodec(request.method); + this._assertOutboundRequestInEra(codec, request.method); + return this._requestWithSchemaViaCodec(codec, request, resultSchema, options); } /** diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 32cc34c30..58b017b5c 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -94,17 +94,6 @@ export type DecodedResult = } | { kind: 'invalid'; error: SdkError }; -/** - * Keys for the high-level method surfaces whose result validation is - * deliberately narrower than the generic per-method registry entry. With the - * registry result map aligned to the typed `ResultTypeMap` (no task-widened - * unions), only `server.createMessage` qualifies: it picks its result schema - * from the REQUEST params (tools vs no tools), which a method-keyed registry - * entry cannot express. Every other high-level surface (`callTool`, - * `elicitInput`) validates exactly its registry entry. - */ -export type NarrowResultKey = 'sampling/createMessage:plain' | 'sampling/createMessage:withTools'; - /** * The per-era wire codec contract (design C §3, adapted to the live funnel * layout: the universal wire-only LIFT runs once in the protocol layer for @@ -123,9 +112,6 @@ export interface WireCodec { resultSchema(method: string): z.ZodType | undefined; notificationSchema(method: string): z.ZodType | undefined; - /** Narrow high-level result schemas (see {@linkcode NarrowResultKey}). */ - narrowResultSchema(key: NarrowResultKey): z.ZodType | undefined; - /** * Step 1 of result decoding: RAW `resultType` handling BEFORE any schema * validation (V-1's structural home). Era postures (Q1-SD3): diff --git a/packages/core/src/wire/rev2025-11-25/codec.ts b/packages/core/src/wire/rev2025-11-25/codec.ts index 37ec34540..c4a9ca515 100644 --- a/packages/core/src/wire/rev2025-11-25/codec.ts +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -22,15 +22,8 @@ * only, and it deletes — never reads, maps, or emits — the foreign value. */ import type { Result } from '../../types/types.js'; -import type { DecodedResult, LiftedWireMaterial, NarrowResultKey, WireCodec } from '../codec.js'; -import { - getNotificationSchema, - getRequestSchema, - getResultSchema, - hasNotificationMethod2025, - hasRequestMethod2025, - narrowResultSchemas2025 -} from './registry.js'; +import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; +import { getNotificationSchema, getRequestSchema, getResultSchema, hasNotificationMethod2025, hasRequestMethod2025 } from './registry.js'; function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); @@ -46,8 +39,6 @@ export const rev2025Codec: WireCodec = { resultSchema: method => getResultSchema(method), notificationSchema: method => getNotificationSchema(method), - narrowResultSchema: (key: NarrowResultKey) => narrowResultSchemas2025[key], - decodeResult(_method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is // dropped before validation, whatever its value. There is no diff --git a/packages/core/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts index 8e8c708fa..ee749a2d4 100644 --- a/packages/core/src/wire/rev2025-11-25/registry.ts +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -25,7 +25,6 @@ import type * as z from 'zod/v4'; import { CallToolResultSchema, CompleteResultSchema, - CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, EmptyResultSchema, @@ -141,16 +140,3 @@ export function hasNotificationMethod2025(method: string): boolean { /** Registry method lists (for the spec-method universe and the CI registry-diff oracle). */ export const rev2025RequestMethods: readonly string[] = Object.keys(requestSchemas); export const rev2025NotificationMethods: readonly string[] = Object.keys(notificationSchemas); - -/** - * Narrow high-level result schemas for this era (see `codec.ts` - * `NarrowResultKey`). Only the sampling pair lives here: with the result map - * aligned to the typed map, `tools/call` and `elicitation/create` have no - * narrower surface than their registry entries — `server.createMessage` is - * the one method whose result schema depends on the REQUEST params (tools vs - * no tools), which a method-keyed registry cannot express. - */ -export const narrowResultSchemas2025: Record = { - 'sampling/createMessage:plain': CreateMessageResultSchema as unknown as z.ZodType, - 'sampling/createMessage:withTools': CreateMessageResultWithToolsSchema as unknown as z.ZodType -}; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index b9b302daf..8aef3e3d1 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -35,6 +35,8 @@ import type { } from '@modelcontextprotocol/core'; import { codecForVersion, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, LATEST_PROTOCOL_VERSION, LoggingLevelSchema, mergeCapabilities, @@ -494,19 +496,24 @@ export class Server extends Protocol { } } - // Use different schemas based on whether tools are provided + // Use different schemas based on whether tools are provided. The + // result schema depends on the REQUEST params, which a method-keyed + // registry entry cannot express, so it goes through the explicit- + // schema path (still era-gated: sampling/createMessage is not a wire + // request on the 2026 era, so a modern-era instance fails with the + // typed era error before anything reaches the transport). if (params.tools) { - return this._requestWithNarrowSchema( + return (await this._requestWithSchema( { method: 'sampling/createMessage', params }, - 'sampling/createMessage:withTools', + CreateMessageResultWithToolsSchema, options - ); + )) as CreateMessageResultWithTools; } - return this._requestWithNarrowSchema( + return (await this._requestWithSchema( { method: 'sampling/createMessage', params }, - 'sampling/createMessage:plain', + CreateMessageResultSchema, options - ); + )) as CreateMessageResult; } /** From c85ceb840ebb4b1ce311affd19c8797cb3b1b363 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 12:45:05 +0000 Subject: [PATCH 14/15] fix(core): list every supported protocol version in the -32004 error data The unsupported-protocol-version rejection sent for an inbound request whose transport-edge protocol-version classification does not match the connection reported only the single version the connection serves in data.supported. The spec defines supported as the full list of protocol versions the receiver supports, so the peer can choose a mutually supported version from the error alone; report the instance's configured supportedProtocolVersions instead. Adds a dispatch-level test pinning the error payload. --- packages/core/src/shared/protocol.ts | 6 ++- packages/core/test/shared/protocol.test.ts | 44 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 0effa2767..d37198860 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -725,7 +725,11 @@ export abstract class Protocol { ) ); sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${classified}`, { - supported: [codec.era], + // Per spec, `supported` is the full list of protocol + // versions the receiver supports — not just the version + // this connection is on — so the peer can pick a mutually + // supported version from the error alone. + supported: this._supportedProtocolVersions, requested: classified }); return; diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 29463b4f0..f488284bd 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -1009,3 +1009,47 @@ describe('inbound validation precedence: −32601 outranks envelope −32602', ( }); }); }); + +describe('inbound protocol-version mismatch (−32004): the error data lists every supported version', () => { + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + test('a request classified for a protocol version this connection does not serve is rejected with the full supported list', async () => { + const supportedProtocolVersions = ['2025-11-25', '2025-06-18', '2025-03-26']; + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = new TestProtocolImpl({ supportedProtocolVersions }); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + await protocol.connect(protocolTx); + + // Deliver a request whose transport-edge classification names a + // protocol version this connection does not serve. The rejection's + // `data.supported` must list every protocol version the receiver + // supports — not just the version the connection is on — so the peer + // can pick a mutually supported version from the error alone. + protocolTx.onmessage?.( + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, + // The in-memory transport's onmessage declares the narrower + // pre-classification extra type; the protocol layer reads the + // full MessageExtraInfo (same cast as the era-gate suite). + { classification: { era: 'modern' } } as never + ); + await flush(); + + expect(sent).toHaveLength(1); + const error = (sent[0] as JSONRPCErrorResponse).error as { + code: number; + message: string; + data?: { supported?: string[]; requested?: string }; + }; + expect(error.code).toBe(-32004); + expect(error.message).toContain('Unsupported protocol version'); + expect(error.data?.supported).toEqual(supportedProtocolVersions); + expect(error.data?.requested).toBe('2026-07-28'); + + await protocol.close(); + }); +}); From e74d335e57b324061c814625f2134039d0250d28 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 14:17:27 +0000 Subject: [PATCH 15/15] refactor(core): type the wire registry lookups by method literal The 2025-era registry maps are now plain literal objects typed as mapped types over their method unions: the result map stays keyed by RequestMethod and each entry must parse to ResultTypeMap[M], and the request/notification maps are keyed by method unions derived from the era's wire role unions, so a missing entry, an extra entry, or an entry pointing at the wrong method's schema is a compile error. The map contents (objects, key order) are unchanged and stay pinned by reference in registryPins. The lookups narrow with own-key membership guards (the has*2025 functions are now type predicates) instead of casting, and the WireCodec schema-lookup members gain the same method-literal overloads, so call sites with a statically known method get the typed parse result directly. The now-redundant call-site assertions in Client._wrapHandler, Server._wrapHandler, and Server.createMessage are removed. The two wire-boundary assertions in the 2025 codec's decodeResult collapse into a single documented toNeutralResult helper (the wire-to-neutral trust boundary). No runtime behavior changes. --- packages/client/src/client/client.ts | 11 +- packages/core/src/wire/codec.ts | 22 ++- packages/core/src/wire/rev2025-11-25/codec.ts | 15 +- .../core/src/wire/rev2025-11-25/registry.ts | 173 ++++++++++++------ packages/server/src/server/server.ts | 14 +- 5 files changed, 159 insertions(+), 76 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 46514d097..7bacbf8d2 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -9,9 +9,6 @@ import type { ClientRequest, CompleteRequest, CompleteResult, - CreateMessageRequest, - ElicitRequest, - ElicitResult, EmptyResult, GetPromptRequest, GetPromptResult, @@ -314,7 +311,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`); } - const { params } = validatedRequest.data as ElicitRequest; + const { params } = validatedRequest.data; params.mode = params.mode ?? 'form'; const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); @@ -336,7 +333,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`); } - const validatedResult = validationResult.data as ElicitResult; + const validatedResult = validationResult.data; const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; if ( @@ -375,7 +372,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); } - const { params } = validatedRequest.data as CreateMessageRequest; + const { params } = validatedRequest.data; const result = await handler(request, ctx); @@ -393,7 +390,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); } - return validationResult.data as Result; + return validationResult.data; }; } diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 58b017b5c..7e61b9536 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -44,7 +44,16 @@ import type * as z from 'zod/v4'; import type { SdkError } from '../errors/sdkErrors.js'; -import type { MessageClassification, RequestMetaEnvelope, Result } from '../types/types.js'; +import type { + MessageClassification, + NotificationMethod, + NotificationTypeMap, + RequestMetaEnvelope, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap +} from '../types/types.js'; import { rev2025Codec } from './rev2025-11-25/codec.js'; /** Wire eras with distinct vocabulary. */ @@ -107,9 +116,18 @@ export interface WireCodec { hasRequestMethod(method: string): boolean; hasNotificationMethod(method: string): boolean; - /** Era-exact dispatch schemas, resolved at dispatch time (never at registration time). */ + /** + * Era-exact dispatch schemas, resolved at dispatch time (never at + * registration time). The method-literal overloads carry the typed parse + * result for statically known spec methods, so call sites need no type + * assertion; `undefined` means the method has no entry on this era's + * registry. + */ + requestSchema(method: M): z.ZodType | undefined; requestSchema(method: string): z.ZodType | undefined; + resultSchema(method: M): z.ZodType | undefined; resultSchema(method: string): z.ZodType | undefined; + notificationSchema(method: M): z.ZodType | undefined; notificationSchema(method: string): z.ZodType | undefined; /** diff --git a/packages/core/src/wire/rev2025-11-25/codec.ts b/packages/core/src/wire/rev2025-11-25/codec.ts index c4a9ca515..458379d9c 100644 --- a/packages/core/src/wire/rev2025-11-25/codec.ts +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -29,15 +29,20 @@ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** The wire→neutral trust boundary: a decoded 2025-era wire result is adopted as the neutral `Result` here (the module's single deliberate assertion). */ +function toNeutralResult(value: unknown): Result { + return value as Result; +} + export const rev2025Codec: WireCodec = { era: '2025-11-25', hasRequestMethod: hasRequestMethod2025, hasNotificationMethod: hasNotificationMethod2025, - requestSchema: method => getRequestSchema(method), - resultSchema: method => getResultSchema(method), - notificationSchema: method => getNotificationSchema(method), + requestSchema: getRequestSchema, + resultSchema: getResultSchema, + notificationSchema: getNotificationSchema, decodeResult(_method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is @@ -46,9 +51,9 @@ export const rev2025Codec: WireCodec = { if (isPlainObject(raw) && 'resultType' in raw) { const stripped = { ...raw }; delete stripped['resultType']; - return { kind: 'complete', result: stripped as Result }; + return { kind: 'complete', result: toNeutralResult(stripped) }; } - return { kind: 'complete', result: raw as Result }; + return { kind: 'complete', result: toNeutralResult(raw) }; }, // The never-stamp guarantee: identity. No stamp code path exists. diff --git a/packages/core/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts index ee749a2d4..e865fb58e 100644 --- a/packages/core/src/wire/rev2025-11-25/registry.ts +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -23,31 +23,79 @@ import type * as z from 'zod/v4'; import { + CallToolRequestSchema, CallToolResultSchema, + CancelledNotificationSchema, + CompleteRequestSchema, CompleteResultSchema, + CreateMessageRequestSchema, CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, + GetPromptRequestSchema, GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, InitializeResultSchema, + ListPromptsRequestSchema, ListPromptsResultSchema, + ListResourcesRequestSchema, ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, + ListRootsRequestSchema, ListRootsResultSchema, + ListToolsRequestSchema, ListToolsResultSchema, - ReadResourceResultSchema + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema } from '../../types/schemas.js'; import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; -import { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas.js'; +import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas.js'; +import { + CancelTaskRequestSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + ListTasksRequestSchema, + TaskStatusNotificationSchema +} from './schemas.js'; + +/* The era's wire vocabulary, derived from the wire role unions in + * `./schemas.ts` (the same unions the registries used to be built from at + * runtime). Keying the maps by these derived unions makes drift a compile + * error in BOTH directions: a union member without a map entry, a map entry + * the unions do not know, and an entry pointing at a different method's + * schema all fail to typecheck. */ +type WireRequest = z.output | z.output; +type WireNotification = z.output | z.output; + +/** Every request method in the 2025-era wire vocabulary (the typed `RequestMethod` surface plus the task family). */ +export type Rev2025RequestMethod = WireRequest['method']; +/** Every notification method in the 2025-era wire vocabulary. */ +export type Rev2025NotificationMethod = WireNotification['method']; /* Runtime schema lookup — result schemas by method */ -// Keyed by `RequestMethod` so the runtime map and the typed `ResultTypeMap` -// cannot drift: `getResultSchema`'s typed overload asserts each entry parses -// to `ResultTypeMap[M]`, so no entry may be looser than the typed map -// (no task-result union members) and no key may fall outside it (no `tasks/*` +// Keyed by `RequestMethod` and valued by `z.ZodType` so the +// runtime map and the typed `ResultTypeMap` cannot drift: a missing entry, an +// extra key, or an entry that does not parse to the typed map's result type +// is a compile error. No entry may be looser than the typed map (no +// task-result union members) and no key may fall outside it (no `tasks/*` // entries — the task methods are 2025-11-25 wire vocabulary with no SDK // runtime; callers needing task interop pass an explicit schema). -const resultSchemas: Record = { +const resultSchemas: { readonly [M in RequestMethod]: z.ZodType } = { ping: EmptyResultSchema, initialize: InitializeResultSchema, 'completion/complete': CompleteResultSchema, @@ -66,75 +114,98 @@ const resultSchemas: Record = { 'roots/list': ListRootsResultSchema }; +/* Runtime schema lookup — request and notification schemas by method. + * + * The entries are the SAME schema objects the wire role unions are built + * from (reference identity is pinned by `test/types/registryPins.test.ts`), + * and the key order preserves the pre-split union iteration order so the + * exported method lists are byte-identical to the builder they replace. */ +const requestSchemas: { readonly [M in Rev2025RequestMethod]: z.ZodType> } = { + ping: PingRequestSchema, + initialize: InitializeRequestSchema, + 'completion/complete': CompleteRequestSchema, + 'logging/setLevel': SetLevelRequestSchema, + 'prompts/get': GetPromptRequestSchema, + 'prompts/list': ListPromptsRequestSchema, + 'resources/list': ListResourcesRequestSchema, + 'resources/templates/list': ListResourceTemplatesRequestSchema, + 'resources/read': ReadResourceRequestSchema, + 'resources/subscribe': SubscribeRequestSchema, + 'resources/unsubscribe': UnsubscribeRequestSchema, + 'tools/call': CallToolRequestSchema, + 'tools/list': ListToolsRequestSchema, + 'tasks/get': GetTaskRequestSchema, + 'tasks/result': GetTaskPayloadRequestSchema, + 'tasks/list': ListTasksRequestSchema, + 'tasks/cancel': CancelTaskRequestSchema, + 'sampling/createMessage': CreateMessageRequestSchema, + 'elicitation/create': ElicitRequestSchema, + 'roots/list': ListRootsRequestSchema +}; + +const notificationSchemas: { readonly [M in Rev2025NotificationMethod]: z.ZodType> } = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/initialized': InitializedNotificationSchema, + 'notifications/roots/list_changed': RootsListChangedNotificationSchema, + 'notifications/tasks/status': TaskStatusNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +}; + +/** The 2025-era request-method set (registry membership = the deletion story). */ +export function hasRequestMethod2025(method: string): method is Rev2025RequestMethod { + return Object.prototype.hasOwnProperty.call(requestSchemas, method); +} + +/** The 2025-era notification-method set. */ +export function hasNotificationMethod2025(method: string): method is Rev2025NotificationMethod { + return Object.prototype.hasOwnProperty.call(notificationSchemas, method); +} + +/** Result-map membership: exactly the typed `RequestMethod` set (no task entries). */ +function hasResultMethod(method: string): method is RequestMethod { + return Object.prototype.hasOwnProperty.call(resultSchemas, method); +} + /** * Gets the Zod schema for validating results of a given request method. * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. + * The typed overload is backed by the map's own typing (`z.ZodType` + * per entry), so callers with a statically known method can use the parsed + * value without a type assertion. */ export function getResultSchema(method: M): z.ZodType; export function getResultSchema(method: string): z.ZodType | undefined; export function getResultSchema(method: string): z.ZodType | undefined { - return resultSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; -} - -/* Runtime schema lookup — request schemas by method */ -type RequestSchemaType = (typeof ClientRequestSchema.options)[number] | (typeof ServerRequestSchema.options)[number]; -type NotificationSchemaType = (typeof ClientNotificationSchema.options)[number] | (typeof ServerNotificationSchema.options)[number]; - -function buildSchemaMap(schemas: readonly T[]): Record { - const map: Record = {}; - for (const schema of schemas) { - const method = schema.shape.method.value; - map[method] = schema; - } - return map; + return hasResultMethod(method) ? resultSchemas[method] : undefined; } -const requestSchemas = buildSchemaMap([...ClientRequestSchema.options, ...ServerRequestSchema.options] as const) as Record< - RequestMethod, - RequestSchemaType ->; -const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, ...ServerNotificationSchema.options] as const) as Record< - NotificationMethod, - NotificationSchemaType ->; - /** * Gets the Zod schema for a given request method. * Returns `undefined` for non-spec methods. - * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers - * to use schema.parse() without needing additional type assertions. - * - * Note: The internal cast is necessary because TypeScript can't correlate the - * Record-based schema lookup with the MethodToTypeMap-based RequestTypeMap - * when M is a generic type parameter. Both compute to the same type at - * instantiation, but TypeScript can't prove this statically. + * The typed overload returns a ZodType that parses to `RequestTypeMap[M]`, + * allowing callers to use `schema.parse()` without additional type assertions. */ export function getRequestSchema(method: M): z.ZodType; export function getRequestSchema(method: string): z.ZodType | undefined; export function getRequestSchema(method: string): z.ZodType | undefined { - return requestSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; + return hasRequestMethod2025(method) ? requestSchemas[method] : undefined; } /** * Gets the Zod schema for a given notification method. * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. + * @see getRequestSchema for the typed-overload contract. */ export function getNotificationSchema(method: M): z.ZodType; export function getNotificationSchema(method: string): z.ZodType | undefined; export function getNotificationSchema(method: string): z.ZodType | undefined { - return notificationSchemas[method as NotificationMethod] as unknown as z.ZodType | undefined; -} - -/** The 2025-era request-method set (registry membership = the deletion story). */ -export function hasRequestMethod2025(method: string): boolean { - return Object.prototype.hasOwnProperty.call(requestSchemas, method); -} - -/** The 2025-era notification-method set. */ -export function hasNotificationMethod2025(method: string): boolean { - return Object.prototype.hasOwnProperty.call(notificationSchemas, method); + return hasNotificationMethod2025(method) ? notificationSchemas[method] : undefined; } /** Registry method lists (for the spec-method universe and the CI registry-diff oracle). */ diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 8aef3e3d1..8d891493c 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -215,7 +215,7 @@ export class Server extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); } - return validationResult.data as Result; + return validationResult.data; }; } @@ -503,17 +503,9 @@ export class Server extends Protocol { // request on the 2026 era, so a modern-era instance fails with the // typed era error before anything reaches the transport). if (params.tools) { - return (await this._requestWithSchema( - { method: 'sampling/createMessage', params }, - CreateMessageResultWithToolsSchema, - options - )) as CreateMessageResultWithTools; + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); } - return (await this._requestWithSchema( - { method: 'sampling/createMessage', params }, - CreateMessageResultSchema, - options - )) as CreateMessageResult; + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } /**