Serve input_required handlers on 2025-era connections via a legacy fulfilment shim (#2381)

This commit is contained in:
Felix Weinberger 2026-06-29 16:43:43 +01:00 • committed by GitHub
commit f0bf785239
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 2196 additions and 217 deletions

View file

@ -716,9 +716,10 @@ function createMcpServer() {
// Diagnostic tools for the input-required conformance scenarios. Each tool
// is written write-once style: it returns `inputRequired(...)` until the
// retried request carries the responses it needs (read from
// `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`), then completes.
// These tools are only meaningful toward 2026-07-28 requests; calling them
// on a 2025-era session fails loudly at the server seam by design.
// `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`), then completes.
// The conformance scenarios drive them on 2026-07-28 requests; on a
// 2025-era session the default legacy shim would fulfil them by pushing
// real server→client requests instead.
// Basic elicitation round trip. Also exercised by the result-type,
// missing-input-response, ignore-extra-params and validate-input
@ -821,10 +822,10 @@ function createMcpServer() {
requestState: await requestStateCodec.mint({ tool: 'request_state', nonce: randomUUID() })
});
}
// The seam-level verify hook has already proven integrity by the
// time the handler runs; calling `verify` again here just yields
// the payload (and would re-reject if it somehow had not).
const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx);
// The seam-level verify hook has already proven integrity AND
// decoded the payload by the time the handler runs — the typed
// accessor returns it directly.
const state = ctx.mcpReq.requestState<Record<string, unknown>>();
if (state === undefined) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid requestState: missing or failed integrity verification');
}
@ -878,7 +879,7 @@ function createMcpServer() {
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx);
const state = ctx.mcpReq.requestState<Record<string, unknown>>();
const round = state?.tool === 'multi_round' && typeof state.round === 'number' ? state.round : 0;
if (round === 0) {
return inputRequired({
@ -928,7 +929,7 @@ function createMcpServer() {
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
if (ctx.mcpReq.requestState !== undefined && acceptedContent(ctx.mcpReq.inputResponses, 'confirm') !== undefined) {
if (ctx.mcpReq.requestState() !== undefined && acceptedContent(ctx.mcpReq.inputResponses, 'confirm') !== undefined) {
return { content: [{ type: 'text', text: 'integrity-ok: requestState verified' }] };
}
return inputRequired({

View file

@ -3120,6 +3120,14 @@ export const REQUIREMENTS: Record<string, Requirement> = {
removedInSpecVersion: '2026-07-28',
note: 'Bounded to the 2025-11-25 axis: this is the freeze cell pinning that the 2026-07-28 era guard leaves the deployed -32042 surface untouched on legacy serving.'
},
'typescript:mrtr:legacy-shim:write-once-on-2025': {
source: 'sdk',
behavior:
'A write-once tool that returns inputRequired() is served to a 2025-era connection by the legacy shim: the embedded request goes out as a REAL server→client elicitation/create over the live session, the client answers it through its registered handler, the handler is re-entered with the collected inputResponses and the byte-exact requestState echo, and the originating call completes as a plain CallToolResult — no era branch in the handler.',
transports: STATEFUL_TRANSPORTS,
removedInSpecVersion: '2026-07-28',
note: 'Bounded to the 2025-11-25 axis (modern-era requests never reach the shim — the client auto-fulfilment driver serves them; see typescript:mrtr:tools-call:write-once-roundtrip). Restricted to stateful arms: the per-request entry has no server→client back-channel, where the shim degrades to its clean capability refusal.'
},
// Legacy SSE
'transport:sse:server-transport': {
source: 'sdk',

View file

@ -480,6 +480,43 @@ verifies('roots:mrtr:list:empty', async ({ transport }: TestArgs) => {
expect(result.structuredContent).toEqual({ count: 0 });
});
verifies('typescript:mrtr:legacy-shim:write-once-on-2025', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'shim-server', version: '1.0.0' }, { capabilities: { tools: {} } });
// The SAME write-once shape as the 2026 cell — no era branch: the
// legacy shim converts the embedded request into a real
// elicitation/create over the session and re-enters the handler.
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: 'shim-opaque-state'
});
}
return { content: [{ type: 'text', text: `deployed to ${env} (state ${ctx.mcpReq.requestState<string>()})` }] };
});
return server;
};
const client = new Client({ name: 'shim-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
const elicitations: Array<Record<string, unknown>> = [];
client.setRequestHandler('elicitation/create', async request => {
elicitations.push(request.params as Record<string, unknown>);
return { action: 'accept', content: { confirm: true } };
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } });
expect(result.isError).toBeUndefined();
expect((result.content as Array<{ text: string }>)[0]!.text).toBe('deployed to prod (state shim-opaque-state)');
// A REAL elicitation/create reached the client's registered handler.
expect(elicitations).toHaveLength(1);
expect(elicitations[0]).toMatchObject({ mode: 'form', message: 'Deploy to prod?' });
});
verifies('typescript:mrtr:legacy-32042-freeze', async ({ transport }: TestArgs) => {
const URL_PARAMS = {
mode: 'url' as const,