diff --git a/.changeset/mrtr-server-seam.md b/.changeset/mrtr-server-seam.md new file mode 100644 index 0000000000..861d7df9b6 --- /dev/null +++ b/.changeset/mrtr-server-seam.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +Add the server side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). Handlers for `tools/call`, `prompts/get`, and `resources/read` can return the value built by `inputRequired()` (exported from the server package together with `acceptedContent()`) +to request additional client input in-band; the structured-content requirement and the tools/call result-schema validation are skipped for that return, the encode seam emits it as `resultType: 'input_required'`, and the handler reads the responses on re-entry from +`ctx.mcpReq.inputResponses` (with non-bare entries reported via `ctx.mcpReq.droppedInputResponseKeys`). The seam re-checks the at-least-one rule for hand-built results, checks every embedded request against the capabilities the client declared on that request's envelope +(answering the typed `-32003` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method or toward a 2025-era request. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request +fails as an internal error with a clear steer to `inputRequired.elicitUrl(...)`, so the `-32042` error never reaches the 2026-07-28 wire; 2025-era serving keeps today's `-32042` behavior +exactly. The typed local error raised when push-style server-to-client request APIs are used while serving a 2026-era request now steers to `inputRequired(...)`. Tool, prompt, and resource callback types accept the new return alongside their existing result types; 2025-era +wire behavior is unchanged. An optional `ServerOptions.requestState.verify` hook lets a server integrity-check the echoed `requestState` before the handler runs — a throw answers the wire-level `-32602` Invalid Params error with `data.reason: 'invalid_request_state'`; the SDK provides no default verification. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b44c5a1f80..14d9330dfd 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -501,34 +501,58 @@ The 2025-11 task side-channel through `Protocol` is removed (was always `@experi `TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) are also removed; they will return with the SEP-2663 server-directed plugin. -NOT removed (wire surface, kept for 2025-11-25 interop, now `@deprecated`): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. +NOT removed (wire surface, kept for 2025-11-25 interop, now `@deprecated`): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, +`TaskAugmentedRequestParams`), task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. -Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap` have no `tasks/*` entries and `NotificationMethod`/`NotificationTypeMap` have no `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. Mechanical fix where task interop is genuinely required: pass an explicit schema (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`-style custom-method form). `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. +Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap` have no `tasks/*` entries and `NotificationMethod`/`NotificationTypeMap` have no `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, +`setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. Mechanical fix where task interop is genuinely required: pass an explicit schema (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`-style custom-method form). +`ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. ## 12b. Wire-only members hidden from public types -`resultType` (2026-07-28 result discrimination) is no longer declared on any public result type; the SDK parses and consumes it internally. The reserved `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`) and retry fields (`inputResponses`, `requestState`) appear in no public params/result type. `RequestMetaEnvelope` and the `*_META_KEY` constants remain exported. +`resultType` (2026-07-28 result discrimination) is no longer declared on any public result type; the SDK parses and consumes it internally. The reserved `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`) and retry fields +(`inputResponses`, `requestState`) appear in no public params/result type. `RequestMetaEnvelope` and the `*_META_KEY` constants remain exported. -| Pattern in v2-alpha code | Mechanical fix | -| ------------------------------------- | --------------------------------------------------------------------------------- | -| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | -| `Result['resultType']` type reference | remove; the member is no longer declared | -| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | +v1 code never reads `resultType` (the field did not exist before 2026-07-28); the table below applies only to code that began reading the wire shape directly. -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). +| Pattern | Mechanical fix | +| -------------------------------------- | --------------------------------------------------------------------------------- | +| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | +| `Result['resultType']` type reference | remove; the member is no longer declared | +| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | + +Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted +envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response +carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; 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. +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. + +| v1 pattern | 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 | + +## 12d. Multi round-trip requests (2026-07-28) + +The 2026-07-28 revision removes the server→client JSON-RPC request channel; servers obtain client input in-band by returning `inputRequired(...)` from a `tools/call`/`prompts/get`/`resources/read` handler, and the client's auto-fulfilment driver retries the original call. + +| v1 pattern (handler serving 2026-07-28 requests) | Mechanical fix | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` inside a handler | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | +| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | +| handler shared across both eras | branch on the served era: keep the v1 push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests | -| 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 | +`inputRequired`/`acceptedContent`/`InputRequiredSpec` are exported from `@modelcontextprotocol/server`. `requestState` round-trips as an opaque string and comes back as attacker-controlled input — integrity-protect (HMAC/AEAD) and verify it yourself when relying on it. Client +side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRounds` cap default 10); manual mode is `inputRequired: { autoFulfill: false }` plus per-call `allowInputRequired: true` and `withInputRequired(schema)`. ## 13. Behavioral Changes @@ -540,34 +564,43 @@ The wire layer is split into per-era codecs (2025-era = 2024-10-07 … 2025-11-2 No code changes required; these are wire-behavior notes: -- Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no longer enable it. Behavior for all currently supported protocol versions is unchanged. -- Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — migrated client code should key off the HTTP `404` status, not the `-32001` code. +- Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no + longer enable it. Behavior for all currently supported protocol versions is unchanged. +- Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — + migrated client code should key off the HTTP `404` status, not the `-32001` code. ### Server (deprecated accessors and app-factory Origin validation) These can require code changes: -- `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed. -- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. +- `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are + removed. +- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need + `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. ### Server (HTTP entry: createMcpHandler — serving the 2026-07-28 draft revision) New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps onto the entry: -- `createMcpHandler(factory)` from `@modelcontextprotocol/server` serves the 2026-07-28 draft revision per request and, out of the box, also serves 2025-era (non-envelope) traffic through per-request stateless serving (`legacy: 'stateless'`, the default) — one factory, one endpoint, both eras. A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. -- Pass `legacy: 'reject'` for a strict, modern-only endpoint: 2025-era requests are rejected with the unsupported-protocol-version error naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. The option type is `legacy?: 'stateless' | 'reject'`. -- An existing sessionful v1 Streamable HTTP setup (a `StreamableHTTPServerTransport` wiring with session IDs) keeps serving 2025 clients by routing in user land in front of a strict entry: `if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. -- `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` is the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. +- `createMcpHandler(factory)` from `@modelcontextprotocol/server` serves the 2026-07-28 draft revision per request and, out of the box, also serves 2025-era (non-envelope) traffic through per-request stateless serving (`legacy: 'stateless'`, the default) — one factory, one + endpoint, both eras. A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. +- Pass `legacy: 'reject'` for a strict, modern-only endpoint: 2025-era requests are rejected with the unsupported-protocol-version error naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. The option type is + `legacy?: 'stateless' | 'reject'`. +- An existing sessionful v1 Streamable HTTP setup (a `StreamableHTTPServerTransport` wiring with session IDs) keeps serving 2025 clients by routing in user land in front of a strict entry: + `if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. +- `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` is the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, + GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, + never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. - `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default. ### Server (stdio / long-lived connections) - A hand-constructed `Server`/`McpServer` connected to a `StdioServerTransport` serves only the 2025-era protocol it was written for: today's behavior, byte-identical — no change required during a mechanical migration. -- Serving the 2026-07-28 draft revision (or both eras) on stdio goes through the connection-pinned entry: `serveStdio(() => new McpServer(info, options))` from `@modelcontextprotocol/server/stdio`. The opening exchange selects the connection's era (2025 `initialize` vs - 2026 per-request envelope, with `server/discover` answered as a probe); one factory instance is pinned per connection. There is no per-instance option that makes a hand-constructed server serve the 2026 revision: move the v1 `server.connect(new StdioServerTransport())` - call into `serveStdio(() => buildServer())`. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. -- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision - (`2026-07-28`), as on instances served through `createMcpHandler`. 2025-pinned connections keep the `initialize`-scoped semantics for all three accessors. +- Serving the 2026-07-28 draft revision (or both eras) on stdio goes through the connection-pinned entry: `serveStdio(() => new McpServer(info, options))` from `@modelcontextprotocol/server/stdio`. The opening exchange selects the connection's era (2025 `initialize` vs 2026 + per-request envelope, with `server/discover` answered as a probe); one factory instance is pinned per connection. There is no per-instance option that makes a hand-constructed server serve the 2026 revision: move the v1 `server.connect(new StdioServerTransport())` call into + `serveStdio(() => buildServer())`. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. +- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision (`2026-07-28`), + as on instances served through `createMcpHandler`. 2025-pinned connections keep the `initialize`-scoped semantics for all three accessors. - A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. ## 14. Runtime-Specific JSON Schema Validators (Enhancement) diff --git a/docs/migration.md b/docs/migration.md index cd51f7dbbc..688d4e4bbe 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -336,11 +336,11 @@ Note: the v2 signature takes a plain `string[]` instead of an options object. ### Resumability gating for unknown protocol versions (Streamable HTTP server) -The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an -open-ended `protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior. +The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an open-ended +`protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior. -The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through -`2025-11-25`) is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided. +The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through `2025-11-25`) +is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided. ### `setRequestHandler` and `setNotificationHandler` use method strings @@ -902,57 +902,67 @@ The 2025-11 experimental tasks side-channel woven through `Protocol` has been re **Also removed:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`). It will return as part of the SEP-2663 server-directed plugin in a follow-up. -**Wire types remain, as deprecated vocabulary.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. These exports are now marked `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support), and the typed method surface no longer offers task methods: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap` exclude `tasks/*` and `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, and `setNotificationHandler()` do not accept them (the explicit-schema overloads still work for custom interop). The method-keyed result types are narrowed to match: `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`), and likewise `sampling/createMessage` and `elicitation/create` lose their task-result union members — the runtime result validation uses the same plain schemas, so a task-shaped response body to one of these methods fails as a local `INVALID_RESULT` error where the result schema rejects it rather than parsing into a mis-typed success. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. +**Wire types remain, as deprecated vocabulary.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, +`RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, the +`isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. These exports are now marked `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support), and the typed method surface no longer offers task methods: +`RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap` exclude `tasks/*` and `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, and `setNotificationHandler()` do not accept them (the +explicit-schema overloads still work for custom interop). The method-keyed result types are narrowed to match: `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`), and likewise `sampling/createMessage` and `elicitation/create` lose their task-result +union members — the runtime result validation uses the same plain schemas, so a task-shaped response body to one of these methods fails as a local `INVALID_RESULT` error where the result schema rejects it rather than parsing into a mis-typed success. Only the behavior is gone: +servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. There is no migration path for the removed surface; it was always `@experimental`. Task support is planned to return as an opt-in extension plugin per SEP-2663. ### Wire-only protocol members hidden from the public types -The protocol revision 2026-07-28 introduces wire-level bookkeeping that the SDK handles internally and that never needs to reach application code: the `resultType` result discrimination field, the reserved per-request `_meta` envelope keys (`io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, `io.modelcontextprotocol/clientCapabilities`, `io.modelcontextprotocol/logLevel`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). The public TypeScript surface no longer declares these members: +The protocol revision 2026-07-28 introduces wire-level bookkeeping that the SDK handles internally and that never needs to reach application code: the `resultType` result discrimination field, the reserved per-request `_meta` envelope keys +(`io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, `io.modelcontextprotocol/clientCapabilities`, `io.modelcontextprotocol/logLevel`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). The public TypeScript surface no longer +declares these members: -- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …, and the `result` member of `JSONRPCResultResponse`). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. If you previously read `result.resultType` (it was always `undefined` from conforming 2025-era peers), drop the read — the SDK now owns that field. -- **High-level methods return the named public types.** `client.callTool()` returns `Promise`, `client.listTools()` returns `Promise`, and so on (previously these returned structurally inferred schema types that exposed `resultType?`). Handler return positions are unaffected: results you build keep type-checking, and unknown members still pass through the loose index signature. +- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …, and the `result` member of `JSONRPCResultResponse`). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. If you previously + read `result.resultType` (it was always `undefined` from conforming 2025-era peers), drop the read — the SDK now owns that field. +- **High-level methods return the named public types.** `client.callTool()` returns `Promise`, `client.listTools()` returns `Promise`, and so on (previously these returned structurally inferred schema types that exposed `resultType?`). Handler + return positions are unaffected: results you build keep type-checking, and unknown members still pass through the loose index signature. - **The reserved envelope keys and retry fields never appear in a public params/result type.** The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported — they document the wire names and type the context surfacing channel (see below). 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):** - -```typescript -const result = await client.callTool({ name: 'echo', arguments: {} }); -// result.resultType was declared as `string | undefined` and always undefined -if (result.resultType === undefined || result.resultType === 'complete') { - console.log(result.content); -} -``` - -**After:** - -```typescript -const result = await client.callTool({ name: 'echo', arguments: {} }); -// resultType is wire-level bookkeeping the SDK consumes; just use the result -console.log(result.content); -``` +- **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; `'input_required'` is fulfilled by the client's multi-round-trip engine (see + "Multi round-trip requests" below); any other kind 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. +- **`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). ### 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. +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. + - `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:** @@ -975,11 +985,7 @@ server.setRequestHandler('tools/call', async () => { }); // 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 => ({}) -); +protocol.setRequestHandler('acme/op', { params: z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }) }, async params => ({})); ``` ## Enhancements @@ -993,10 +999,7 @@ import { Client } from '@modelcontextprotocol/client'; // Auto-negotiate: try the 2026-07-28 draft revision, fall back to the 2025 // handshake automatically when the server is a 2025-era deployment. -const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' } } -); +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(transport); client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' @@ -1005,14 +1008,15 @@ client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' How the modes behave: - **absent / `mode: 'legacy'`** (default): today's behavior, unchanged. No probe, no new headers. -- **`mode: 'auto'`**: `connect()` first sends a single `server/discover` probe. A modern server answers it and no `initialize` is sent; a 2025-era server rejects it (deployed servers answer fast, e.g. `-32601` or a `400`), and the client falls back to the plain legacy - handshake **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. +- **`mode: 'auto'`**: `connect()` first sends a single `server/discover` probe. A modern server answers it and no `initialize` is sent; a 2025-era server rejects it (deployed servers answer fast, e.g. `-32601` or a `400`), and the client falls back to the plain legacy handshake + **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. - **`mode: { pin: '2026-07-28' }`**: modern era at exactly that revision. No fallback — if the server does not offer the pinned version, `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). -Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era -revision; with a modern-only list there is nothing to fall back to and `connect()` rejects with the typed negotiation error instead — while a network outage rejects with a typed connect error (`SdkError` with `EraNegotiationFailed`). A probe timeout is transport-aware, following the specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown -pre-`initialize` requests at all) and the client falls back to `initialize` on the same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a -legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. +Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era revision; with a +modern-only list there is nothing to fall back to and `connect()` rejects with the typed negotiation error instead — while a network outage rejects with a typed connect error (`SdkError` with `EraNegotiationFailed`). A probe timeout is transport-aware, following the +specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown pre-`initialize` requests at all) and the client falls back to `initialize` on the +same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during +the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. Probe policy is configured under `versionNegotiation.probe`: @@ -1026,9 +1030,9 @@ versionNegotiation: { ``` On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and -other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe -already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other +long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation +probe already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1051,15 +1055,14 @@ const handler = createMcpHandler(ctx => { How the `legacy` option behaves: -- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only - `sessionIdGenerator: undefined`. Because this serving is per-request and stateless, GET and DELETE (2025 session operations) are answered `405` / `Method not allowed.`, exactly like the canonical stateless example. The exported `legacyStatelessFallback(factory)` is the - same serving as a standalone fetch-shaped handler for hand-wired compositions. -- **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no - 2025 serving in this mode.** +- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`. + Because this serving is per-request and stateless, GET and DELETE (2025 session operations) are answered `405` / `Method not allowed.`, exactly like the canonical stateless example. The exported `legacyStatelessFallback(factory)` is the same serving as a standalone fetch-shaped + handler for hand-wired compositions. +- **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no 2025 + serving in this mode.** -> **If you have an existing sessionful 1.x Streamable HTTP setup** (a `StreamableHTTPServerTransport` wiring with session IDs that your deployed 2025-era clients depend on), keep that handler serving 2025 traffic and route it in front of a strict (`legacy: 'reject'`) -> entry with the exported `isLegacyRequest(request)` predicate. The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the -> entry: +> **If you have an existing sessionful 1.x Streamable HTTP setup** (a `StreamableHTTPServerTransport` wiring with session IDs that your deployed 2025-era clients depend on), keep that handler serving 2025 traffic and route it in front of a strict (`legacy: 'reject'`) entry with +> the exported `isLegacyRequest(request)` predicate. The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the entry: > > ```typescript > // An existing sessionful 1.x streamable HTTP wiring keeps serving 2025 clients, routed in front of a strict entry. @@ -1077,22 +1080,22 @@ How the `legacy` option behaves: > }; > ``` > -> `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, and non-JSON bodies). It returns `false` for everything the -> modern path answers — including a request carrying a **malformed** modern claim, which the modern path rejects with `-32602` — so route `false` traffic to the modern handler, never to your legacy handler. The predicate classifies a clone, so the request body stays -> readable for whichever handler you route to (pass an already-parsed body as the second argument if the stream has been consumed). +> `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, and non-JSON bodies). It returns `false` for everything the modern path +> answers — including a request carrying a **malformed** modern claim, which the modern path rejects with `-32602` — so route `false` traffic to the modern handler, never to your legacy handler. The predicate classifies a clone, so the request body stays readable for whichever +> handler you route to (pass an already-parsed body as the second argument if the stream has been consumed). -The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams -and DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives -out-of-band errors and rejected requests for logging. +The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams and +DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives out-of-band errors and +rejected requests for logging. -The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from -request headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed -around (`const { fetch } = handler`). +The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from request +headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around +(`const { fetch } = handler`). ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` -The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to -that connection and serves only that era. +The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that +connection and serves only that era. ```typescript import { McpServer } from '@modelcontextprotocol/server'; @@ -1107,28 +1110,27 @@ serveStdio(() => { How the connection's era is decided: -- A plain 2025 client opens with the `initialize` handshake (or any request without the per-request `_meta` envelope): the connection is pinned to a 2025-era instance and served exactly as a hand-wired stdio server serves it today. Pass `legacy: 'reject'` to refuse - 2025-era openings instead — they are answered with the unsupported-protocol-version error naming the supported modern revisions, and there is no silent 2025 serving. +- A plain 2025 client opens with the `initialize` handshake (or any request without the per-request `_meta` envelope): the connection is pinned to a 2025-era instance and served exactly as a hand-wired stdio server serves it today. Pass `legacy: 'reject'` to refuse 2025-era + openings instead — they are answered with the unsupported-protocol-version error naming the supported modern revisions, and there is no silent 2025 serving. - A 2026-capable client opens with requests carrying the per-request `_meta` envelope: the connection is pinned to a 2026-era instance. -- A `server/discover` probe is answered (from an instance built with your factory, so the advertisement reflects your real server definition) without pinning the connection: the client either continues with enveloped modern requests — pinning the connection to the 2026 - era — or falls back to `initialize` when it shares no modern revision with the advertisement, in which case the probe instance is discarded and a fresh 2025-era instance serves the handshake. Once the modern era is pinned, a later `initialize` is rejected with the +- A `server/discover` probe is answered (from an instance built with your factory, so the advertisement reflects your real server definition) without pinning the connection: the client either continues with enveloped modern requests — pinning the connection to the 2026 era — or + falls back to `initialize` when it shares no modern revision with the advertisement, in which case the probe instance is discarded and a fresh 2025-era instance serves the handshake. Once the modern era is pinned, a later `initialize` is rejected with the unsupported-protocol-version error naming the supported revisions. -Because the entry may construct an instance for a probe that is later discarded (and `createMcpHandler` constructs one per request), factories should be cheap and side-effect-free. Bring your own transport with the `transport` option (for example a -`StdioServerTransport` over a Unix domain socket or TCP stream); by default the entry serves the current process's stdio. The returned handle's `close()` tears down the pinned instance and the transport. +Because the entry may construct an instance for a probe that is later discarded (and `createMcpHandler` constructs one per request), factories should be cheap and side-effect-free. Bring your own transport with the `transport` option (for example a `StdioServerTransport` over a +Unix domain socket or TCP stream); by default the entry serves the current process's stdio. The returned handle's `close()` tears down the pinned instance and the transport. -Directionality follows the connection's era: the 2026-07-28 revision has no server→client JSON-RPC request channel, so handlers serving a 2026-pinned connection cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a -2025-pinned connection keeps today's behavior. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. +Directionality follows the connection's era: the 2026-07-28 revision has no server→client JSON-RPC request channel, so handlers serving a 2026-pinned connection cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a 2025-pinned +connection keeps today's behavior. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. -**The v1 stdio pattern keeps working and stays 2025-only.** A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` — the way every v1 stdio server is written — still works and serves only the 2025-era protocol it was written for: upgrading -the SDK changes nothing about what it puts on the wire, and no per-instance option turns such a server into a 2026-era server. Serving the 2026-07-28 revision (or both eras) on stdio always goes through `serveStdio`. To migrate an existing v1 stdio server, move its -construction into the factory: replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`, registering tools/resources/prompts inside the factory as before — and pass `{ legacy: 'reject' }` if 2025-era clients should be refused -instead of served. +**The v1 stdio pattern keeps working and stays 2025-only.** A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` — the way every v1 stdio server is written — still works and serves only the 2025-era protocol it was written for: upgrading the SDK +changes nothing about what it puts on the wire, and no per-instance option turns such a server into a 2026-era server. Serving the 2026-07-28 revision (or both eras) on stdio always goes through `serveStdio`. To migrate an existing v1 stdio server, move its construction into the +factory: replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`, registering tools/resources/prompts inside the factory as before — and pass `{ legacy: 'reject' }` if 2025-era clients should be refused instead of served. ### Cache fields and cache hints for cacheable 2026-07-28 results -The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`). When serving that revision, the SDK now always emits both fields, -defaulting to `ttlMs: 0` and `cacheScope: 'private'` — the most conservative policy, equivalent to "do not cache". To advertise a real cache policy: +The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`). When serving that revision, the SDK now always emits both fields, defaulting to +`ttlMs: 0` and `cacheScope: 'private'` — the most conservative policy, equivalent to "do not cache". To advertise a real cache policy: ```typescript const server = new McpServer( @@ -1147,23 +1149,65 @@ server.registerResource('config', 'config://app', { cacheHint: { ttlMs: 5_000 } ``` Resolution is per field, most specific author first: for each of `ttlMs` and `cacheScope`, a value returned by the handler itself (when valid) wins over the per-resource `cacheHint`, which wins over `ServerOptions.cacheHints[operation]`, which wins over the default — so a -per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on -2025-era connections never carry these fields, with or without configuration. +per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on 2025-era +connections never carry these fields, with or without configuration. + +### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver + +The 2026-07-28 revision removes the server→client JSON-RPC request channel: servers obtain client input (elicitation, sampling, roots) **in-band**, by answering `tools/call`, `prompts/get`, or `resources/read` with an `input_required` result that embeds the requests, and the +client retries the original call with the responses. The SDK ships both halves: + +**Server side — return `inputRequired(...)` instead of pushing requests.** A handler for one of the three multi-round-trip methods requests input by returning the value built by `inputRequired()` (with the per-kind constructors `inputRequired.elicit`, `inputRequired.elicitUrl`, +`inputRequired.createMessage`, `inputRequired.listRoots`), and reads the responses on re-entry from `ctx.mcpReq.inputResponses` (the `acceptedContent()` helper reads an accepted form elicitation). Hand-built `resultType: 'input_required'` literals are equally legal. + +```typescript +const confirmSchema = { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } as const; + +server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: confirmSchema }) } + }); + } + return { content: [{ type: 'text', text: `deployed to ${env}` }] }; +}); +``` + +The in-band return is only legal toward 2026-07-28 requests. **A handler that serves both eras branches on the served era**: 2025-era handlers keep using the push-style APIs (`ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`, instance-level +`createMessage()`/`elicitInput()`/`listRoots()`), and modern handlers return `inputRequired(...)` — an `input_required` return on a 2025-era request fails as a server-side internal error rather than reaching the wire mis-typed. URL elicitation on the 2026-07-28 era is expressed +with `inputRequired.elicitUrl(...)` (correlation across retries belongs in `requestState`); throwing the 1.x `UrlElicitationRequiredError` on a 2026-era request fails loudly with a clear steer to that constructor (it is not converted), while 2025-era serving keeps today's +`-32042` behavior exactly. + +On 2026-era requests the push-style APIs (`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`, and the instance-level `server.createMessage()`/`elicitInput()`/`listRoots()`/`ping()` on modern-bound instances) fail with a typed local +error before anything reaches the wire; in a tool handler the error surfaces to the caller as an `isError` result whose text steers to returning `inputRequired(...)`. Their behavior toward 2025-era requests is unchanged. The error surface differs per family exactly as it always +has: only `tools/call` has a catch-all that wraps handler failures into `isError` results — errors thrown by `prompts/get` and `resources/read` handlers (including the loud failures of the seam guards) surface as JSON-RPC errors. + +**`requestState` is untrusted input — protect it yourself.** `inputRequired({ requestState })` lets a server round-trip opaque state through the client instead of holding it in memory. The SDK treats it as an opaque string end to end: the client echoes it back byte-exact and +never parses it, and the server sees the echoed value raw at `ctx.mcpReq.requestState`. The specification's requirement is the consumer's obligation: the value comes back as **attacker-controlled input**, so if it influences authorization, resource access, or business logic you +MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, +but it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a +real JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example. + +**Client side — auto-fulfilment by default.** When a call to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection answers `input_required`, the client fulfils the embedded requests through the same handlers registered with +`setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries the original request (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). `client.callTool()` and its siblings +keep returning their plain result types — the interactive rounds happen inside the call, and a registered handler written for the 2025 flow keeps working unchanged. Configure or opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`), drive the flow manually per call +with the `allowInputRequired: true` request option plus the `withInputRequired()` schema wrapper, and expect the typed `InputRequiredRoundsExceeded` error when the round cap is exhausted. 2025-era connections are unaffected (the legacy wire has no `input_required` vocabulary). ### Typed `-32003` missing-client-capability error -`MissingRequiredClientCapabilityError` is the typed error class for the 2026-07-28 `-32003` protocol error: processing a request requires a capability the client did not declare in the request's `clientCapabilities`. Its `data.requiredCapabilities` lists the missing -capabilities, and `ProtocolError.fromError` recognizes the code/data shape (recognize peers' errors by their code and `error.data`, not by `instanceof`). When the HTTP entry refuses such a request, the response uses HTTP status `400` as the specification requires. +`MissingRequiredClientCapabilityError` is the typed error class for the 2026-07-28 `-32003` protocol error: processing a request requires a capability the client did not declare in the request's `clientCapabilities`. Its `data.requiredCapabilities` lists the missing capabilities, +and `ProtocolError.fromError` recognizes the code/data shape (recognize peers' errors by their code and `error.data`, not by `instanceof`). When the HTTP entry refuses such a request, the response uses HTTP status `400` as the specification requires. The multi-round-trip seam +answers with the same error when a handler embeds an input request (for example an elicitation) that the request's declared client capabilities do not cover. ### Client identity accessors deprecated in favor of per-request context -`Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to -handlers as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped -values, as before. +`Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to handlers +as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped values, as before. -On a connection pinned to the 2026-07-28 era by `serveStdio` the identity accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped identity has nothing stable to report there. -`getClientCapabilities()` and `getClientVersion()` return `undefined` (no `initialize` handshake ever ran on such a connection) and handlers read the per-request identity from `ctx.mcpReq.envelope`. `getNegotiatedProtocolVersion()` reports the pinned revision -(`2026-07-28`) — the entry era-marks the instance when it binds it, so the accessor reports the same value as on instances serving that revision through `createMcpHandler`. On 2025-pinned connections the accessors keep their `initialize`-scoped semantics, as before. +On a connection pinned to the 2026-07-28 era by `serveStdio` the identity accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped identity has nothing stable to report there. `getClientCapabilities()` and `getClientVersion()` +return `undefined` (no `initialize` handshake ever ran on such a connection) and handlers read the per-request identity from `ctx.mcpReq.envelope`. `getNegotiatedProtocolVersion()` reports the pinned revision (`2026-07-28`) — the entry era-marks the instance when it binds it, so +the accessor reports the same value as on instances serving that revision through `createMcpHandler`. On 2025-pinned connections the accessors keep their `initialize`-scoped semantics, as before. ### Origin validation middleware and default arming @@ -1176,10 +1220,10 @@ const app = createMcpExpressApp(); // localhost bind: Host AND Origin validation const appCustom = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); ``` -Requests without an `Origin` header pass unchanged (MCP clients outside a browser do not send one), so non-browser traffic is unaffected. A present `Origin` whose hostname is not allowed — or that cannot be parsed, including the opaque `null` origin — is rejected with -`403` (deny on failure). For a localhost-bound factory app there is no switch that turns Origin validation off: passing an explicit `allowedOrigins` list replaces the default localhost allowlist (use it to allow additional origins, such as a deployed web frontend), and -validation stays armed. The framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) live in -`@modelcontextprotocol/server` for bare web-standard mounts, and `@modelcontextprotocol/node` now ships request guards (`hostHeaderValidation`, `originValidation` and their `localhost*` variants) for plain `node:http` servers, which previously had no validation helpers. +Requests without an `Origin` header pass unchanged (MCP clients outside a browser do not send one), so non-browser traffic is unaffected. A present `Origin` whose hostname is not allowed — or that cannot be parsed, including the opaque `null` origin — is rejected with `403` (deny +on failure). For a localhost-bound factory app there is no switch that turns Origin validation off: passing an explicit `allowedOrigins` list replaces the default localhost allowlist (use it to allow additional origins, such as a deployed web frontend), and validation stays +armed. The framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) live in `@modelcontextprotocol/server` for bare web-standard mounts, and `@modelcontextprotocol/node` now ships request guards (`hostHeaderValidation`, +`originValidation` and their `localhost*` variants) for plain `node:http` servers, which previously had no validation helpers. ### Automatic JSON Schema validator selection by runtime @@ -1270,9 +1314,9 @@ The following APIs are unchanged between v1 and v2 (only the import paths change - All Zod schemas and type definitions from `types.ts` (except the aliases listed above) - Tool, prompt, and resource callback return types -**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and -message `Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the -`-32001` code in client logic; key off the HTTP `404` status instead. +**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and message +`Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the `-32001` code in +client logic; key off the HTTP `404` status instead. ## Using an LLM to migrate your code diff --git a/examples/client/README.md b/examples/client/README.md index 46f7c82c9c..0879b3b6c0 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -35,6 +35,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/src/multiRoundTripClient.ts b/examples/client/src/multiRoundTripClient.ts new file mode 100644 index 0000000000..68068806a1 --- /dev/null +++ b/examples/client/src/multiRoundTripClient.ts @@ -0,0 +1,124 @@ +/** + * Drives the multi-round-trip server example + * (`examples/server/src/multiRoundTrip.ts`) two ways on a 2026-07-28 + * connection: + * + * 1. **auto-fulfilment** (the default) — the same `elicitation/create` + * handler the client would register for the 2025-era flow fulfils the + * embedded form and URL elicitations, and the SDK retries the original + * `tools/call` for you. `client.callTool()` returns a plain + * `CallToolResult`; + * 2. **manual mode** — `inputRequired: { autoFulfill: false }` plus per-call + * `allowInputRequired: true`: the input-required value is handed back, and + * the example collects responses, echoes `requestState`, and retries + * itself. + * + * Start the server first, then: + * + * tsx examples/client/src/multiRoundTripClient.ts + * + * (Attaching the per-request `_meta` envelope explicitly is a stop-gap; + * automatic envelope emission for every request is a client-side follow-up.) + */ +import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; +import { + Client, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + isInputRequiredResult, + PROTOCOL_VERSION_META_KEY, + StreamableHTTPClientTransport +} from '@modelcontextprotocol/client'; + +const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; +const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' }; + +// Per-request envelope every 2026-era request carries on the wire. The +// declared client capabilities are what the server's −32003 check reads. +function envelope(negotiated: string): Record { + return { + [PROTOCOL_VERSION_META_KEY]: negotiated, + [CLIENT_INFO_META_KEY]: CLIENT_INFO, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {}, url: {} } } + }; +} + +async function autoFulfilLeg(): Promise { + console.log('--- auto-fulfilment (the default) ---'); + const client = new Client(CLIENT_INFO, { + versionNegotiation: { mode: 'auto' }, + capabilities: { elicitation: { form: {}, url: {} } } + }); + // The SAME handler a 2025-flow client registers: the auto-fulfilment + // engine dispatches embedded form and URL elicitations through it. + client.setRequestHandler('elicitation/create', async request => { + const params = request.params as { mode?: string; message: string; url?: string }; + if (params.mode === 'url') { + console.log(`[client] (auto) url elicitation: ${params.message} → ${params.url}`); + return { action: 'accept' }; + } + console.log(`[client] (auto) form elicitation: ${params.message}`); + return { action: 'accept', content: { confirm: true } }; + }); + + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const negotiated = client.getNegotiatedProtocolVersion()!; + console.log('negotiated protocol version:', negotiated); + + // callTool returns a plain CallToolResult — the interactive rounds happen + // inside the call. + const result = await client.request({ + method: 'tools/call', + params: { name: 'deploy', arguments: { env: 'prod' }, _meta: envelope(negotiated) } + }); + console.log('deploy result:', JSON.stringify(result.content)); + await client.close(); +} + +async function manualLeg(): Promise { + console.log('--- manual mode (autoFulfill: false + allowInputRequired) ---'); + const client = new Client(CLIENT_INFO, { + versionNegotiation: { mode: 'auto' }, + capabilities: { elicitation: { form: {}, url: {} } }, + inputRequired: { autoFulfill: false } + }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const negotiated = client.getNegotiatedProtocolVersion()!; + + let inputResponses: Record | undefined; + let requestState: string | undefined; + for (let round = 0; round < 10; round++) { + // allowInputRequired: true → the call resolves with either the + // complete CallToolResult or the input-required value (use + // `withInputRequired(schema)` on the explicit-schema path to type + // both outcomes; here the method-keyed path is used for brevity). + const value = (await client.request( + { + method: 'tools/call', + params: { + name: 'deploy', + arguments: { env: 'staging' }, + _meta: envelope(negotiated), + ...(inputResponses && { inputResponses }), + ...(requestState && { requestState }) + } + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + if (!isInputRequiredResult(value)) { + console.log('deploy result:', JSON.stringify(value.content)); + break; + } + // Collect responses and echo requestState byte-exact. + console.log(`[client] (manual) round ${round + 1}: server asked for ${Object.keys(value.inputRequests ?? {}).join(', ')}`); + inputResponses = {}; + for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { + inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; + } + requestState = value.requestState; + } + await client.close(); +} + +await autoFulfilLeg(); +await manualLeg(); diff --git a/examples/server/README.md b/examples/server/README.md index a71e63a7d5..bce265104a 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts | Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/multiRoundTrip.ts b/examples/server/src/multiRoundTrip.ts new file mode 100644 index 0000000000..51abba4eb2 --- /dev/null +++ b/examples/server/src/multiRoundTrip.ts @@ -0,0 +1,135 @@ +/** + * A write-once tool served via `createMcpHandler` that requests client input + * with multi round-trip results (protocol revision 2026-07-28). + * + * The `deploy` tool returns `inputRequired(...)` instead of pushing a + * server→client request: a form-mode elicitation for confirmation, then a + * URL-mode elicitation for sign-in via `inputRequired.elicitUrl(...)`. The + * step the tool is waiting for is carried in `requestState`, which the SDK + * round-trips opaquely (echoed byte-exact by the client; the server reads it + * raw at `ctx.mcpReq.requestState`). + * + * `requestState` round-trips through the client and is therefore + * attacker-controlled input on re-entry. A real server MUST integrity-protect + * it (e.g. HMAC or AEAD): this example mints `body.hmac` with a per-process + * key and rejects tampered state via the {@linkcode ServerOptions.requestState} + * `verify` hook, which answers a wire-level `-32602` Invalid Params error. + * + * Run with: + * + * tsx examples/server/src/multiRoundTrip.ts + * + * and point the paired client example at it: + * + * tsx examples/client/src/multiRoundTripClient.ts + */ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; +import { acceptedContent, createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; + +// Per-process integrity key for requestState. The 2026-07-28 path serves every +// request from a fresh server instance — the state itself is the only thing +// that survives between rounds — so the key is process-local. +const STATE_KEY = randomBytes(32); + +type DeployState = { step: 'confirm' | 'signed-in'; env: string }; + +function mintState(payload: DeployState): string { + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${body}.${createHmac('sha256', STATE_KEY).update(body).digest('base64url')}`; +} + +function verifyState(state: string): void { + const dot = state.lastIndexOf('.'); + const body = dot > 0 ? state.slice(0, dot) : ''; + const expected = createHmac('sha256', STATE_KEY).update(body).digest(); + const provided = Buffer.from(state.slice(dot + 1), 'base64url'); + if (dot <= 0 || provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + throw new Error('requestState failed integrity verification'); + } +} + +function readState(ctx: { mcpReq: { requestState?: string } }): DeployState | undefined { + // The seam-level verify hook has already proven integrity by the time the + // handler runs; this only re-reads the body. + const state = ctx.mcpReq.requestState; + return state === undefined + ? undefined + : (JSON.parse(Buffer.from(state.slice(0, state.lastIndexOf('.')), 'base64url').toString()) as DeployState); +} + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'mrtr-example-server', version: '1.0.0' }, + { capabilities: { tools: {} }, requestState: { verify: verifyState } } + ); + + server.registerTool( + 'deploy', + { + title: 'Deploy (write-once)', + description: 'Deploys to the named environment after a confirmation and a sign-in.', + inputSchema: z.object({ env: z.string() }) + }, + async ({ env }, ctx): Promise => { + // The handler reads the SAME context fields on every entry; what + // changes between rounds is which input responses have arrived and + // what (verified) `requestState` was echoed back. + const state = readState(ctx); + const step = state?.step ?? 'confirm'; + console.error(`[server] tools/call deploy(${env}) step=${step}`); + + if (step === 'confirm') { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) + }, + // The next entry stays at the 'confirm' step until the + // user actually accepts. + requestState: mintState({ step: 'confirm', env }) + }); + } + // Move to the URL-mode sign-in step. URL elicitation rides + // the multi-round-trip flow on this revision — the throw-style + // UrlElicitationRequiredError of earlier revisions is not + // available toward 2026-07-28 requests. + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: 'Sign in to continue', + url: `https://example.com/auth?env=${env}` + }) + }, + requestState: mintState({ step: 'signed-in', env }) + }); + } + + // step === 'signed-in': the URL-mode elicitation completed out of + // band — verify the auth response actually arrived. + const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined; + if (auth?.action !== 'accept') { + return { isError: true, content: [{ type: 'text', text: 'auth response missing or declined' }] }; + } + return { content: [{ type: 'text', text: `deployed to ${state?.env ?? env}` }] }; + } + ); + + return server; +} + +// Host with the per-request HTTP entry on its default posture (2026-07-28 +// served per request; 2025-era traffic served stateless from the same +// factory). +const handler = createMcpHandler(() => buildServer()); +const port = Number(process.env.PORT ?? '3000'); + +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`multi-round-trip example server listening on http://localhost:${port}/`); +}); diff --git a/packages/core/src/shared/clientCapabilityRequirements.ts b/packages/core/src/shared/clientCapabilityRequirements.ts index 19c5b1a310..4f8fa65619 100644 --- a/packages/core/src/shared/clientCapabilityRequirements.ts +++ b/packages/core/src/shared/clientCapabilityRequirements.ts @@ -53,6 +53,60 @@ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** + * Whether a required nested member counts as declared even though it is not + * spelled out: a bare `elicitation: {}` declaration (no mode sub-capability at + * all) is read as form support — the pre-mode (2025) meaning of a bare + * declaration — so an `elicitation.form` requirement treats it as satisfied. + * Declaring any mode explicitly (for example `elicitation: { url: {} }`) + * removes the implication. + */ +function isImpliedCapabilityMember(capability: string, member: string, declaredValue: Record): boolean { + return capability === 'elicitation' && member === 'form' && declaredValue['form'] === undefined && declaredValue['url'] === undefined; +} + +/** + * The client capabilities an embedded multi-round-trip input request requires + * (call site 2 — the outbound input-request leg): a server MUST NOT send an + * `inputRequests` kind the request's declared client capabilities do not + * cover. Returns `undefined` for entries whose method is not one of the + * embedded input-request kinds (those are a server bug handled separately, + * not a capability question). + * + * The requirement is mode-aware where the capability is: URL-mode elicitation + * requires `elicitation.url`; form-mode (or mode-omitted) elicitation requires + * `elicitation.form` (modes are sub-capabilities, and a server MUST NOT send a + * mode the client did not declare); sampling with `tools`/`toolChoice` + * requires `sampling.tools`. A bare `elicitation: {}` declaration satisfies + * the form requirement — see {@linkcode missingClientCapabilities}. + */ +export function requiredClientCapabilitiesForInputRequest(entry: { + method: string; + params?: Record; +}): ClientCapabilities | undefined { + switch (entry.method) { + case 'elicitation/create': { + if (entry.params?.['mode'] === 'url') { + return { elicitation: { url: {} } }; + } + return { elicitation: { form: {} } }; + } + case 'sampling/createMessage': { + const params = entry.params; + if (params !== undefined && (params['tools'] !== undefined || params['toolChoice'] !== undefined)) { + return { sampling: { tools: {} } }; + } + return { sampling: {} }; + } + case 'roots/list': { + return { roots: {} }; + } + default: { + return undefined; + } + } +} + /** * Computes the subset of `required` client capabilities the client did not * declare. Returns `undefined` when every required capability is declared; @@ -63,7 +117,10 @@ function isPlainObject(value: unknown): value is Record { * A capability counts as declared when its top-level key is present on the * declared capabilities; when the requirement names nested members (for * example `elicitation: { url: {} }`), each named member must also be present - * under the declared capability. An absent or empty `declared` value means + * under the declared capability. One lenient reading applies: a bare + * `elicitation: {}` declaration (no mode sub-capability at all) counts as + * declaring `elicitation.form` — the pre-mode (2025) meaning of a bare + * declaration. An absent or empty `declared` value means * nothing is declared — every required capability is missing (the structural * clean-refusal posture for sessions with no per-request capability view). */ @@ -85,7 +142,11 @@ export function missingClientCapabilities( if (isPlainObject(requirement) && isPlainObject(declaredValue)) { const missingMembers: Record = {}; for (const [member, memberRequirement] of Object.entries(requirement)) { - if (memberRequirement !== undefined && declaredValue[member] === undefined) { + if ( + memberRequirement !== undefined && + declaredValue[member] === undefined && + !isImpliedCapabilityMember(capability, member, declaredValue) + ) { missingMembers[member] = memberRequirement; } } diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index b90efbcb91..88b62e06cf 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -27,6 +27,12 @@ * A notification that does carry a claim is treated body-primary like a * request, and a malformed claim is rejected the same way a request's * malformed claim is — never silently resolved against the header. + * The notification-POST header cross-checks here are an SDK-defensive + * posture, not a spec requirement: the spec leaves header rules for posted + * notifications undefined (core client notifications do not occur over + * Streamable HTTP); applying the request rules symmetrically is what an + * ecosystem custom-notification POST expects, and the −32001 cells stay + * passing for them. * - `GET`/`DELETE` (and any other non-`POST` method) are body-less 2025-era * session operations: the modern era is `POST`-only, so they are routed to * legacy serving when it is configured and rejected otherwise. diff --git a/packages/core/test/shared/clientCapabilityRequirements.test.ts b/packages/core/test/shared/clientCapabilityRequirements.test.ts index 9b4c607586..80758d3916 100644 --- a/packages/core/test/shared/clientCapabilityRequirements.test.ts +++ b/packages/core/test/shared/clientCapabilityRequirements.test.ts @@ -12,6 +12,7 @@ import { describe, expect, test } from 'vitest'; import { missingClientCapabilities, REQUIRED_CLIENT_CAPABILITIES_BY_METHOD, + requiredClientCapabilitiesForInputRequest, requiredClientCapabilitiesForRequest } from '../../src/shared/clientCapabilityRequirements.js'; import { rev2026RequestMethods } from '../../src/wire/rev2026-07-28/registry.js'; @@ -39,6 +40,43 @@ describe('missingClientCapabilities', () => { test('an empty requirement object is always satisfied', () => { expect(missingClientCapabilities({}, undefined)).toBeUndefined(); }); + + test('a bare elicitation declaration implies form support (the pre-mode meaning), but not other modes', () => { + // Bare `elicitation: {}` satisfies the form requirement… + expect(missingClientCapabilities({ elicitation: { form: {} } }, { elicitation: {} })).toBeUndefined(); + // …but an explicit mode declaration removes the implication… + expect(missingClientCapabilities({ elicitation: { form: {} } }, { elicitation: { url: {} } })).toEqual({ + elicitation: { form: {} } + }); + // …and the bare declaration never implies URL support. + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: {} })).toEqual({ elicitation: { url: {} } }); + }); +}); + +describe('requiredClientCapabilitiesForInputRequest', () => { + test('elicitation requirements are mode-aware sub-capabilities', () => { + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { mode: 'url' } })).toEqual({ + elicitation: { url: {} } + }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { mode: 'form' } })).toEqual({ + elicitation: { form: {} } + }); + // Mode omitted defaults to form. + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { message: 'Name?' } })).toEqual({ + elicitation: { form: {} } + }); + }); + + test('sampling requires sampling.tools only when tools/toolChoice are present; roots requires roots; other methods are not input requests', () => { + expect(requiredClientCapabilitiesForInputRequest({ method: 'sampling/createMessage', params: { maxTokens: 5 } })).toEqual({ + sampling: {} + }); + expect( + requiredClientCapabilitiesForInputRequest({ method: 'sampling/createMessage', params: { maxTokens: 5, tools: [] } }) + ).toEqual({ sampling: { tools: {} } }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'roots/list' })).toEqual({ roots: {} }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'tools/call' })).toBeUndefined(); + }); }); describe('requiredClientCapabilitiesForRequest', () => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4420e20712..f3f1a885d0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -75,5 +75,11 @@ export { classifyInboundRequest } from '@modelcontextprotocol/core'; // the registerResource cacheHint option). export type { CacheHint, CacheScope } from '@modelcontextprotocol/core'; +// Multi round-trip requests (protocol revision 2026-07-28): the authoring +// helpers a handler uses to request additional client input by returning an +// input-required result instead of sending a server→client request. +export type { InputRequiredSpec } from '@modelcontextprotocol/core'; +export { acceptedContent, inputRequired } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 6fcdd9a327..33f6408e9a 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -7,6 +7,7 @@ import type { CompleteResult, GetPromptResult, Implementation, + InputRequiredResult, ListPromptsResult, ListResourcesResult, ListToolsResult, @@ -30,6 +31,7 @@ import { assertCompleteRequestResourceTemplate, assertValidCacheHint, attachCacheHintFallback, + isInputRequiredResult, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -159,7 +161,7 @@ export class McpServer { }) ); - this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { + this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { const tool = this._registeredTools[request.params.name]; if (!tool) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); @@ -231,11 +233,17 @@ export class McpServer { /** * Validates tool output against the tool's output schema. */ - private async validateToolOutput(tool: RegisteredTool, result: CallToolResult, toolName: string): Promise { + private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | InputRequiredResult, toolName: string): Promise { if (!tool.outputSchema) { return; } + // An input-required result is not the tool's final output: structured + // content is only required (and validated) on the completing result. + if (isInputRequiredResult(result)) { + return; + } + if (result.isError) { return; } @@ -260,7 +268,11 @@ export class McpServer { /** * Executes a tool handler. */ - private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { + private async executeToolHandler( + tool: RegisteredTool, + args: unknown, + ctx: ServerContext + ): Promise { // Executor encapsulates handler invocation with proper types return tool.executor(args, ctx); } @@ -469,7 +481,7 @@ export class McpServer { }) ); - this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { + this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { const prompt = this._registeredPrompts[request.params.name]; if (!prompt) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); @@ -1079,13 +1091,19 @@ export type InferRawShape = z.infer>; /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ export type LegacyToolCallback = Args extends ZodRawShape - ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise - : (ctx: ServerContext) => CallToolResult | Promise; + ? ( + args: InferRawShape, + ctx: ServerContext + ) => CallToolResult | InputRequiredResult | Promise + : (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise; /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ export type LegacyPromptCallback = Args extends ZodRawShape - ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; + ? ( + args: InferRawShape, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise + : (ctx: ServerContext) => GetPromptResult | InputRequiredResult | Promise; export type BaseToolCallback< SendResultT extends Result, @@ -1099,7 +1117,7 @@ export type BaseToolCallback< * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. */ export type ToolCallback = BaseToolCallback< - CallToolResult, + CallToolResult | InputRequiredResult, ServerContext, Args >; @@ -1112,7 +1130,7 @@ export type AnyToolHandler Promise; +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; export type RegisteredTool = { title?: string; @@ -1157,7 +1175,9 @@ function createToolExecutor( } // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - const callback = handler as (ctx: ServerContext) => CallToolResult | Promise; + const callback = handler as ( + ctx: ServerContext + ) => CallToolResult | InputRequiredResult | Promise; return async (_args, ctx) => callback(ctx); } @@ -1179,7 +1199,10 @@ export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult /** * Callback to read a resource at a given URI. */ -export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; +export type ReadResourceCallback = ( + uri: URL, + ctx: ServerContext +) => ReadResourceResult | InputRequiredResult | Promise; export type RegisteredResource = { name: string; @@ -1209,7 +1232,7 @@ export type ReadResourceTemplateCallback = ( uri: URL, variables: Variables, ctx: ServerContext -) => ReadResourceResult | Promise; +) => ReadResourceResult | InputRequiredResult | Promise; export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; @@ -1233,16 +1256,22 @@ export type RegisteredResourceTemplate = { }; export type PromptCallback = Args extends StandardSchemaWithJSON - ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; + ? ( + args: StandardSchemaWithJSON.InferOutput, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise + : (ctx: ServerContext) => GetPromptResult | InputRequiredResult | Promise; /** * Internal handler type that encapsulates parsing and callback invocation. * This allows type-safe handling without runtime type assertions. */ -type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; +type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; -type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; +type ToolCallbackInternal = ( + args: unknown, + ctx: ServerContext +) => CallToolResult | InputRequiredResult | Promise; export type RegisteredPrompt = { title?: string; @@ -1276,7 +1305,10 @@ function createPromptHandler( callback: PromptCallback ): PromptHandler { if (argsSchema) { - const typedCallback = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; + const typedCallback = callback as ( + args: unknown, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise; return async (args, ctx) => { const parseResult = await validateStandardSchema(argsSchema, args); @@ -1286,7 +1318,9 @@ function createPromptHandler( return typedCallback(parseResult.data, ctx); }; } else { - const typedCallback = callback as (ctx: ServerContext) => GetPromptResult | Promise; + const typedCallback = callback as ( + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise; return async (_args, ctx) => { return typedCallback(ctx); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 5e3633a6ff..905d6cae97 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -39,23 +39,36 @@ import type { import { assertValidCacheHint, attachCacheHintFallback, + CLIENT_CAPABILITIES_META_KEY, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + isInputRequiredResult, + isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, LoggingLevelSchema, mergeCapabilities, + missingClientCapabilities, + MissingRequiredClientCapabilityError, modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, + requiredClientCapabilitiesForInputRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +/** + * The request methods whose 2026-07-28 result vocabulary includes + * `input_required` (the multi round-trip methods). Returning an + * input-required result from any other handler is a server bug. + */ +const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -103,6 +116,33 @@ export type ServerOptions = ProtocolOptions & { * affected. Invalid values throw a `RangeError` at construction time. */ cacheHints?: Partial>; + + /** + * Multi-round-trip `requestState` integrity hook (protocol revision + * 2026-07-28). + */ + requestState?: { + /** + * Called on every re-entered multi-round-trip request that carries a + * `requestState` (i.e. whenever `ctx.mcpReq.requestState` is present), + * BEFORE the handler runs. Throw or reject to refuse the request: the + * seam answers with a wire-level `-32602` Invalid Params error whose + * message is frozen to `"Invalid or expired requestState"` and whose + * `data.reason` is `'invalid_request_state'` — the thrown reason is + * surfaced via the server's `onerror` callback only and never reaches + * the wire. + * + * This is the place to put HMAC or AEAD verification of + * `requestState`. The spec MUST for integrity-protecting state that + * influences authorization, resource access, or business logic is on + * the server author (basic/patterns/mrtr, server requirements 4–5); + * the SDK provides NO default verification. Leaving this option + * unconfigured keeps today's behavior — `ctx.mcpReq.requestState` is + * passed through raw and MUST be treated as attacker-controlled + * input. + */ + verify?: (state: string, ctx: ServerContext) => void | Promise; + }; }; /* @@ -186,6 +226,7 @@ export class Server extends Protocol { private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; private _cacheHints?: ServerOptions['cacheHints']; + private _requestStateVerify?: (state: string, ctx: ServerContext) => void | Promise; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -203,6 +244,7 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._requestStateVerify = options?.requestState?.verify; // Configured cache hints fail loudly at construction time (before any // handler registration consults them). @@ -307,9 +349,15 @@ export class Server extends Protocol { /** * Enforces server-side validation for `tools/call` results regardless of how the - * handler was registered, and attaches the configured per-operation cache hint + * handler was registered, attaches the configured per-operation cache hint * (when one exists) so the 2026-07-28 encode seam can fill `ttlMs`/`cacheScope` - * for results that do not provide their own. The hint rides a symbol-keyed + * for results that do not provide their own, and owns the multi-round-trip + * seam: on the methods whose 2026-07-28 result vocabulary includes + * `input_required` (`tools/call`, `prompts/get`, `resources/read`) an + * input-required return skips result-schema validation and is checked + * against the served era, the at-least-one rule, and the request's own + * declared client capabilities; on every other method an input-required + * return is a server bug and fails loudly. The hint rides a symbol-keyed * property that is never serialized, so 2025-era responses are unaffected. */ protected override _wrapHandler( @@ -318,10 +366,41 @@ export class Server extends Protocol { ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { if (method !== 'tools/call') { const cacheHint = (this._cacheHints as Record | undefined)?.[method]; - if (cacheHint === undefined) { - return handler; + const isInputRequiredCapable = INPUT_REQUIRED_CAPABLE_METHODS.has(method); + if (cacheHint === undefined && !isInputRequiredCapable) { + // Server-bug guard: an input-required return from a method + // whose result vocabulary does not include it is never + // mis-typed onto the wire. + return async (request, ctx) => { + const result = await handler(request, ctx); + if (isInputRequiredResult(result)) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but only tools/call, prompts/get and ` + + `resources/read support input_required (protocol revision 2026-07-28)` + ); + } + return result; + }; } - return async (request, ctx) => attachCacheHintFallback(await handler(request, ctx), cacheHint); + return async (request, ctx) => { + const result = isInputRequiredCapable + ? await this._invokeInputRequiredCapableHandler(method, handler, request, ctx) + : await handler(request, ctx); + if (isInputRequiredResult(result)) { + if (!isInputRequiredCapable) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but only tools/call, prompts/get and ` + + `resources/read support input_required (protocol revision 2026-07-28)` + ); + } + // Never cache-stamped (the encode contract skips + // non-complete results); the hint is not attached. + return result; + } + return cacheHint === undefined ? result : attachCacheHintFallback(result, cacheHint); + }; } return async (request, ctx) => { // Era-exact validation: the request and result schemas come from @@ -343,7 +422,13 @@ export class Server extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); } - const result = await handler(request, ctx); + const result = await this._invokeInputRequiredCapableHandler('tools/call', handler, request, ctx); + if (isInputRequiredResult(result)) { + // Already checked by the seam; the CallToolResult schema does + // not apply to it (no widening — InputRequiredResult travels + // alongside). + return result; + } const validationResult = parseSchema(callToolResultSchema, result); if (!validationResult.success) { @@ -356,6 +441,179 @@ export class Server extends Protocol { }; } + /** + * Whether this instance is bound to a 2026-07-28-or-later protocol + * revision. Era is instance state — a serving entry (`createMcpHandler`, + * `serveStdio`) marks the instance modern at construction; a 2025-era + * `initialize` handshake binds it legacy. The multi-round-trip seam reads + * this directly: there is no per-request era consult. + */ + private _servedModernEra(): boolean { + return this._negotiatedProtocolVersion !== undefined && isModernProtocolVersion(this._negotiatedProtocolVersion); + } + + /** + * Invokes a handler for one of the multi-round-trip methods and applies + * the input-required seam: + * + * - a `UrlElicitationRequiredError` (or any 2025-style server→client + * request idiom) escaping the handler on a request served on the + * 2026-07-28 era fails LOUDLY with a clear steer to + * `inputRequired.elicitUrl(...)` — the `-32042` error never reaches the + * 2026-07-28 wire and the throw is not silently converted. Requests + * served on the 2025 era keep today's `-32042` behavior byte-exact (the + * error is rethrown unchanged). + * - an input-required RETURN is only legal toward the 2026-07-28 era; it + * must satisfy the at-least-one rule (`inputRequests` or + * `requestState`), and every embedded request must be covered by the + * capabilities the client declared on this request's envelope + * (violations answer with the typed `-32003` error). + */ + private async _invokeInputRequiredCapableHandler( + method: string, + handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise, + request: JSONRPCRequest, + ctx: ServerContext + ): Promise { + const servedModern = this._servedModernEra(); + + // The configured requestState.verify hook runs above the handler (and + // therefore above the McpServer tools/call funnel), so a rejection + // reaches the wire as a real JSON-RPC error rather than an `isError` + // tool result. The wire message is FROZEN — the thrown reason is + // surfaced via `onerror` only. A non-string `requestState` value (the + // wire field is `string | undefined`) is treated as invalid regardless + // of whether a hook is configured, so a malformed value cannot bypass + // verification. + const rawRequestState = ctx.mcpReq.requestState as unknown; + if (rawRequestState !== undefined && typeof rawRequestState !== 'string') { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { + reason: 'invalid_request_state' + }); + } + if (this._requestStateVerify !== undefined && typeof rawRequestState === 'string') { + try { + await this._requestStateVerify(rawRequestState, ctx); + } catch (error) { + this.onerror?.( + new Error(`requestState verification rejected ${method}: ${error instanceof Error ? error.message : String(error)}`) + ); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { + reason: 'invalid_request_state' + }); + } + } + + let result: Result; + try { + result = await handler(request, ctx); + } catch (error) { + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { + if (!servedModern) { + // 2025-era behavior is frozen: the error reaches the wire + // exactly as it does today. + throw error; + } + // 2026-era requests do not carry the -32042 surface. A + // 2025-style throw fails loudly with a clear steer rather than + // being converted: the handler should return + // inputRequired.elicitUrl(...) instead. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `URL elicitation cannot be signalled by throwing UrlElicitationRequiredError on protocol revision ` + + `${this._negotiatedProtocolVersion}: return inputRequired({ inputRequests: { …: inputRequired.elicitUrl(...) } }) ` + + `from the handler instead. The urlElicitationRequired error (-32042) of earlier revisions is not ` + + `available on this revision.` + ); + } + throw error; + } + + if (!isInputRequiredResult(result)) { + return result; + } + + if (!servedModern) { + // The 2025-era wire has no input_required vocabulary: fail loudly + // rather than putting a mis-typed result on the wire. A handler + // that serves both eras branches on the served era and uses the + // push-style APIs toward 2025-era requests. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but this request is served on protocol revision ` + + `${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary` + ); + } + + // F7 at-least-one re-check (hand-built results are legal; the rule is + // re-checked at the seam). + const inputRequests = result.inputRequests as Record | null | undefined; + const hasInputRequests = inputRequests != null && Object.keys(inputRequests).length > 0; + const hasRequestState = typeof result.requestState === 'string'; + if (!hasInputRequests && !hasRequestState) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result with neither inputRequests nor requestState ` + + `(every InputRequiredResult must include at least one of the two)` + ); + } + + // Per-embedded-request capability check against the capabilities the + // client declared on THIS request's envelope (-32003 on violation). + if (hasInputRequests) { + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; + for (const [key, entry] of Object.entries(inputRequests)) { + if (entry === null || typeof entry !== 'object' || typeof (entry as { method?: unknown }).method !== 'string') { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an invalid input request '${key}': each inputRequests entry must be an ` + + `embedded elicitation/create, sampling/createMessage, or roots/list request` + ); + } + const embedded = entry as { method: string; params?: Record }; + const required = requiredClientCapabilitiesForInputRequest(embedded); + if (required === undefined) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input request '${key}' of kind '${embedded.method}', which is not an ` + + `embedded request the 2026-07-28 revision defines` + ); + } + const missing = missingClientCapabilities(required, declared); + if (missing !== undefined) { + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: missing }, + `Cannot request input '${key}' (${embedded.method}): the request's client capabilities do not declare ` + + `the required capability` + ); + } + } + } + + return result; + } + + /** + * Guard for the push-style server→client request APIs ({@linkcode createMessage}, + * {@linkcode elicitInput}, {@linkcode listRoots}, {@linkcode ping}) on a + * modern-era instance: the 2026-07-28 revision has no server→client request + * channel, so the call fails before any wire traffic with a typed error + * whose message steers to `inputRequired(...)`. The base era gate would + * also reject it; this guard runs first to carry the steer. + */ + private _assertPushApiInServedEra(method: string): void { + if (this._servedModernEra()) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Server-to-client requests are not available on protocol revision ${this._negotiatedProtocolVersion}: ` + + `'${method}' cannot be sent while serving a request on that revision. ` + + `Return inputRequired({ ... }) from the handler instead — the client fulfils the embedded ` + + `requests and retries the original request (multi round-trip requests).`, + { method, era: '2026-07-28' } + ); + } + } + protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method) { case 'sampling/createMessage': { @@ -592,6 +850,7 @@ export class Server extends Protocol { } async ping(): Promise { + this._assertPushApiInServedEra('ping'); return this.request({ method: 'ping' }); } @@ -633,6 +892,7 @@ export class Server extends Protocol { params: CreateMessageRequest['params'], options?: RequestOptions ): Promise { + this._assertPushApiInServedEra('sampling/createMessage'); // Capability check - only required when tools/toolChoice are provided if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); @@ -702,6 +962,7 @@ export class Server extends Protocol { * @returns The result of the elicitation request. */ async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + this._assertPushApiInServedEra('elicitation/create'); const mode = (params.mode ?? 'form') as 'form' | 'url'; switch (mode) { @@ -792,6 +1053,7 @@ export class Server extends Protocol { * Migrate to passing paths via tool parameters, resource URIs, or configuration. */ async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { + this._assertPushApiInServedEra('roots/list'); return this.request({ method: 'roots/list', params }, options); } diff --git a/packages/server/test/server/inputRequired.test.ts b/packages/server/test/server/inputRequired.test.ts new file mode 100644 index 0000000000..9d0e1d26c4 --- /dev/null +++ b/packages/server/test/server/inputRequired.test.ts @@ -0,0 +1,455 @@ +/** + * Server-side multi-round-trip seam (M4.1): + * + * - a handler for tools/call, prompts/get, or resources/read returns an + * input-required result on a 2026-07-28-classified request and it reaches + * the wire as `resultType: 'input_required'` (validateToolOutput and the + * tools/call result schema are skipped for it; cache fields are never + * stamped on it); + * - the guards: at-least-one re-check for hand-built results, the per-embedded + * -request `-32003` capability check against the request's OWN envelope + * capabilities, the server-bug guard (non-multi-round-trip methods, and any + * method on a 2025-era request, never put a mis-typed result on the wire); + * - a UrlElicitationRequiredError escaping a handler on the modern era fails + * LOUDLY (clear steer to inputRequired.elicitUrl(...), never converted) — + * `-32042` never reaches the 2026-07-28 wire — while 2025-era traffic keeps + * today's `-32042` behavior; + * - the push-style APIs loud-fail on 2026-era requests with the + * `inputRequired(...)` steer surfaced through the tools/call catch-all, with + * zero wire traffic emitted for the attempted server→client request; + * - the write-once re-entry: a retried request's `inputResponses` reach the + * handler via ctx and the final result passes full validation. + */ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse +} from '@modelcontextprotocol/core'; +import { + acceptedContent, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + inputRequired, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion, + UrlElicitationRequiredError +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; +import type { ServerOptions } from '../../src/server/server.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; + +const envelope = (clientCapabilities: Record = {}) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'mrtr-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities +}); + +async function wire(server: McpServer | Server, options?: { era?: 'modern' | 'legacy' }) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + // Era is instance state: a serving entry binds the instance modern; for + // these unit tests we bind directly via the package-internal setter (the + // way createMcpHandler/serveStdio do). + if (options?.era === 'modern') { + setNegotiatedProtocolVersion(server instanceof Server ? server : server.server, MODERN); + } + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + return { request, notify, inbound, close: () => server.close() }; +} + +const modernToolCall = ( + id: number, + name: string, + args: Record = {}, + options?: { clientCapabilities?: Record; extraParams?: Record } +): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + _meta: envelope(options?.clientCapabilities ?? {}), + name, + arguments: args, + ...options?.extraParams + } +}); + +const legacyInitialize = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +function resultOf(message: JSONRPCMessage): Record { + return (message as JSONRPCResultResponse).result as unknown as Record; +} + +function errorOf(message: JSONRPCMessage): { code: number; message: string; data?: unknown } { + return (message as JSONRPCErrorResponse).error; +} + +describe('input-required returns on the 2026-07-28 era', () => { + it('a write-once tool returning inputRequired() reaches the wire as input_required and completes on the retry', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool( + 'deploy', + { inputSchema: z.object({ env: z.string() }), outputSchema: z.object({ deployed: z.boolean() }) }, + async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } } } + }) + }, + requestState: 'opaque-deploy-state' + }); + } + return { content: [{ type: 'text', text: 'deployed' }], structuredContent: { deployed: true } }; + } + ); + const { request, close } = await wire(server, { era: 'modern' }); + + // First leg: input_required goes out, with no cache stamping and the + // structured-content requirement skipped. + const first = resultOf( + await request(modernToolCall(1, 'deploy', { env: 'prod' }, { clientCapabilities: { elicitation: { form: {} } } })) + ); + expect(first.resultType).toBe('input_required'); + expect(first.requestState).toBe('opaque-deploy-state'); + expect(first.inputRequests).toMatchObject({ confirm: { method: 'elicitation/create' } }); + expect(first.ttlMs).toBeUndefined(); + expect(first.cacheScope).toBeUndefined(); + expect(first.content).toBeUndefined(); + + // Retry leg (fresh id, responses + byte-exact echo): full validation + // applies to the completing result, which is stamped 'complete'. + const second = resultOf( + await request( + modernToolCall( + 2, + 'deploy', + { env: 'prod' }, + { + clientCapabilities: { elicitation: { form: {} } }, + extraParams: { + inputResponses: { confirm: { action: 'accept', content: { confirm: true } } }, + requestState: 'opaque-deploy-state' + } + } + ) + ) + ); + expect(second.resultType).toBe('complete'); + expect(second.structuredContent).toEqual({ deployed: true }); + + await close(); + }); + + it('prompts/get and resources/read handlers can return input_required (no catch-all rewraps it)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { prompts: {}, resources: {} } }); + server.registerPrompt('wizard', { argsSchema: z.object({}) }, async () => inputRequired({ requestState: 'prompt-state' })); + server.registerResource('secret', 'file:///secret.txt', {}, async () => inputRequired({ requestState: 'resource-state' })); + const { request, close } = await wire(server, { era: 'modern' }); + + const promptResult = resultOf( + await request({ + jsonrpc: '2.0', + id: 1, + method: 'prompts/get', + params: { _meta: envelope(), name: 'wizard', arguments: {} } + }) + ); + expect(promptResult.resultType).toBe('input_required'); + expect(promptResult.requestState).toBe('prompt-state'); + + const resourceResult = resultOf( + await request({ + jsonrpc: '2.0', + id: 2, + method: 'resources/read', + params: { _meta: envelope(), uri: 'file:///secret.txt' } + }) + ); + expect(resourceResult.resultType).toBe('input_required'); + expect(resourceResult.requestState).toBe('resource-state'); + expect(resourceResult.ttlMs).toBeUndefined(); + + await close(); + }); +}); + +describe('guards', () => { + it('hand-built results missing both inputRequests and requestState fail loudly (at-least-one re-check)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('broken', { inputSchema: z.object({}) }, async () => ({ resultType: 'input_required' }) as never); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'broken')); + expect(errorOf(answer).code).toBe(-32_603); + expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"'); + + await close(); + }); + + it('checks every embedded request against the capabilities the request itself declared (-32003 on violation)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('ask', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } }) + } + }) + ); + server.registerTool('open-url', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ message: 'Sign in', url: 'https://example.com' }) + } + }) + ); + const { request, close } = await wire(server, { era: 'modern' }); + + // No elicitation capability declared on the request → -32003 naming + // the form sub-capability the embedded form-mode elicitation needs. + const noCapability = await request(modernToolCall(1, 'ask', {}, { clientCapabilities: {} })); + expect(errorOf(noCapability).code).toBe(-32_003); + expect(errorOf(noCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } }); + + // Form-mode capability declared → the same tool is served. + const withCapability = await request(modernToolCall(2, 'ask', {}, { clientCapabilities: { elicitation: { form: {} } } })); + expect(resultOf(withCapability).resultType).toBe('input_required'); + + // URL-mode embedded request requires elicitation.url specifically. + const urlWithoutUrlCapability = await request( + modernToolCall(3, 'open-url', {}, { clientCapabilities: { elicitation: { form: {} } } }) + ); + expect(errorOf(urlWithoutUrlCapability).code).toBe(-32_003); + expect(errorOf(urlWithoutUrlCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { url: {} } } }); + + // Form-mode embedded request toward a URL-only client → -32003: modes + // are sub-capabilities and the server must not send an undeclared one. + const formTowardUrlOnly = await request(modernToolCall(4, 'ask', {}, { clientCapabilities: { elicitation: { url: {} } } })); + expect(errorOf(formTowardUrlOnly).code).toBe(-32_003); + expect(errorOf(formTowardUrlOnly).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } }); + + // A bare `elicitation: {}` declaration is read as form support (the + // pre-mode meaning of a bare declaration) → served. + const bareElicitation = await request(modernToolCall(5, 'ask', {}, { clientCapabilities: { elicitation: {} } })); + expect(resultOf(bareElicitation).resultType).toBe('input_required'); + + await close(); + }); + + it('a 2025-era request never sees an input_required result: the server fails loudly instead (server-bug guard)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('deploy', { inputSchema: z.object({}) }, async () => inputRequired({ requestState: 'state' })); + const { request, close } = await wire(server); + + await request(legacyInitialize(1)); + const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'deploy', arguments: {} } }); + expect(errorOf(answer).code).toBe(-32_603); + // The mis-typed result never reaches the wire: the answer is an error, not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + + await close(); + }); + + it('non-multi-round-trip methods can never emit input_required (server-bug guard)', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { completions: {} } }); + server.setRequestHandler('completion/complete', async () => ({ resultType: 'input_required', requestState: 's' }) as never); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request({ + jsonrpc: '2.0', + id: 1, + method: 'completion/complete', + params: { + _meta: envelope(), + ref: { type: 'ref/prompt', name: 'p' }, + argument: { name: 'a', value: 'v' } + } + }); + expect(errorOf(answer).code).toBe(-32_603); + // The mis-typed result never reaches the wire: the answer is an error, not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + + await close(); + }); +}); + +describe('UrlElicitationRequiredError (the 2025-era -32042 idiom)', () => { + const URL_PARAMS = { mode: 'url' as const, message: 'Sign in to continue', elicitationId: 'elicit-7', url: 'https://example.com/auth' }; + + function buildUrlThrowingServer() { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + return server; + } + + it('fails LOUDLY on a 2026-era request with a clear inputRequired.elicitUrl(...) steer — never converted, never -32042', async () => { + const { request, close } = await wire(buildUrlThrowingServer(), { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'protected', {}, { clientCapabilities: { elicitation: { url: {} } } })); + expect(errorOf(answer).code).toBe(-32_603); + expect(errorOf(answer).message).toContain('inputRequired.elicitUrl'); + expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"'); + // The -32042 error code never appears on the 2026-07-28 wire (the steer + // text mentions it for migration; the wire error code is InternalError). + expect(JSON.stringify(answer)).not.toContain('"code":-32042'); + + await close(); + }); + + it('keeps the exact -32042 behavior for 2025-era traffic', async () => { + const { request, close } = await wire(buildUrlThrowingServer()); + + await request(legacyInitialize(1)); + const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'protected', arguments: {} } }); + const error = errorOf(answer); + expect(error.code).toBe(-32_042); + expect(error.data).toEqual({ elicitations: [URL_PARAMS] }); + + await close(); + }); +}); + +describe('requestState.verify hook', () => { + function buildServer(options?: ServerOptions) { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options }); + const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })); + server.registerTool('deploy', { inputSchema: z.object({}) }, handler); + return { server, handler }; + } + + const reentry = (id: number, requestState?: string) => + modernToolCall(id, 'deploy', {}, { extraParams: requestState === undefined ? {} : { requestState } }); + + it('is called with the echoed state and the handler context, before the handler', async () => { + const seen: Array<{ state: string; method: string }> = []; + const { server, handler } = buildServer({ + requestState: { verify: (state, ctx) => void seen.push({ state, method: ctx.mcpReq.method }) } + }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1, 'signed-state'))); + expect(seen).toEqual([{ state: 'signed-state', method: 'tools/call' }]); + expect(handler).toHaveBeenCalledOnce(); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); + + it('a throw becomes the frozen -32602 wire error (not an isError tool result); the reason goes to onerror only', async () => { + const { server, handler } = buildServer({ + requestState: { + verify: () => { + throw new Error('HMAC mismatch — granular reason'); + } + } + }); + const onerror = vi.fn(); + server.server.onerror = onerror; + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request(reentry(1, 'tampered')); + // Real JSON-RPC error (above the tools/call funnel), not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + const error = errorOf(answer); + expect(error.code).toBe(-32_602); + expect(error.message).toBe('Invalid or expired requestState'); + expect(error.data).toEqual({ reason: 'invalid_request_state' }); + // The granular reason never reaches the wire — onerror only. + expect(JSON.stringify(answer)).not.toContain('HMAC mismatch'); + expect(onerror).toHaveBeenCalledOnce(); + expect(String(onerror.mock.calls[0]?.[0])).toContain('HMAC mismatch'); + expect(handler).not.toHaveBeenCalled(); + + await close(); + }); + + it('is not called when the request carries no requestState', async () => { + const verify = vi.fn(); + const { server, handler } = buildServer({ requestState: { verify } }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1))); + expect(verify).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledOnce(); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); + + it('not configured → today’s behavior (raw passthrough; the handler reads the state itself)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + let seen: string | undefined; + server.registerTool('deploy', { inputSchema: z.object({}) }, async (_args, ctx) => { + seen = ctx.mcpReq.requestState; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1, 'raw-state'))); + expect(seen).toBe('raw-state'); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); +}); + +describe('push-style APIs on 2026-era requests', () => { + it('ctx.mcpReq.elicitInput rejects before any wire traffic and the catch-all surfaces the inputRequired() steer as isError', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: JSON.stringify(answer) }] }; + }); + const { request, inbound, close } = await wire(server, { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'legacy-style', {}, { clientCapabilities: { elicitation: { form: {} } } })); + const result = resultOf(answer); + expect(result.isError).toBe(true); + const text = JSON.stringify(result.content); + expect(text).toContain('inputRequired('); + + // Zero wire traffic for the attempted server→client request: the only + // message the peer ever received is the tools/call response itself. + expect(inbound.filter(message => (message as { method?: string }).method === 'elicitation/create')).toHaveLength(0); + expect(inbound).toHaveLength(1); + + await close(); + }); +}); diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index c79c27b3c4..ce4a19e93e 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -162,11 +162,16 @@ export async function wire( return response; }; let clientTx = new StreamableHTTPClientTransport(url, { fetch }); + // entryModern is the era-fixed 2026-07-28 arm: it is the only arm + // whose wire may legitimately carry input_required results, so it + // opts the sniffer into accepting them (other arms stay strict). + let armSniff: WireOptions = sniff; if (transport === 'entryModern') { pinModernNegotiation(client); clientTx = attachModernEnvelope(clientTx); + armSniff = { allowInputRequiredResults: true, ...sniff }; } - await client.connect(sniffTransport(clientTx, 'client', sniff)); + await client.connect(sniffTransport(clientTx, 'client', armSniff)); if (transport === 'entryModern') assertModernNegotiation(client); return { fetch, diff --git a/test/e2e/helpers/wire-sniffer.test.ts b/test/e2e/helpers/wire-sniffer.test.ts index 73ea7222e8..ca072217a9 100644 --- a/test/e2e/helpers/wire-sniffer.test.ts +++ b/test/e2e/helpers/wire-sniffer.test.ts @@ -61,6 +61,19 @@ describe('assertWireMessage', () => { expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow(); }); + it('rejects an input_required server result unless the cell opted in (modern-era arms only)', () => { + const inputRequired = resp({ + resultType: 'input_required', + inputRequests: { ask: { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } } + }); + // Default (legacy-era cells): input_required is not legal wire vocabulary. + expect(() => assertWireMessage(inputRequired, 'server')).toThrow(/invalid message/); + // Modern-era arms opt in explicitly. + expect(() => assertWireMessage(inputRequired, 'server', { allowInputRequiredResults: true })).not.toThrow(); + // The opt-in never applies to client-sent results. + expect(() => assertWireMessage(inputRequired, 'client', { allowInputRequiredResults: true })).toThrow(/invalid message/); + }); + it('accepts a JSON-RPC error response for either party', () => { const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32_601, message: 'Method not found' } }; expect(() => assertWireMessage(err, 'server')).not.toThrow(); diff --git a/test/e2e/helpers/wire-sniffer.ts b/test/e2e/helpers/wire-sniffer.ts index 89663214ce..3a5dc2fc3f 100644 --- a/test/e2e/helpers/wire-sniffer.ts +++ b/test/e2e/helpers/wire-sniffer.ts @@ -8,6 +8,7 @@ import { } from '@modelcontextprotocol/core'; import type { Transport } from '@modelcontextprotocol/server'; import { + isInputRequiredResult, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, @@ -22,6 +23,13 @@ export interface SnifferOptions { allowCustomMethods?: boolean; /** `false` → envelope check only (for tests that deliberately send malformed messages). */ strictValidation?: boolean; + /** + * Permit `input_required` results as server output. Set automatically by + * the wiring for the modern-era (2026-07-28) arms — multi-round-trip + * results are not legal vocabulary on the 2025-era wire, so an + * `input_required` leaking onto a legacy cell is flagged. + */ + allowInputRequiredResults?: boolean; } const OUTBOUND = { @@ -87,6 +95,12 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO if (isJSONRPCResultResponse(msg)) { const result = (msg as { result: unknown }).result; + // Multi-round-trip results (protocol revision 2026-07-28) are valid + // server output but deliberately NOT part of the neutral result union + // (InputRequiredResultSchema lives alongside, never widening it). + // Era-gated: only cells wired for the modern era opt in, so an + // input_required on a 2025-era cell's wire is still flagged. + if (party === 'server' && opts.allowInputRequiredResults === true && isInputRequiredResult(result)) return; const r = schemas.result.safeParse(result); if (!r.success) { // A result for a vendor-extension request legitimately won't match the spec union. diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 2c919fbc2d..48c715ab66 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -514,7 +514,10 @@ export const REQUIREMENTS: Record = { 'mcpserver:tool:url-elicitation-error': { source: 'sdk', behavior: - 'A tool function that raises the URL-elicitation-required error surfaces to the caller as error -32042 with the elicitation parameters intact.' + 'A tool function that raises the URL-elicitation-required error surfaces to the caller as error -32042 with the elicitation parameters intact.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'typescript:mrtr:url-elicitation:no-32042-on-2026', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (the supersedes link names that surface).' }, 'typescript:mcpserver:tool:schema-variants': { source: 'sdk', @@ -1061,18 +1064,16 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation', behavior: 'The client ignores an elicitation/complete notification referencing an unknown or already-completed elicitationId without error.', - entryExclusions: [ - { - arm: 'entryModern', - reason: 'method-not-in-modern-registry', - note: 'notifications/elicitation/complete was removed from the 2026-07-28 draft; on that revision the client drops it as an unknown notification (the row asserts ignored-without-error against received copies, which never arrive)' - } - ] + removedInSpecVersion: '2026-07-28', + note: 'Retired on the 2026-07-28 era: notifications/elicitation/complete is removed from the draft schema (spec PR #2891), so there is no notification for the modern client to ignore.' }, 'elicitation:url:required-error': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error', behavior: - 'A handler that cannot proceed without a URL elicitation rejects the request with error -32042, carrying the pending elicitations in the error data.' + 'A handler that cannot proceed without a URL elicitation rejects the request with error -32042, carrying the pending elicitations in the error data.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'typescript:mrtr:url-elicitation:no-32042-on-2026', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (the supersedes link names that surface).' }, 'elicitation:form:response-validation': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#form-mode-security', @@ -2618,6 +2619,47 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) + 'typescript:mrtr:tools-call:write-once-roundtrip': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'A write-once tool that returns inputRequired() on a 2026-07-28 connection is fulfilled by the client auto-fulfilment driver: the registered elicitation handler answers the embedded request, and the original call is retried with a fresh request id, a byte-exact requestState echo, and the collected inputResponses, completing as a plain CallToolResult.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm; the input_required wire shape, the fresh request id, and the byte-exact requestState echo are asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:mrtr:push-api:loud-fail-2026': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'The push-style server→client APIs (e.g. ctx.mcpReq.elicitInput) on a 2026-07-28 request fail with a typed local error before any wire traffic; in a tool handler the error surfaces as an isError result whose text steers to inputRequired(...).', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm; the absence of any server→client request on the wire is asserted on the arm-recorded HTTP bytes.' + }, + 'typescript:mrtr:url-elicitation:no-32042-on-2026': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'URL-mode elicitation rides the multi-round-trip flow on the 2026-07-28 era: a tool handler that returns inputRequired.elicitUrl(...) embeds a URL-mode elicitation/create in an input_required result (capability-gated by -32003 on elicitation.url), the registered elicitation handler fulfils it, the retried call completes, and the urlElicitationRequired error code (-32042) never appears on the wire.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['mcpserver:tool:url-elicitation-error', 'elicitation:url:required-error'], + note: 'Runs on the entryModern arm; the input_required wire shape and the absence of -32042 anywhere in the exchange are asserted on the arm-recorded HTTP bytes.' + }, + 'typescript:mrtr:rounds-cap': { + source: 'sdk', + behavior: + 'The client auto-fulfilment driver is bounded: when a server keeps answering input_required, the call fails with the typed InputRequiredRoundsExceeded error (carrying the last input_required payload) once the configurable inputRequired.maxRounds cap is exhausted, instead of looping forever.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm so the round count can be asserted directly on the arm-recorded HTTP exchanges.' + }, + 'typescript:mrtr:legacy-32042-freeze': { + source: 'sdk', + behavior: + 'On 2025-era serving, a UrlElicitationRequiredError thrown by a tool handler still reaches the client as the exact urlElicitationRequired protocol error: code -32042 with data.elicitations carrying the URL-mode elicitation params, byte-identical to the pre-multi-round-trip behavior.', + removedInSpecVersion: '2026-07-28', + note: 'Bounded to the 2025-11-25 axis: this is the freeze cell pinning that the 2026-07-28 era guard leaves the deployed -32042 surface untouched on legacy serving.' + }, // Legacy SSE 'transport:sse:server-transport': { source: 'sdk', diff --git a/test/e2e/scenarios/mrtr.test.ts b/test/e2e/scenarios/mrtr.test.ts new file mode 100644 index 0000000000..5899a4bafd --- /dev/null +++ b/test/e2e/scenarios/mrtr.test.ts @@ -0,0 +1,233 @@ +/** + * Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) through + * the public surface: a write-once tool returning inputRequired() is + * fulfilled by the client's registered elicitation handler and retried with + * fresh ids + a byte-exact requestState echo; push-style server→client APIs + * loud-fail on 2026-era requests with the inputRequired() steer; URL-mode + * elicitation rides the flow with zero -32042 on the 2026 wire; the + * auto-fulfilment driver is bounded by inputRequired.maxRounds; and 2025-era + * serving keeps the exact -32042 behavior (the freeze cell). + * + * The 2026-era cells run on the entryModern arm (per-request modern hosting); + * raw wire facts are asserted on the arm-recorded HTTP exchanges. + */ +import { Client, SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; +import { acceptedContent, inputRequired, McpServer, ProtocolError, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Every JSON-RPC request the wired client POSTed for the given method, in order. */ +function recordedRequests(wired: Wired, method: string): Array> { + const requests: Array> = []; + for (const exchange of wired.httpLog ?? []) { + if (exchange.requestBody === undefined) continue; + try { + const parsed = JSON.parse(exchange.requestBody) as Record; + if (parsed.method === method) requests.push(parsed); + } catch { + // Not a JSON body (e.g. an empty notification POST) — skip it. + } + } + return requests; +} + +/** All recorded HTTP bytes (request bodies + response bodies) concatenated, for absence assertions. */ +async function allRecordedBytes(wired: Wired): Promise { + const responses = await Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text())); + const requests = (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + return [...requests, ...responses].join('\n'); +} + +const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; + +verifies('typescript:mrtr:tools-call:write-once-roundtrip', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) }, + requestState: 'opaque-deploy-state' + }); + } + return { content: [{ type: 'text', text: `deployed to ${env}` }] }; + }); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } + ); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: { confirm: true } }; + }); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed to prod' }]); + expect('resultType' in result).toBe(false); + + // The registered handler fulfilled the embedded elicitation. + expect(handled).toHaveLength(1); + expect(handled[0]).toMatchObject({ mode: 'form', message: 'Deploy to prod?' }); + + // Two independent wire legs with fresh ids; the retry carries the bare + // response and the byte-exact requestState echo alongside the original params. + const toolCalls = recordedRequests(wired, 'tools/call'); + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + const retryParams = toolCalls[1]!.params as Record; + expect(retryParams.name).toBe('deploy'); + expect(retryParams.arguments).toEqual({ env: 'prod' }); + expect(retryParams.requestState).toBe('opaque-deploy-state'); + expect(retryParams.inputResponses).toEqual({ confirm: { action: 'accept', content: { confirm: true } } }); +}); + +verifies('typescript:mrtr:push-api:loud-fail-2026', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => { + // The pre-2026 pattern: pushing a server→client elicitation request. + const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: JSON.stringify(answer) }] }; + }); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } + ); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} })); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'legacy-style', arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('inputRequired('); + + // The attempted server→client request never produced wire traffic: no + // elicitation/create request appears in any recorded exchange. + const bytes = await allRecordedBytes(wired); + expect(bytes).not.toContain('"method":"elicitation/create"'); +}); + +verifies('typescript:mrtr:url-elicitation:no-32042-on-2026', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses?.['auth'] !== undefined) { + return { content: [{ type: 'text', text: 'authorized' }] }; + } + // The 2026-07-28 idiom: return an embedded URL-mode elicitation + // (the 2025-style throw is not converted on this era). + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: 'Sign in to continue', + url: 'https://example.com/auth' + }) + } + }); + }); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { url: {} } } } + ); + const seenUrlRequests: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + seenUrlRequests.push(request.params); + // URL mode: the user completes the interaction out of band; the + // response carries no content. + return { action: 'accept' }; + }); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'protected', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'authorized' }]); + expect(seenUrlRequests).toHaveLength(1); + expect(seenUrlRequests[0]).toMatchObject({ mode: 'url', url: 'https://example.com/auth' }); + + // The -32042 error code never appears on the 2026 wire; the + // input_required result is what travelled instead. + const bytes = await allRecordedBytes(wired); + expect(bytes).not.toContain('32042'); + expect(bytes).toContain('"resultType":"input_required"'); +}); + +verifies('typescript:mrtr:rounds-cap', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('insatiable', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { more: inputRequired.elicit({ message: 'More input?', requestedSchema: CONFIRM_SCHEMA }) }, + requestState: 'never-enough' + }) + ); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } } + ); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } })); + + await using wired = await wire(transport, makeServer, client); + + const outcome = await client.callTool({ name: 'insatiable', arguments: {} }).then( + value => ({ resolved: value as unknown }), + error => ({ rejected: error as unknown }) + ); + expect('rejected' in outcome, 'the call must not resolve').toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect((rejection as SdkError).data).toMatchObject({ rounds: 2, lastResult: { requestState: 'never-enough' } }); + + // The cap bounded the wire traffic: the original call plus exactly two retries. + expect(recordedRequests(wired, 'tools/call')).toHaveLength(3); +}); + +verifies('typescript:mrtr:legacy-32042-freeze', async ({ transport }: TestArgs) => { + const URL_PARAMS = { + mode: 'url' as const, + message: 'Sign in to continue', + elicitationId: 'auth-legacy', + url: 'https://example.com/auth' + }; + const makeServer = () => { + const server = new McpServer({ name: 'legacy-url-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + return server; + }; + const client = new Client({ name: 'legacy-url-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } }); + + await using _ = await wire(transport, makeServer, client); + + const outcome = await client.callTool({ name: 'protected', arguments: {} }).then( + value => ({ resolved: value as unknown }), + error => ({ rejected: error as unknown }) + ); + expect('rejected' in outcome, 'the -32042 error must surface, not a result').toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(ProtocolError); + expect((rejection as ProtocolError).code).toBe(-32_042); + expect((rejection as ProtocolError).data).toEqual({ elicitations: [URL_PARAMS] }); +});