Serve input_required handlers on 2025-era connections via a legacy fulfilment shim (#2381)
This commit is contained in:
parent
7c42d476cd
commit
f0bf785239
3 changed files with 2196 additions and 217 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue