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
fails as an internal error with a clear steer to `inputRequired.elicitUrl(...)`, so the `-32042` error never reaches the 2026-07-28 wire; 2025-era serving keeps today's `-32042` behavior
exactly. The typed local error raised when push-style server-to-client request APIs are used while serving a 2026-era request now steers to `inputRequired(...)`. Tool, prompt, and resource callback types accept the new return alongside their existing result types; 2025-era
wire behavior is unchanged. An optional `ServerOptions.requestState.verify` hook lets a server integrity-check the echoed `requestState` before the handler runs — a throw answers the wire-level `-32602` Invalid Params error with `data.reason: 'invalid_request_state'`; the SDK provides no default verification.
95 changes: 64 additions & 31 deletions docs/migration-SKILL.md

Large diffs are not rendered by default.

256 changes: 150 additions & 106 deletions docs/migration.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions examples/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md
| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) |
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
| Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) |

## URL elicitation example (server + client)

Expand Down
124 changes: 124 additions & 0 deletions examples/client/src/multiRoundTripClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Drives the multi-round-trip server example
* (`examples/server/src/multiRoundTrip.ts`) two ways on a 2026-07-28
* connection:
*
* 1. **auto-fulfilment** (the default) — the same `elicitation/create`
* handler the client would register for the 2025-era flow fulfils the
* embedded form and URL elicitations, and the SDK retries the original
* `tools/call` for you. `client.callTool()` returns a plain
* `CallToolResult`;
* 2. **manual mode** — `inputRequired: { autoFulfill: false }` plus per-call
* `allowInputRequired: true`: the input-required value is handed back, and
* the example collects responses, echoes `requestState`, and retries
* itself.
*
* Start the server first, then:
*
* tsx examples/client/src/multiRoundTripClient.ts
*
* (Attaching the per-request `_meta` envelope explicitly is a stop-gap;
* automatic envelope emission for every request is a client-side follow-up.)
*/
import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client';
import {
Client,
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
isInputRequiredResult,
PROTOCOL_VERSION_META_KEY,
StreamableHTTPClientTransport
} from '@modelcontextprotocol/client';

const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/';
const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' };

// Per-request envelope every 2026-era request carries on the wire. The
// declared client capabilities are what the server's −32003 check reads.
function envelope(negotiated: string): Record<string, unknown> {
return {
[PROTOCOL_VERSION_META_KEY]: negotiated,
[CLIENT_INFO_META_KEY]: CLIENT_INFO,
[CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {}, url: {} } }
};
}

async function autoFulfilLeg(): Promise<void> {
console.log('--- auto-fulfilment (the default) ---');
const client = new Client(CLIENT_INFO, {
versionNegotiation: { mode: 'auto' },
capabilities: { elicitation: { form: {}, url: {} } }
});
// The SAME handler a 2025-flow client registers: the auto-fulfilment
// engine dispatches embedded form and URL elicitations through it.
client.setRequestHandler('elicitation/create', async request => {
const params = request.params as { mode?: string; message: string; url?: string };
if (params.mode === 'url') {
console.log(`[client] (auto) url elicitation: ${params.message} → ${params.url}`);
return { action: 'accept' };
}
console.log(`[client] (auto) form elicitation: ${params.message}`);
return { action: 'accept', content: { confirm: true } };
});

await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL)));
const negotiated = client.getNegotiatedProtocolVersion()!;
console.log('negotiated protocol version:', negotiated);

// callTool returns a plain CallToolResult — the interactive rounds happen
// inside the call.
const result = await client.request({
method: 'tools/call',
params: { name: 'deploy', arguments: { env: 'prod' }, _meta: envelope(negotiated) }
});
console.log('deploy result:', JSON.stringify(result.content));
await client.close();
}

async function manualLeg(): Promise<void> {
console.log('--- manual mode (autoFulfill: false + allowInputRequired) ---');
const client = new Client(CLIENT_INFO, {
versionNegotiation: { mode: 'auto' },
capabilities: { elicitation: { form: {}, url: {} } },
inputRequired: { autoFulfill: false }
});
await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL)));
const negotiated = client.getNegotiatedProtocolVersion()!;

let inputResponses: Record<string, unknown> | undefined;
let requestState: string | undefined;
for (let round = 0; round < 10; round++) {
// allowInputRequired: true → the call resolves with either the
// complete CallToolResult or the input-required value (use
// `withInputRequired(schema)` on the explicit-schema path to type
// both outcomes; here the method-keyed path is used for brevity).
const value = (await client.request(
{
method: 'tools/call',
params: {
name: 'deploy',
arguments: { env: 'staging' },
_meta: envelope(negotiated),
...(inputResponses && { inputResponses }),
...(requestState && { requestState })
}
},
{ allowInputRequired: true }
)) as CallToolResult | InputRequiredResult;
if (!isInputRequiredResult(value)) {
console.log('deploy result:', JSON.stringify(value.content));
break;
}
// Collect responses and echo requestState byte-exact.
console.log(`[client] (manual) round ${round + 1}: server asked for ${Object.keys(value.inputRequests ?? {}).join(', ')}`);
inputResponses = {};
for (const [key, entry] of Object.entries(value.inputRequests ?? {})) {
inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {};
}
requestState = value.requestState;
}
await client.close();
}

await autoFulfilLeg();
await manualLeg();
1 change: 1 addition & 0 deletions examples/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts
| Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) |
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
| Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) |

## OAuth demo flags (Streamable HTTP server)

Expand Down
135 changes: 135 additions & 0 deletions examples/server/src/multiRoundTrip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* A write-once tool served via `createMcpHandler` that requests client input
* with multi round-trip results (protocol revision 2026-07-28).
*
* The `deploy` tool returns `inputRequired(...)` instead of pushing a
* server→client request: a form-mode elicitation for confirmation, then a
* URL-mode elicitation for sign-in via `inputRequired.elicitUrl(...)`. The
* step the tool is waiting for is carried in `requestState`, which the SDK
* round-trips opaquely (echoed byte-exact by the client; the server reads it
* raw at `ctx.mcpReq.requestState`).
*
* `requestState` round-trips through the client and is therefore
* attacker-controlled input on re-entry. A real server MUST integrity-protect
* it (e.g. HMAC or AEAD): this example mints `body.hmac` with a per-process
* key and rejects tampered state via the {@linkcode ServerOptions.requestState}
* `verify` hook, which answers a wire-level `-32602` Invalid Params error.
*
* Run with:
*
* tsx examples/server/src/multiRoundTrip.ts
*
* and point the paired client example at it:
*
* tsx examples/client/src/multiRoundTripClient.ts
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server';
import { acceptedContent, createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] };

// Per-process integrity key for requestState. The 2026-07-28 path serves every
// request from a fresh server instance — the state itself is the only thing
// that survives between rounds — so the key is process-local.
const STATE_KEY = randomBytes(32);

type DeployState = { step: 'confirm' | 'signed-in'; env: string };

function mintState(payload: DeployState): string {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${body}.${createHmac('sha256', STATE_KEY).update(body).digest('base64url')}`;
}

function verifyState(state: string): void {
const dot = state.lastIndexOf('.');
const body = dot > 0 ? state.slice(0, dot) : '';
const expected = createHmac('sha256', STATE_KEY).update(body).digest();
const provided = Buffer.from(state.slice(dot + 1), 'base64url');
if (dot <= 0 || provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
throw new Error('requestState failed integrity verification');
}
}

function readState(ctx: { mcpReq: { requestState?: string } }): DeployState | undefined {
// The seam-level verify hook has already proven integrity by the time the
// handler runs; this only re-reads the body.
const state = ctx.mcpReq.requestState;
return state === undefined
? undefined
: (JSON.parse(Buffer.from(state.slice(0, state.lastIndexOf('.')), 'base64url').toString()) as DeployState);
}

function buildServer(): McpServer {
const server = new McpServer(
{ name: 'mrtr-example-server', version: '1.0.0' },
{ capabilities: { tools: {} }, requestState: { verify: verifyState } }
);

server.registerTool(
'deploy',
{
title: 'Deploy (write-once)',
description: 'Deploys to the named environment after a confirmation and a sign-in.',
inputSchema: z.object({ env: z.string() })
},
async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => {
// The handler reads the SAME context fields on every entry; what
// changes between rounds is which input responses have arrived and
// what (verified) `requestState` was echoed back.
const state = readState(ctx);
const step = state?.step ?? 'confirm';
console.error(`[server] tools/call deploy(${env}) step=${step}`);

if (step === 'confirm') {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
if (!confirmed?.confirm) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA })
},
// The next entry stays at the 'confirm' step until the
// user actually accepts.
requestState: mintState({ step: 'confirm', env })
});
Comment thread
felixweinberger marked this conversation as resolved.
}
// Move to the URL-mode sign-in step. URL elicitation rides
// the multi-round-trip flow on this revision — the throw-style
// UrlElicitationRequiredError of earlier revisions is not
// available toward 2026-07-28 requests.
return inputRequired({
inputRequests: {
auth: inputRequired.elicitUrl({
message: 'Sign in to continue',
url: `https://example.com/auth?env=${env}`
})
},
requestState: mintState({ step: 'signed-in', env })
Comment on lines +103 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The deploy example interpolates the unconstrained, client-supplied env tool argument directly into the URL-mode elicitation URL (url: https://example.com/auth?env=${env}``) without encoding, so a value like prod&redirect_uri=https://evil.example (or anything containing &, #, =, or spaces) injects extra query parameters/fragments into — or simply breaks — the sign-in URL the human user is asked to open. Since this file is the canonical copy-paste example for the new URL-elicitation flow, please build the URL with encodeURIComponent(env) or new URL()/URLSearchParams.

Extended reasoning...

What the bug is. In examples/server/src/multiRoundTrip.ts (the deploy handler's sign-in step, around line 107), the URL-mode elicitation is built as:

auth: inputRequired.elicitUrl({
    message: 'Sign in to continue',
    url: `https://example.com/auth?env=${env}`
})

env is the raw tool argument, typed only as z.string() with no constraint, and it is interpolated into the query string with no encodeURIComponent and no URL/URLSearchParams construction. Any reserved character in the value lands directly in the URL.

The code path that triggers it. The first round of the deploy flow returns the form-mode confirm elicitation; once accepted, the handler builds this URL-mode elicitation and the client presents the URL to the human user to open in a browser (that is the whole point of URL elicitation). Nothing between the tool argument and the URL sanitizes or encodes env: the schema is an unconstrained string, and the template literal concatenates it verbatim after ?env=.

Why existing code doesn't prevent it. The example otherwise goes out of its way to model security best practice — it HMAC-protects requestState and wires the new requestState.verify hook — but the URL construction has no equivalent guard. The seam-level checks added in this PR (capability check, at-least-one rule) validate the shape of the embedded request, not the contents of its url field.

Impact. Two layers. Functionally, any env containing &, #, =, or spaces produces a semantically wrong or ambiguous URL even on the benign path. Security-wise, in MCP the tool arguments are typically composed by an LLM host (and can be influenced by untrusted prompt content), while the URL elicitation is shown to and opened by the human user — so the entity choosing env and the entity opening the URL differ. A real server copied from this canonical example (it is linked from the server README, paired with the client example, and referenced by the migration docs) with a real auth endpoint would let injected query params (the classic redirect_uri-style injection) ride a trusted-host URL presented to the human. The injection cannot change the host (it lands after ?), so the ceiling is parameter injection on the auth endpoint, not a full open redirect — and the demo URL is example.com — which is why this is a nit rather than a blocker.

Step-by-step proof. (1) A caller (or a prompt-injected model) invokes tools/call deploy with arguments: { env: 'prod&redirect_uri=https://evil.example' }. (2) The confirm round completes normally. (3) The handler builds url: 'https://example.com/auth?env=prod&redirect_uri=https://evil.example' — the injected redirect_uri is now a separate query parameter on the trusted host. (4) The URL-mode elicitation carrying that URL is presented to the human user, who sees an example.com/auth link and opens it. With a real auth endpoint that honours redirect_uri, the sign-in flow would redirect to the attacker-chosen URL. Even with a harmless value like env: 'us east' or env: 'a#b', step (3) already produces a malformed/ambiguous URL.

How to fix. One token: url: https://example.com/auth?env=${encodeURIComponent(env)}`` — or, more idiomatically, build the URL with const u = new URL('https://example.com/auth'); u.searchParams.set('env', env); and pass u.href. Example-only; no SDK runtime path is affected.

});
}

// step === 'signed-in': the URL-mode elicitation completed out of
// band — verify the auth response actually arrived.
const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined;
if (auth?.action !== 'accept') {
return { isError: true, content: [{ type: 'text', text: 'auth response missing or declined' }] };
}
return { content: [{ type: 'text', text: `deployed to ${state?.env ?? env}` }] };
}
);

return server;
}

// Host with the per-request HTTP entry on its default posture (2026-07-28
// served per request; 2025-era traffic served stateless from the same
// factory).
const handler = createMcpHandler(() => buildServer());
const port = Number(process.env.PORT ?? '3000');

createServer((req, res) => void handler.node(req, res)).listen(port, () => {
console.error(`multi-round-trip example server listening on http://localhost:${port}/`);
});
Loading
Loading