Serve input_required handlers on 2025-era connections via a legacy fulfilment shim (#2381)
This commit is contained in:
parent
7c42d476cd
commit
f0bf785239
4 changed files with 2196 additions and 217 deletions
|
|
@ -145,7 +145,8 @@ function buildServer(reqCtx: McpRequestContext): McpServer {
|
|||
// for integrity-protecting `requestState` in production.
|
||||
const dates = acceptedContent<{ departure: string; nights: number }>(ctx.mcpReq.inputResponses, 'dates');
|
||||
const destination =
|
||||
ctx.mcpReq.requestState ?? acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination;
|
||||
ctx.mcpReq.requestState<string>() ??
|
||||
acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination;
|
||||
if (!destination) {
|
||||
return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
* 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`).
|
||||
* round-trips opaquely (echoed byte-exact by the client; the handler reads
|
||||
* the verified payload back via the typed `ctx.mcpReq.requestState<T>()`
|
||||
* accessor).
|
||||
*
|
||||
* `requestState` round-trips through the client and is therefore
|
||||
* attacker-controlled input on re-entry. A real server MUST integrity-protect
|
||||
|
|
@ -60,9 +61,9 @@ function buildServer(): McpServer {
|
|||
// 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);
|
||||
// verify hook has already proven integrity AND decoded the payload
|
||||
// by the time the handler runs — the typed accessor returns it.
|
||||
const state = ctx.mcpReq.requestState<DeployState>();
|
||||
const step = state?.step ?? 'confirm';
|
||||
console.error(`[server] tools/call deploy(${env}) step=${step}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ Any other `mcpServers`-style host can spawn it too:
|
|||
| list_changed | every mutation | resource list + resource updated notifications, delivered correctly over stdio and per-request HTTP |
|
||||
| Prompts + completions | `plan-my-day`, `seed-board` | `completable()` argument values (project names, themes) wired to `completion/complete` |
|
||||
|
||||
The two protocol eras differ in how interactive tools converse with the client: on 2025-era connections the server _pushes_ `elicitation/create` / `sampling/createMessage` requests and awaits them inline; on 2026-07-28 it returns `input_required` results and the client retries the call with the answers. The interactive tools (`brainstorm_tasks`, `clear_done`, `prioritize`) implement both arms — branch on `reqCtx.era` to compare them side by side.
|
||||
The two protocol eras differ in how interactive conversations travel: on 2025-era connections the wire carries _pushed_ `elicitation/create` / `sampling/createMessage` requests; on 2026-07-28 the server returns `input_required` results and the client retries the call with the answers. The interactive tools (`brainstorm_tasks`, `clear_done`, `prioritize`) are written **once** in the `input_required` style — on 2025-era connections the SDK's default-on legacy shim performs the push-style round trips for them, so there is no era branch in any handler. (For a side-by-side of the two wire styles written by hand, see `examples/elicitation`.)
|
||||
|
||||
One serving-mode caveat: over **HTTP with a 2025-era client**, `createMcpHandler`'s default stateless posture has no return path for push-style server→client requests, so the sampling/elicitation tools refuse cleanly on that leg (stdio is unaffected; 2026-07-28 HTTP is unaffected).
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ type BrainstormState =
|
|||
/**
|
||||
* HMAC-signs the `requestState` round-tripped through brainstorm_tasks' multi-round flow so a
|
||||
* client cannot forge or mutate the carried step/theme/count. The seam runs `verify` before the
|
||||
* handler (rejecting tampered state with -32602); the handler calls `verify` again to decode.
|
||||
* handler (rejecting tampered state with -32602) and the handler reads the decoded payload via
|
||||
* the typed `ctx.mcpReq.requestState<BrainstormState>()` accessor — no second decode.
|
||||
* The key comes from the environment for real deployments and falls back to a per-process
|
||||
* random one for the zero-setup demo (which is fine because one process serves every round).
|
||||
*/
|
||||
|
|
@ -445,41 +446,15 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
|
|||
content: [{ type: 'text', text: `Nothing added (user answered: ${action}).` }]
|
||||
});
|
||||
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025 era: push-style round trips, awaited inline — one elicitation for the theme
|
||||
// and count (with a follow-up form only when the user picks "custom"), then the
|
||||
// host's model invents the tasks (sampling).
|
||||
const countResult = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: countMessage,
|
||||
requestedSchema: BRAINSTORM_COUNT_SCHEMA
|
||||
});
|
||||
if (countResult.action !== 'accept') return declined(countResult.action);
|
||||
const topic = resolveTopic(countResult.content?.theme);
|
||||
let wanted = parseBrainstormCount(countResult.content?.count);
|
||||
if (countResult.content?.count === 'custom') {
|
||||
const customResult = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'How many exactly?',
|
||||
requestedSchema: BRAINSTORM_CUSTOM_COUNT_SCHEMA
|
||||
});
|
||||
if (customResult.action !== 'accept') return declined(customResult.action);
|
||||
wanted = parseBrainstormCount(customResult.content?.customCount);
|
||||
}
|
||||
if (wanted === undefined) return declined('cancel');
|
||||
const response = await ctx.mcpReq.requestSampling(buildBrainstormSampling(topic, wanted));
|
||||
const ideasText = !Array.isArray(response.content) && response.content.type === 'text' ? response.content.text : '';
|
||||
return finish(ideasText, wanted, topic);
|
||||
}
|
||||
|
||||
// 2026-07-28: the same conversation as a multi-round input_required chain. The
|
||||
// handler is a state machine over BrainstormState — it dispatches on `state.step`
|
||||
// (not on which inputResponses key arrived), so each round knows exactly which
|
||||
// answer to read and which data is in scope. State is HMAC-signed by stateCodec;
|
||||
// the seam already verified integrity before this handler ran, so verify here is
|
||||
// the decode.
|
||||
const state: BrainstormState | undefined =
|
||||
ctx.mcpReq.requestState === undefined ? undefined : await stateCodec.verify(ctx.mcpReq.requestState, ctx);
|
||||
// The whole conversation as a multi-round input_required chain — written ONCE.
|
||||
// The handler is a state machine over BrainstormState — it dispatches on
|
||||
// `state.step` (not on which inputResponses key arrived), so each round knows
|
||||
// exactly which answer to read and which data is in scope. State is HMAC-signed
|
||||
// by stateCodec; the seam verified integrity AND decoded the payload before this
|
||||
// handler ran — the typed accessor returns it. On a 2025-era session the SDK's
|
||||
// legacy shim fulfils each round as real push-style requests; the handler never
|
||||
// branches on the served era.
|
||||
const state = ctx.mcpReq.requestState<BrainstormState>();
|
||||
const askForIdeas = async (count: number, topic: string): Promise<InputRequiredResult> =>
|
||||
inputRequired({
|
||||
inputRequests: { ideas: inputRequired.createMessage(buildBrainstormSampling(topic, count)) },
|
||||
|
|
@ -622,27 +597,19 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
|
|||
if (done.length === 0) return { content: [{ type: 'text', text: 'No completed tasks to clear.' }] };
|
||||
const message = `Delete ${done.length} completed task(s) from the board?`;
|
||||
|
||||
let action: string;
|
||||
let confirmation: { confirm?: boolean } | undefined;
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025 era: a push-style elicitation/create request, answered inline.
|
||||
const result = await ctx.mcpReq.elicitInput({ mode: 'form', message, requestedSchema: CLEAR_CONFIRM_SCHEMA });
|
||||
action = result.action;
|
||||
confirmation = result.action === 'accept' && result.content ? { confirm: result.content.confirm === true } : undefined;
|
||||
} else {
|
||||
// 2026-07-28: a single input_required round, so no requestState is needed —
|
||||
// the first call has no inputResponses and returns the question; the re-call
|
||||
// carries the answer. (For multi-round flows, dispatch on a discriminated
|
||||
// requestState instead — see brainstorm_tasks.)
|
||||
const response = ctx.mcpReq.inputResponses?.['confirmation'];
|
||||
if (response === undefined) {
|
||||
return inputRequired({
|
||||
inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) }
|
||||
});
|
||||
}
|
||||
action = elicitAction(response);
|
||||
confirmation = acceptedContent<{ confirm?: boolean }>(ctx.mcpReq.inputResponses, 'confirmation');
|
||||
// A single input_required round, written once for both eras — the first call has
|
||||
// no inputResponses and returns the question; the re-call carries the answer.
|
||||
// (For multi-round flows, dispatch on a discriminated requestState instead — see
|
||||
// brainstorm_tasks.) On 2025-era sessions the SDK's legacy shim asks the question
|
||||
// as a real push-style elicitation.
|
||||
const response = ctx.mcpReq.inputResponses?.['confirmation'];
|
||||
if (response === undefined) {
|
||||
return inputRequired({
|
||||
inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) }
|
||||
});
|
||||
}
|
||||
const action = elicitAction(response);
|
||||
const confirmation = acceptedContent<{ confirm?: boolean }>(ctx.mcpReq.inputResponses, 'confirmation');
|
||||
|
||||
if (confirmation?.confirm !== true) {
|
||||
// Decline and cancel are answers — report them and stop, never ask again.
|
||||
|
|
@ -675,21 +642,15 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
|
|||
maxTokens: 400
|
||||
};
|
||||
|
||||
let rankingText: string;
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025 era: push-style sampling/createMessage back to the client, awaited inline.
|
||||
const response = await ctx.mcpReq.requestSampling(samplingRequest);
|
||||
rankingText = !Array.isArray(response.content) && response.content.type === 'text' ? response.content.text : '';
|
||||
} else {
|
||||
// 2026-07-28: a single input_required round (the ranking arrives on the retried
|
||||
// call), so no requestState is needed. For multi-round flows, dispatch on a
|
||||
// discriminated requestState instead — see brainstorm_tasks.
|
||||
const response = ctx.mcpReq.inputResponses?.['ranking'];
|
||||
if (response === undefined) {
|
||||
return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } });
|
||||
}
|
||||
rankingText = sampledText(response);
|
||||
// A single input_required round, written once for both eras (the ranking arrives
|
||||
// on the retried call), so no requestState is needed. For multi-round flows,
|
||||
// dispatch on a discriminated requestState instead — see brainstorm_tasks. On
|
||||
// 2025-era sessions the SDK's legacy shim performs the sampling round trip.
|
||||
const response = ctx.mcpReq.inputResponses?.['ranking'];
|
||||
if (response === undefined) {
|
||||
return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } });
|
||||
}
|
||||
const rankingText = sampledText(response);
|
||||
|
||||
const ranked = applyRanking(rankingText, candidates);
|
||||
for (const [index, task] of ranked.entries()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue