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:
parent
e4227d135c
commit
1823aae891
4 changed files with 69131 additions and 9001 deletions
10
examples/mrtr/README.md
Normal file
10
examples/mrtr/README.md
Normal 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
89
examples/mrtr/client.ts
Normal 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();
|
||||
23
examples/mrtr/package.json
Normal file
23
examples/mrtr/package.json
Normal 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
119
examples/mrtr/server.ts
Normal 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`);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue