diff --git a/.changeset/codec-era-gates.md b/.changeset/codec-era-gates.md new file mode 100644 index 0000000000..30855b7f87 --- /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 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/.changeset/codec-split-wire-break.md b/.changeset/codec-split-wire-break.md new file mode 100644 index 0000000000..2a20452ab6 --- /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 312229a8c5..c906b0bc7d 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; 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) + +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 afef43025b..764203ec2b 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. 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):** @@ -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 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): + +- **`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/client/src/client/client.ts b/packages/client/src/client/client.ts index c181133c86..7bacbf8d2b 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -43,30 +43,20 @@ import type { UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - CompleteResultSchema, - CreateMessageRequestSchema, + codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ElicitRequestSchema, - ElicitResultSchema, - EmptyResultSchema, - GetPromptResultSchema, - InitializeResultSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, mergeCapabilities, + negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, - ReadResourceResultSchema, SdkError, - SdkErrorCode + SdkErrorCode, + setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; /** @@ -219,7 +209,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; @@ -302,7 +291,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 + // 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 + // 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 = @@ -324,7 +325,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 = @@ -355,7 +356,16 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + // 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( + 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); @@ -366,6 +376,11 @@ 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 = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; const validationResult = parseSchema(resultSchema, result); @@ -423,13 +438,26 @@ 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); + 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: 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._requestWithSchema( + const result = await this.request( { method: 'initialize', params: { @@ -438,7 +466,6 @@ export class Client extends Protocol { clientInfo: this._clientInfo } }, - InitializeResultSchema, options ); @@ -452,7 +479,6 @@ export class Client extends Protocol { this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; - this._negotiatedProtocolVersion = result.protocolVersion; // HTTP transports must set the protocol version in each header after initialization. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.protocolVersion); @@ -464,6 +490,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); @@ -496,7 +531,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); } /** @@ -638,22 +673,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 +718,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 +748,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 +768,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 +824,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 +918,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/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts index 77f3d3dfc8..99d8f84dfb 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/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index 808841807c..1f77d1faca 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/index.ts b/packages/core/src/index.ts index a704267ee3..fc022586f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,9 +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 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 { 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 0cad635289..d37198860b 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,9 @@ import { } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; +import { bootstrapOutboundCodec } from '../wire/bootstrap.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'; /** @@ -157,15 +157,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 @@ -379,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. @@ -391,12 +421,37 @@ 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(); 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[]; /** @@ -529,7 +584,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)}`)); } @@ -576,23 +631,57 @@ 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; + + // 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 + // 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}`))); } @@ -601,29 +690,99 @@ 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; + + // 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 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, data?: unknown) => { const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, - error: { - code: ProtocolErrorCode.MethodNotFound, - message: 'Method not found' - } + 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}`, { + // 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; + } + } + + // 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; } + 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; + } + + // 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(this._resolveOutboundCodec(notification.method), notification, { + ...options, + relatedRequestId: request.id + }); + const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => + this._requestWithSchemaViaCodec(this._resolveOutboundCodec(r.method), r, resultSchema, { + ...options, + relatedRequestId: request.id + }); + const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); @@ -642,10 +801,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 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 = getResultSchema(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().` @@ -669,8 +833,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 = { - result, + result: encoded, jsonrpc: '2.0', id: request.id }; @@ -804,26 +985,91 @@ export abstract class Protocol { options?: RequestOptions ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { + const codec = this._resolveOutboundCodec(request.method); + 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); + } + + /** + * 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(); } /** - * Sends a request and waits for a response, using the provided schema for validation. + * 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 } + ); + } + } + + /** + * 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> { + const codec = this._resolveOutboundCodec(request.method); + this._assertOutboundRequestInEra(codec, request.method); + return this._requestWithSchemaViaCodec(codec, 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 ?? {}; @@ -951,6 +1197,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); @@ -991,10 +1259,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(this._resolveOutboundCodec(notification.method), notification, options); + } + + /** + * 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) { 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 }; @@ -1076,18 +1363,32 @@ 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 connection (the instance era at + // dispatch time), never with a schema captured at registration + // time. + stored = (request, ctx) => { + const schema = this._negotiatedWireCodec().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 }; - 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}`); } @@ -1159,13 +1460,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; } @@ -1173,9 +1483,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 dd5c4765a1..8091b962c1 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 00d04db3ad..105fca6dc0 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1,23 +1,7 @@ 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 type { - JSONArray, - JSONObject, - JSONValue, - NotificationMethod, - NotificationTypeMap, - RequestMethod, - RequestTypeMap, - ResultTypeMap -} from './types.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(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), JSONValueSchema), z.array(JSONValueSchema)]) @@ -34,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() @@ -127,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.) }); /** @@ -682,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. @@ -1460,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. @@ -1600,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. @@ -1695,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(), @@ -2209,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, @@ -2222,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([ @@ -2242,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, @@ -2268,7 +2042,6 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); @@ -2282,95 +2055,5 @@ export const ServerResultSchema = z.union([ ListResourceTemplatesResultSchema, ReadResourceResultSchema, CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListToolsResultSchema ]); - -/* 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/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 9da6a2f4a8..de66e99418 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 f2ca07ad36..e0a9b04304 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, @@ -588,6 +594,38 @@ 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 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 { + /** + * 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 +635,14 @@ export interface MessageExtraInfo { */ request?: globalThis.Request; + /** + * Protocol-era classification of the message, when the transport + * 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; + /** * The authentication information. */ diff --git a/packages/core/src/wire/bootstrap.ts b/packages/core/src/wire/bootstrap.ts new file mode 100644 index 0000000000..3f54029328 --- /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 0000000000..7e61b95363 --- /dev/null +++ b/packages/core/src/wire/codec.ts @@ -0,0 +1,206 @@ +/** + * 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 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 + * 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 { + 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. */ +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 }; + +/** + * 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). 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; + + /** + * 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 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 classifiedWireEra(classification: MessageClassification): WireEra { + if (classification.revision !== undefined) return codecForVersion(classification.revision).era; + return classification.era === 'modern' ? MODERN_WIRE_REVISION : rev2025Codec.era; +} + +/** + * 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]; 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 0000000000..458379d9cd --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -0,0 +1,64 @@ +/** + * 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, 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); +} + +/** 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: 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 + // 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: toNeutralResult(stripped) }; + } + return { kind: 'complete', result: toNeutralResult(raw) }; + }, + + // 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 0000000000..e865fb58ea --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -0,0 +1,213 @@ +/** + * 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 { + 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, + 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 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` 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: { readonly [M in RequestMethod]: z.ZodType } = { + 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 +}; + +/* 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. + * 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 hasResultMethod(method) ? resultSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given request method. + * Returns `undefined` for non-spec methods. + * 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 hasRequestMethod2025(method) ? requestSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given notification method. + * Returns `undefined` for non-spec methods. + * @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 hasNotificationMethod2025(method) ? notificationSchemas[method] : undefined; +} + +/** 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); 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 0000000000..3c62d7f900 --- /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 0000000000..aaf03ab389 --- /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 d044709485..06fc311ab2 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 ffee5b9a7d..b50c376793 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/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 6e77430d61..f488284bd5 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,144 @@ 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(); + }); +}); + +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' + }); + }); +}); + +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(); + }); +}); diff --git a/packages/core/test/shared/rawResultTypeFirst.test.ts b/packages/core/test/shared/rawResultTypeFirst.test.ts index 6ccec21b95..b9710b6e42 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 acd667c6cc..1cd836d3db 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 {} @@ -91,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 45adde80e2..40b14a43cf 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 a92615bceb..70b0b02a82 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/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index b7985ae2c8..bb5fb64325 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', diff --git a/packages/core/test/types/registryPins.test.ts b/packages/core/test/types/registryPins.test.ts new file mode 100644 index 0000000000..73222b8eb5 --- /dev/null +++ b/packages/core/test/types/registryPins.test.ts @@ -0,0 +1,198 @@ +/** + * 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 { + 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, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + 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 = { + 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/'))); + }); +}); diff --git a/packages/core/test/types/schemaBoundaryPins.test.ts b/packages/core/test/types/schemaBoundaryPins.test.ts index 5cb1f5cccb..0f18151beb 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 85e7d4c195..7a077717cf 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 8b2ea7526d..1a71e600cc 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()']) { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 1925e5ced4..8d891493c6 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -34,22 +34,20 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { - CallToolRequestSchema, - CallToolResultSchema, + codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ElicitResultSchema, - EmptyResultSchema, LATEST_PROTOCOL_VERSION, - ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, + negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode + SdkErrorCode, + setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -85,7 +83,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; @@ -190,7 +187,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 instance era, resolved at dispatch time (the era gate + // guarantees tools/call exists on the serving era). + 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), + // 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,7 +208,7 @@ 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); @@ -364,7 +373,10 @@ export class Server extends Protocol { ? requestedVersion : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); - this._negotiatedProtocolVersion = 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 { @@ -395,7 +407,7 @@ export class Server extends Protocol { * `undefined` before initialization. */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; + return negotiatedProtocolVersionOf(this); } /** @@ -406,7 +418,7 @@ export class Server extends Protocol { } async ping(): Promise { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); + return this.request({ method: 'ping' }); } /** @@ -484,11 +496,16 @@ 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._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); } - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } /** @@ -508,7 +525,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 +537,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 +594,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); } /** diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0307681f43..4ca198535b 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 }); + }); + }); }); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index a7613b24e4..89ea643edb 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -171,6 +171,42 @@ test('should restore negotiated protocol version on transport when reconnecting expect(reconnectSetProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); }); +/*** + * 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'; + 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: 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 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); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + + await client.close(); +}); + /*** * Test: Reject Unsupported Protocol Version */ @@ -1769,6 +1805,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 +1883,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 +2111,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 +2197,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'strict-tool') { // Return structured content with extra property return { + content: [], structuredContent: { name: 'John', extraField: 'not allowed'