Implement MCP 2026-07-28 (#2286)

Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 69131 additions and 9001 deletions

10
examples/mrtr/README.md Normal file
View file

@ -0,0 +1,10 @@
# mrtr (multi-round-trip requests)
A write-once `deploy` tool that requests client input by **returning** `inputRequired(...)` instead of pushing a server→client request (protocol revision 2026-07-28). State between rounds is carried in `requestState`, which the example HMAC-protects and verifies via the
`ServerOptions.requestState.verify` hook (a wire-level `-32602` on tamper).
The client drives both the default auto-fulfilment mode (your existing `elicitation/create` handler is dispatched for you and `callTool()` returns a plain `CallToolResult`) and manual mode (`autoFulfill: false` + `allowInputRequired: true`).
```bash
pnpm tsx examples/mrtr/client.ts
```

89
examples/mrtr/client.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* Drives the multi-round-trip server (`./server.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.
*
* Asserts both flows reach `deployed to …` and exits 0.
*/
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import type { CallToolResult, ClientOptions, InputRequiredResult } from '@modelcontextprotocol/client';
import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const { transport, url, era } = parseExampleArgs();
// Both halves connect identically and differ only in ClientOptions; the
// local helper keeps the SDK transport setup visible in THIS file (the
// canonical shape) while avoiding duplicating it for each half.
const connect = async (options: ClientOptions): Promise<Client> => {
const client = new Client(
{ name: 'mrtr-example-client', version: '1.0.0' },
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, ...options }
);
await (transport === 'stdio'
? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }))
: client.connect(new StreamableHTTPClientTransport(new URL(url))));
return client;
};
// --- auto-fulfilment (the default) ---
const auto = await connect({ capabilities: { elicitation: { form: {}, url: {} } } });
// The SAME handler a 2025-flow client registers: the auto-fulfilment
// engine dispatches embedded form and URL elicitations through it.
auto.setRequestHandler('elicitation/create', async request => {
const params = request.params as { mode?: string; message: string; url?: string };
if (params.mode === 'url') return { action: 'accept' };
return { action: 'accept', content: { confirm: true } };
});
// callTool returns a plain CallToolResult — the interactive rounds happen
// inside the call.
const autoResult = await auto.callTool({ name: 'deploy', arguments: { env: 'prod' } });
const autoText = autoResult.content?.[0]?.type === 'text' ? autoResult.content[0].text : '';
check.equal(autoText, 'deployed to prod');
await auto.close();
// --- manual mode (autoFulfill: false + allowInputRequired) ---
const manual = await connect({
capabilities: { elicitation: { form: {}, url: {} } },
inputRequired: { autoFulfill: false }
});
let inputResponses: Record<string, unknown> | undefined;
let requestState: string | undefined;
let final: CallToolResult | undefined;
for (let round = 0; round < 10; round++) {
const value = (await manual.request(
{
method: 'tools/call',
params: {
name: 'deploy',
arguments: { env: 'staging' },
...(inputResponses && { inputResponses }),
...(requestState && { requestState })
}
},
{ allowInputRequired: true }
)) as CallToolResult | InputRequiredResult;
if (!isInputRequiredResult(value)) {
final = value;
break;
}
// Collect responses and echo requestState byte-exact.
inputResponses = {};
for (const [key, entry] of Object.entries(value.inputRequests ?? {})) {
inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {};
}
requestState = value.requestState;
}
check.ok(final, 'manual flow should reach a CallToolResult within 10 rounds');
const manualText = final?.content?.[0]?.type === 'text' ? final.content[0].text : '';
check.equal(manualText, 'deployed to staging');
await manual.close();

View file

@ -0,0 +1,23 @@
{
"name": "@mcp-examples/mrtr",
"private": true,
"type": "module",
"scripts": {
"server": "tsx server.ts",
"client": "tsx client.ts"
},
"dependencies": {
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
"devDependencies": {
"tsx": "catalog:devTools"
},
"example": {
"era": "modern",
"//": "Multi-round-trip inputRequired is a 2026-07-28 protocol feature."
}
}

119
examples/mrtr/server.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* A write-once tool 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 uses the SDK-provided
* {@linkcode createRequestStateCodec} helper — `mint` HMAC-seals the payload
* with a per-process key and a TTL, and `verify` is dropped directly into the
* {@linkcode ServerOptions.requestState} hook so the seam rejects tampered or
* expired state with a wire-level `-32602` Invalid Params error before the
* handler runs.
*
* One binary, either transport — selected by `--http --port <N>` (defaults to
* stdio). See `examples/CONTRIBUTING.md` for the canonical shape.
*/
import { createServer } from 'node:http';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server';
import { acceptedContent, createMcpHandler, createRequestStateCodec, inputRequired, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] };
type DeployState = { step: 'confirm' | 'signed-in'; env: string };
// 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. A multi-instance
// deployment would load a shared secret here instead.
const stateCodec = createRequestStateCodec<DeployState>({
key: crypto.getRandomValues(new Uint8Array(32)),
ttlSeconds: 600
});
function buildServer(): McpServer {
const server = new McpServer(
{ name: 'mrtr-example-server', version: '1.0.0' },
{ capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } }
);
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. The seam-level
// verify hook has already proven integrity by the time the handler
// runs; calling `verify` again here just yields the payload.
const state = ctx.mcpReq.requestState === undefined ? undefined : await stateCodec.verify(ctx.mcpReq.requestState, 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: await stateCodec.mint({ step: 'confirm', env })
});
}
// Move to the URL-mode sign-in step. URL elicitation rides
// the multi-round-trip flow on this revision — the throw-style
// UrlElicitationRequiredError of earlier revisions is not
// available toward 2026-07-28 requests.
return inputRequired({
inputRequests: {
auth: inputRequired.elicitUrl({
message: 'Sign in to continue',
url: `https://example.com/auth?env=${env}`
})
},
requestState: await stateCodec.mint({ step: 'signed-in', env })
});
}
// step === 'signed-in': the URL-mode elicitation completed out of
// band — verify the auth response actually arrived.
const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined;
if (auth?.action !== 'accept') {
return { isError: true, content: [{ type: 'text', text: 'auth response missing or declined' }] };
}
return { content: [{ type: 'text', text: `deployed to ${state?.env ?? env}` }] };
}
);
return server;
}
const { transport, port } = parseExampleArgs();
if (transport === 'stdio') {
void serveStdio(buildServer);
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}