diff --git a/.changeset/mrtr-server-seam.md b/.changeset/mrtr-server-seam.md new file mode 100644 index 000000000..b05b168fe --- /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 +is converted into a URL-mode elicitation embedded in an `input_required` result when the request declared `elicitation.url` (and fails as an internal error otherwise), 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. diff --git a/docs/migration.md b/docs/migration.md index 70ef7df0e..41557c03f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1126,10 +1126,53 @@ Resolution is per field, most specific author first: for each of `ttlMs` and `ca 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. The same handler keeps working for 2025-era clients today by serving them the old way (the in-band return is only legal toward 2026-07-28 requests; the automatic legacy bridge that replays the embedded requests as real server→client requests on 2025 +sessions is a separate, upcoming feature — until it lands, an `input_required` return on a 2025-era request fails as a server-side internal error rather than reaching the wire mis-typed). + +```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}` }] }; +}); +``` + +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. The `-32042` +URL-elicitation error also never appears on the 2026-07-28 wire: a `UrlElicitationRequiredError` thrown while serving a 2026-era request is converted into a URL-mode elicitation embedded in an `input_required` result (when the request declared the `elicitation.url` +capability), while 2025-era serving keeps today's `-32042` behavior exactly. Note that the `notifications/elicitation/complete` notification has no delivery channel under modern per-request HTTP serving (there is no server→client stream tied to a completed request), so do +not rely on it to resume URL elicitations on the 2026-07-28 era — carry the resumption through `requestState` and the retry instead. + +**`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. + +**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. +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 diff --git a/packages/core/src/shared/clientCapabilityRequirements.ts b/packages/core/src/shared/clientCapabilityRequirements.ts index 19c5b1a31..4f8fa6561 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/types/types.ts b/packages/core/src/types/types.ts index 4876cf15e..28fb2ac3c 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -503,13 +503,12 @@ export interface InputResponses { * surfaces it raw at `ctx.mcpReq.requestState` and applies no integrity * protection of its own. */ -export interface InputRequiredResult { +export interface InputRequiredResult extends Result { resultType: 'input_required'; /** Embedded requests the client must fulfil before retrying. */ inputRequests?: InputRequests; /** Opaque server state the client echoes back verbatim on retry. */ requestState?: string; - _meta?: { [key: string]: unknown }; } /* Client messages */ diff --git a/packages/core/test/shared/clientCapabilityRequirements.test.ts b/packages/core/test/shared/clientCapabilityRequirements.test.ts index 9b4c60758..80758d391 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 76244d12b..756e5481f 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 6fcdd9a32..33f6408e9 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 20e299592..82ae8cc16 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -36,29 +36,35 @@ import type { ServerCapabilities, ServerContext, ToolResultContent, - ToolUseContent + ToolUseContent, + UrlElicitationRequiredError } from '@modelcontextprotocol/core'; import { assertValidCacheHint, attachCacheHintFallback, classifyInboundMessage, + CLIENT_CAPABILITIES_META_KEY, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, envelopeClaimVersion, FIRST_MODERN_PROTOCOL_VERSION, hasEnvelopeClaim, + isInputRequiredResult, isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, LoggingLevelSchema, mergeCapabilities, + missingClientCapabilities, + MissingRequiredClientCapabilityError, modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, requestMetaOf, + requiredClientCapabilitiesForInputRequest, SdkError, SdkErrorCode, SUPPORTED_MODERN_PROTOCOL_VERSIONS, @@ -67,6 +73,29 @@ import { import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; import * as z from 'zod/v4'; +/** + * 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']); + +/** + * Symbol-keyed carrier for the per-request classification on the handler + * context: set at `buildContext` time and read by the multi-round-trip seam in + * `_wrapHandler` to decide which era a handler's `input_required` return is + * being served on (a long-lived dual-era instance is never bound to a single + * era). Symbol-keyed properties never appear in JSON serialization and the key + * is not exported, so nothing about it is part of the public context surface + * (the same pattern used for the result cache-hint carrier). + */ +const CONTEXT_CLASSIFICATION: unique symbol = Symbol('modelcontextprotocol.serverContextClassification'); + +/** A handler context that may carry the per-request classification. */ +interface ContextClassificationCarrier { + [CONTEXT_CLASSIFICATION]?: MessageClassification; +} + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -447,7 +476,8 @@ export class Server extends Protocol { SdkErrorCode.MethodNotSupportedByProtocolVersion, `Server-to-client requests are not available on protocol revision ${servedCodec.era}: ` + `'${method}' cannot be sent while serving a request on that revision. ` + - `Servers obtain client input through request results once multi-round-trip support is available.`, + `Return inputRequired({ ... }) from the handler instead — the client fulfils the embedded ` + + `requests and retries the original request (multi round-trip requests).`, { method, era: servedCodec.era } ); } @@ -464,7 +494,7 @@ export class Server extends Protocol { this._assertContextRequestInServedEra(classification, request.method); return baseSend(request, ...rest); }) as BaseContext['mcpReq']['send']; - return { + const built: ServerContext = { ...ctx, mcpReq: { ...ctx.mcpReq, @@ -491,6 +521,14 @@ export class Server extends Protocol { } : undefined }; + if (classification !== undefined) { + // Carried on the context itself (symbol-keyed, never serialized, + // not part of the public context types) for the multi-round-trip + // seam: input_required returns are only legal toward the era that + // defines them. + (built as ContextClassificationCarrier)[CONTEXT_CLASSIFICATION] = classification; + } + return built; } // Map log levels by session id @@ -523,9 +561,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( @@ -534,10 +578,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 @@ -559,7 +634,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) { @@ -572,6 +653,160 @@ export class Server extends Protocol { }; } + /** + * The protocol revision a handler context's request is being served on: + * the per-request classification carried on the context (when the + * entry/transport supplied one), the instance's negotiated version + * otherwise. + */ + private _servedProtocolVersionFor(ctx: ServerContext): string | undefined { + const classification = (ctx as ContextClassificationCarrier)[CONTEXT_CLASSIFICATION]; + if (classification !== undefined) { + return classification.revision ?? (classification.era === 'modern' ? FIRST_MODERN_PROTOCOL_VERSION : undefined); + } + return this._negotiatedProtocolVersion; + } + + /** + * Invokes a handler for one of the multi-round-trip methods and applies + * the input-required seam: + * + * - a `UrlElicitationRequiredError` escaping the handler on a request + * served on the 2026-07-28 era is CONVERTED into a URL-mode elicitation + * embedded in an input-required result when the request's declared + * client capabilities include `elicitation.url`, and fails loudly + * otherwise — the `-32042` error never reaches the 2026-07-28 wire. + * 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 servedVersion = this._servedProtocolVersionFor(ctx); + const servedModern = servedVersion !== undefined && isModernProtocolVersion(servedVersion); + + 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; + } + return this._convertUrlElicitationRequiredError(error as UrlElicitationRequiredError, ctx); + } + throw error; + } + + if (!isInputRequiredResult(result)) { + return result; + } + + if (!servedModern) { + // The 2025-era wire has no input_required vocabulary, and the + // legacy bridge (fulfilling the embedded requests as real + // server→client requests) is a separate feature: fail loudly + // rather than putting a mis-typed result on the wire. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but this request is served on protocol revision ` + + `${servedVersion ?? 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 | undefined; + const hasInputRequests = inputRequests !== undefined && 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; + } + + /** + * F5 conversion: a `UrlElicitationRequiredError` escaping a handler on a + * 2026-07-28-served multi-round-trip request becomes a URL-mode + * elicitation embedded in an input-required result (URL elicitation rides + * the multi-round-trip flow on that revision); without the + * `elicitation.url` client capability the failure is loud — `-32042` + * never reaches the 2026-07-28 wire. + */ + private _convertUrlElicitationRequiredError(error: UrlElicitationRequiredError, ctx: ServerContext): Result { + if (error.elicitations.length === 0) { + // Nothing to embed: converting would produce an input_required + // with an empty inputRequests map (violating the at-least-one + // rule), so this is a server bug surfaced loudly instead. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'URL elicitation was signalled for this request, but the error carries no elicitations to embed' + ); + } + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; + if (declared?.elicitation?.url === undefined) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'URL elicitation is required to complete this request, but the request did not declare the elicitation.url ' + + 'client capability (the urlElicitationRequired error of earlier revisions is not available on 2026-07-28)' + ); + } + const inputRequests: Record = {}; + for (const [index, params] of error.elicitations.entries()) { + const preferred = params.elicitationId; + const key = preferred && !(preferred in inputRequests) ? preferred : `url-elicitation-${index + 1}`; + inputRequests[key] = { method: 'elicitation/create', params }; + } + return { resultType: 'input_required', inputRequests }; + } + protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method) { case 'sampling/createMessage': { diff --git a/packages/server/test/server/inputRequired.test.ts b/packages/server/test/server/inputRequired.test.ts new file mode 100644 index 000000000..aba51def6 --- /dev/null +++ b/packages/server/test/server/inputRequired.test.ts @@ -0,0 +1,392 @@ +/** + * 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); + * - the F5 conversion guard: a UrlElicitationRequiredError escaping a handler + * on the modern era becomes a URL-mode elicitation inside an input-required + * result (capability-gated) — `-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, + UrlElicitationRequiredError +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.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) { + 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(); + + 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: {} }, eraSupport: 'dual-era' }); + 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); + + // 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: {} }, eraSupport: 'dual-era' } + ); + 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); + + 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: {} }, eraSupport: 'dual-era' }); + server.registerTool('broken', { inputSchema: z.object({}) }, async () => ({ resultType: 'input_required' }) as never); + const { request, close } = await wire(server); + + 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: {} }, eraSupport: 'dual-era' }); + 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', elicitationId: 'auth-1', url: 'https://example.com' }) + } + }) + ); + const { request, close } = await wire(server); + + // 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: {} }, eraSupport: 'dual-era' }); + server.setRequestHandler('completion/complete', async () => ({ resultType: 'input_required', requestState: 's' }) as never); + const { request, close } = await wire(server); + + 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('F5 conversion guard (UrlElicitationRequiredError)', () => { + const URL_PARAMS = { mode: 'url' as const, message: 'Sign in to continue', elicitationId: 'elicit-7', url: 'https://example.com/auth' }; + + function buildUrlThrowingServer(eraSupport?: 'dual-era') { + const server = new McpServer( + { name: 's', version: '1.0.0' }, + { capabilities: { tools: {} }, ...(eraSupport ? { eraSupport } : {}) } + ); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + return server; + } + + it('converts to a URL-mode elicitation inside input_required when the request declares elicitation.url', async () => { + const { request, close } = await wire(buildUrlThrowingServer('dual-era')); + + const answer = await request(modernToolCall(1, 'protected', {}, { clientCapabilities: { elicitation: { url: {} } } })); + const result = resultOf(answer); + expect(result.resultType).toBe('input_required'); + expect(result.inputRequests).toEqual({ 'elicit-7': { method: 'elicitation/create', params: URL_PARAMS } }); + expect(JSON.stringify(answer)).not.toContain('-32042'); + + await close(); + }); + + it('fails loudly (never -32042) on the modern era when the request does not declare elicitation.url', async () => { + const { request, close } = await wire(buildUrlThrowingServer('dual-era')); + + const answer = await request(modernToolCall(1, 'protected', {}, { clientCapabilities: {} })); + expect(errorOf(answer).code).toBe(-32_603); + expect(JSON.stringify(answer)).not.toContain('32042'); + + await close(); + }); + + it('fails loudly when the error carries no elicitations (never an empty inputRequests map)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([]); + }); + const { request, close } = await wire(server); + + const answer = await request(modernToolCall(1, 'protected', {}, { clientCapabilities: { elicitation: { url: {} } } })); + expect(errorOf(answer).code).toBe(-32_603); + expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"'); + expect(JSON.stringify(answer)).not.toContain('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('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: {} }, eraSupport: 'dual-era' }); + 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); + + 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 afd70b38a..f013dd3cd 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -159,11 +159,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)); return { fetch, url, diff --git a/test/e2e/helpers/wire-sniffer.test.ts b/test/e2e/helpers/wire-sniffer.test.ts index 73ea7222e..ca072217a 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 89663214c..3a5dc2fc3 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 567c55e02..8e009af8f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -512,6 +512,13 @@ export const REQUIREMENTS: Record = { behavior: "Registering a tool whose name violates the spec's tool-naming conventions emits a warning; registration still succeeds." }, 'mcpserver:tool:url-elicitation-error': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'modern-error-surface', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (typescript:mrtr:url-elicitation:no-32042-on-2026 covers that surface).' + } + ], 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.' @@ -1058,11 +1065,25 @@ export const REQUIREMENTS: Record = { note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' }, 'elicitation:url:complete-unknown-ignored': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'modern-error-surface', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (typescript:mrtr:url-elicitation:no-32042-on-2026 covers that surface).' + } + ], 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.' }, 'elicitation:url:required-error': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'modern-error-surface', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (typescript:mrtr:url-elicitation:no-32042-on-2026 covers that surface).' + } + ], 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.' @@ -2618,6 +2639,47 @@ export const REQUIREMENTS: Record = { 'The SDK provides a server-side legacy HTTP+SSE transport so existing SSE deployments can be hosted on SDK components alone.', transports: ['sse'], note: 'This asserts the availability of the server half of the legacy SSE transport (SSEServerTransport from @modelcontextprotocol/server-legacy/sse); the matrix transport arg is ignored, so it runs as a single sse-labelled cell.' + }, + + // 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() is completed transparently for the caller on the 2026-07-28 era: the client fulfils the embedded elicitation through its registered elicitation/create handler and retries the original tools/call with bare inputResponses, a byte-exact requestState echo, and a fresh request id; client.callTool() resolves with the plain final CallToolResult.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm (per-request modern hosting is the multi-round-trip flow’s natural home); the two independent wire legs, the fresh ids, and the retry params (bare responses + requestState echo) are asserted on the arm-recorded HTTP exchanges. In-memory coverage of the engine lives in the client package unit suite.' + }, + 'typescript:mrtr:push-api:loud-fail-2026': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'A tool handler that calls a push-style server-to-client API (ctx.mcpReq.elicitInput) while serving a 2026-07-28 request fails with a typed local error before any wire traffic is emitted for the attempted request, and the tools/call catch-all surfaces it as an isError result whose text steers to returning inputRequired(...) instead.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the absence of any elicitation/create wire traffic and the steer text are asserted on the arm-recorded HTTP exchanges. 2025-era behavior of the push-style APIs is covered by the existing elicitation/sampling/roots requirements on the 2025 axis.' + }, + '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 UrlElicitationRequiredError thrown by a tool handler is converted into a URL-mode elicitation/create embedded in an input_required result (capability-gated on elicitation.url), the registered elicitation handler fulfils it, the retried call completes, and the urlElicitationRequired error code (-32042) never appears on the wire.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + 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.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm with a small explicit maxRounds so the cell stays fast; the typed error code and the bounded number of wire legs are asserted.' + }, + 'typescript:mrtr:legacy-32042-freeze': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation', + 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 conversion guard leaves the deployed -32042 surface untouched on legacy serving.' } } satisfies Record; diff --git a/test/e2e/scenarios/mrtr.test.ts b/test/e2e/scenarios/mrtr.test.ts new file mode 100644 index 000000000..fa14caeb3 --- /dev/null +++ b/test/e2e/scenarios/mrtr.test.ts @@ -0,0 +1,225 @@ +/** + * 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 URL_PARAMS = { mode: 'url' as const, message: 'Sign in to continue', elicitationId: 'auth-1', url: 'https://example.com/auth' }; + 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-1'] !== undefined) { + return { content: [{ type: 'text', text: 'authorized' }] }; + } + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + 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', elicitationId: 'auth-1' }); + + // The conversion guard kept -32042 off 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] }); +});