Skip to content
12 changes: 12 additions & 0 deletions .changeset/mrtr-server-seam.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 44 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 63 additions & 2 deletions packages/core/src/shared/clientCapabilityRequirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,60 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
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<string, unknown>): 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<string, unknown>;
}): 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;
Expand All @@ -63,7 +117,10 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
* 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).
*/
Expand All @@ -85,7 +142,11 @@ export function missingClientCapabilities(
if (isPlainObject(requirement) && isPlainObject(declaredValue)) {
const missingMembers: Record<string, unknown> = {};
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;
}
}
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
38 changes: 38 additions & 0 deletions packages/core/test/shared/clientCapabilityRequirements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading