Serve input_required handlers on 2025-era connections via a legacy fulfilment shim (#2381)
This commit is contained in:
parent
7c42d476cd
commit
f0bf785239
29 changed files with 2196 additions and 217 deletions
|
|
@ -2,4 +2,4 @@
|
|||
'@modelcontextprotocol/server': minor
|
||||
---
|
||||
|
||||
Add `createRequestStateCodec({ key, ttlSeconds?, bind? })`, an opt-in HMAC-SHA256 sealing helper for the multi-round-trip `requestState`: `mint` seals a JSON-serializable payload (with TTL and optional context binding) and `verify` drops directly into `ServerOptions.requestState.verify`. WebCrypto-based and runtime-neutral; verification is fail-closed and constant-time. The `ServerOptions.requestState.verify` hook's return type is widened to `unknown | Promise<unknown>` (the seam already discarded the return value) so the codec's `verify` is directly assignable.
|
||||
Add `createRequestStateCodec({ key, ttlSeconds?, bind? })`, an opt-in HMAC-SHA256 sealing helper for the multi-round-trip `requestState`: `mint` seals a JSON-serializable payload (with TTL and optional context binding) and `verify` drops directly into `ServerOptions.requestState.verify`. WebCrypto-based and runtime-neutral; verification is fail-closed and constant-time. The `ServerOptions.requestState.verify` hook's return type is widened to `unknown | Promise<unknown>` so the codec's `verify` is directly assignable; the seam captures the hook's resolved value (the decoded payload) and hands it to handlers via the typed `ctx.mcpReq.requestState<T>()` accessor.
|
||||
|
|
|
|||
10
.changeset/legacy-input-required-shim.md
Normal file
10
.changeset/legacy-input-required-shim.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
'@modelcontextprotocol/server': minor
|
||||
'@modelcontextprotocol/core-internal': minor
|
||||
---
|
||||
|
||||
Serve `input_required` handlers on 2025-era connections: the legacy shim (on by default) converts each embedded request of an `input_required` return into a real server→client request (`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — stamped with the originating request's id for stream association — and re-enters the handler with the collected `inputResponses` until a final result. Handlers are written once in the 2026 `inputRequired(...)` style and serve both eras; the previous loud `-32603` failure remains available via `ServerOptions.inputRequired.legacyShim: false`. Knobs: `inputRequired.maxRounds` (default 8) and `inputRequired.roundTimeoutMs` (default 600 000 ms per leg; legs carry a progressToken so a client reporting progress mid-leg resets the leg timeout). Semantics mirror the modern client driver exactly: per-round replaced `inputResponses`, byte-exact `requestState` echo with the verify hook running every round, paced requestState-only rounds, and elicitation accepted content passed through UNVALIDATED (the handler validates via the schema-aware `acceptedContent` overload, exactly as on the 2026 era). URL-mode legs synthesize the `elicitationId` the 2025-11-25 wire requires. Failures surface per family (`tools/call` → `isError` tool result; `prompts/get` / `resources/read` → JSON-RPC error); stateless legacy HTTP degrades to a clean capability refusal; the shim emits no progress of its own (the originating progressToken is the handler's single must-increase stream — the shim never adds a second author to it).
|
||||
|
||||
`ctx.mcpReq.requestState` is now a typed accessor: `ctx.mcpReq.requestState<T>()` returns the payload the configured `requestState.verify` hook resolved with (e.g. `createRequestStateCodec.verify` — the hook's return value is now load-bearing; verifiers that are not also decoders should resolve `undefined`), the raw wire string when no hook is configured, or `undefined` when the round carried no state. Code that read the property directly becomes a call: `ctx.mcpReq.requestState` → `ctx.mcpReq.requestState<string>()`. Note the member is now always present (a function), so truthiness no longer means "has state", and it is dropped by JSON serialization of the context.
|
||||
|
||||
New typed readers for `inputResponses`, exported from `@modelcontextprotocol/server`: a schema-aware `acceptedContent(responses, key, schema)` overload (validates untrusted accepted content against any synchronous Standard Schema) and `inputResponse(responses, key)` (discriminated `missing | elicit | sampling | roots` view, for decline/cancel detection and the non-elicitation kinds). Content conveniences like text extraction stay in application code as one-liners over the discriminated view.
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
Add the server side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). Handlers for `tools/call`, `prompts/get`, and `resources/read` can return the value built by `inputRequired()` (exported from the server package together with `acceptedContent()`)
|
||||
to request additional client input in-band; the structured-content requirement and the tools/call result-schema validation are skipped for that return, the encode seam emits it as `resultType: 'input_required'`, and the handler reads the responses on re-entry from
|
||||
`ctx.mcpReq.inputResponses` (with non-bare entries reported via `ctx.mcpReq.droppedInputResponseKeys`). The seam re-checks the at-least-one rule for hand-built results, checks every embedded request against the capabilities the client declared on that request's envelope
|
||||
(answering the typed `-32021` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method or toward a 2025-era request. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request
|
||||
(answering the typed `-32021` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method. Toward a 2025-era request the return is served by the default-on legacy shim (real server→client requests plus handler re-entry); the loud failure for that case remains available via `ServerOptions.inputRequired.legacyShim: false`. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request
|
||||
fails as an internal error with a clear steer to `inputRequired.elicitUrl(...)`, so the `-32042` error never reaches the 2026-07-28 wire; 2025-era serving keeps today's `-32042` behavior
|
||||
exactly. The typed local error raised when push-style server-to-client request APIs are used while serving a 2026-era request now steers to `inputRequired(...)`. Tool, prompt, and resource callback types accept the new return alongside their existing result types; 2025-era
|
||||
wire behavior is unchanged. An optional `ServerOptions.requestState.verify` hook lets a server integrity-check the echoed `requestState` before the handler runs — a throw answers the wire-level `-32602` Invalid Params error with `data.reason: 'invalid_request_state'`; the SDK provides no default verification.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ below.
|
|||
- [Per-era wire codecs](#per-era-wire-codecs)
|
||||
- [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types)
|
||||
- [Multi-round-trip requests](#multi-round-trip-requests)
|
||||
- [Legacy shim for `input_required`](#legacy-shim-for-input_required)
|
||||
- [`subscriptions/listen`](#subscriptionslisten)
|
||||
- [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243)
|
||||
- [Cache fields and cache hints](#cache-fields-and-cache-hints)
|
||||
|
|
@ -222,7 +223,10 @@ The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh
|
|||
request and there is no `Mcp-Session-Id`. If your v1 server kept state keyed on the
|
||||
session id (`ctx.sessionId` / `extra.sessionId`), the 2026 answer is `requestState`: an
|
||||
opaque string the server returns with `inputRequired(...)` and the client echoes
|
||||
byte-for-byte on the retry. Read it at `ctx.mcpReq.requestState`.
|
||||
byte-for-byte on the retry. Read it back with the typed accessor
|
||||
`ctx.mcpReq.requestState<T>()` — it returns the payload your configured verify hook
|
||||
decoded (see below), the raw wire string when no hook is configured, or `undefined`
|
||||
when the round carried no state.
|
||||
|
||||
`requestState` round-trips through the client and is therefore **untrusted input** —
|
||||
integrity-protect it (HMAC / AEAD over the payload, bound to principal, originating
|
||||
|
|
@ -232,9 +236,53 @@ method/parameters, and an expiry) and reject failed verification on re-entry. Co
|
|||
The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns
|
||||
`{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify`
|
||||
is exactly the function you assign to the hook. The codec is **signed, not encrypted**
|
||||
(the client can base64url-decode the payload). See `examples/mrtr/server.ts` and
|
||||
(the client can base64url-decode the payload). `mint<T>` and
|
||||
`ctx.mcpReq.requestState<T>()` are the typed encode/read pair: the seam captures what
|
||||
`verify` returns and the accessor hands it to the handler already decoded — no second
|
||||
`verify` call. See `examples/mrtr/server.ts` and
|
||||
[Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape.
|
||||
|
||||
**Multi-step flows: the phase switch.** `inputResponses` are **per round** — each retry
|
||||
carries only that round's responses, never earlier rounds' (the modern client driver
|
||||
and the [legacy shim](#legacy-shim-for-input_required) both guarantee replace, not
|
||||
accumulate). A flow with more than one input round therefore threads everything it has
|
||||
learned through `requestState`, as a discriminated union of phases, and switches on the
|
||||
phase rather than probing which response keys arrived:
|
||||
|
||||
```typescript
|
||||
type BrainstormState =
|
||||
| { step: 'awaiting-count' }
|
||||
| { step: 'awaiting-custom-count'; topic: string }
|
||||
| { step: 'awaiting-ideas'; topic: string; count: number };
|
||||
|
||||
const stateCodec = createRequestStateCodec<BrainstormState>({ key: SECRET });
|
||||
// ServerOptions: { requestState: { verify: stateCodec.verify } }
|
||||
|
||||
async (args, ctx) => {
|
||||
const state = ctx.mcpReq.requestState<BrainstormState>();
|
||||
switch (state?.step) {
|
||||
case undefined: // first call — ask for the count
|
||||
return inputRequired({
|
||||
inputRequests: { count: inputRequired.elicit({ … }) },
|
||||
requestState: await stateCodec.mint({ step: 'awaiting-count' })
|
||||
});
|
||||
case 'awaiting-count': {
|
||||
const accepted = acceptedContent(ctx.mcpReq.inputResponses, 'count', COUNT_SCHEMA);
|
||||
// …decide: follow-up question or the sampling round, carrying
|
||||
// everything learned so far inside the next minted state…
|
||||
}
|
||||
case 'awaiting-ideas': {
|
||||
const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas');
|
||||
return finish(ideas.kind === 'sampling' ? ideas.result : undefined, state.count, state.topic);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Each `case` knows exactly which answer to read and which data is in scope — the state
|
||||
machine is explicit, and the same handler runs unchanged on 2025-era connections
|
||||
through the legacy shim.
|
||||
|
||||
---
|
||||
|
||||
## Auth on 2026-07-28
|
||||
|
|
@ -322,13 +370,13 @@ The protocol layer enforces the same boundary at runtime:
|
|||
(typed `Partial<RequestMetaEnvelope>`); for notifications there is no per-message
|
||||
context, so lifted envelope keys are dropped. On requests only, `inputResponses` /
|
||||
`requestState` are lifted from top-level params to `ctx.mcpReq.inputResponses` /
|
||||
`ctx.mcpReq.requestState`; notification params are never touched.
|
||||
the `ctx.mcpReq.requestState()` accessor; notification params are never touched.
|
||||
- **Collision note for 2025-era peers.** The `_meta` lift is invisible to conforming
|
||||
2025 traffic (the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too).
|
||||
The retry-field lift is the one collision: 2025-11-25 does not reserve the bare names
|
||||
`inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that uses
|
||||
them as ordinary top-level params has them lifted out of `request.params` (still
|
||||
readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`).
|
||||
readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`).
|
||||
- **Raw-first result discrimination.** On a 2026-era exchange, `'complete'` is consumed
|
||||
and stripped; `'input_required'` is fulfilled by the client's auto-fulfilment driver;
|
||||
any other kind rejects with `SdkError(UnsupportedResultType)` (kind in
|
||||
|
|
@ -361,7 +409,7 @@ client retries the original call with the responses.
|
|||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry |
|
||||
| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` |
|
||||
| handler shared across both eras | branch on the served era: keep the push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests |
|
||||
| handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests |
|
||||
|
||||
`inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from
|
||||
`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs
|
||||
|
|
@ -383,6 +431,97 @@ opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manu
|
|||
call with `allowInputRequired: true` plus `withInputRequired()`. Expect
|
||||
`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted.
|
||||
|
||||
**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a
|
||||
structural read with an unvalidated cast), two typed readers ship from
|
||||
`@modelcontextprotocol/server`:
|
||||
|
||||
- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous
|
||||
Standard Schema, e.g. a zod object): validates the untrusted accepted content and
|
||||
returns it typed, or `undefined` on mismatch/decline/missing.
|
||||
- `inputResponse(responses, key)` — discriminated view
|
||||
(`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`)
|
||||
for decline/cancel detection and the non-elicitation kinds.
|
||||
|
||||
Content conveniences stay in your code — e.g. the text of a sampling response is a
|
||||
one-liner over the discriminated view:
|
||||
|
||||
```typescript
|
||||
const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas');
|
||||
const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined;
|
||||
const text = block?.type === 'text' ? block.text : undefined;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy shim for `input_required`
|
||||
|
||||
An `input_required` return on a **2025-era** connection is served by the SDK's legacy
|
||||
shim, on by default: each embedded request is sent as a real server→client request
|
||||
(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session —
|
||||
stamped with the originating request's id, so on sessionful Streamable HTTP the
|
||||
requests ride the originating POST's stream — and the handler is re-entered with the
|
||||
collected `inputResponses` until it returns a final result. Handlers are **written
|
||||
once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs
|
||||
remain available for code that still calls them directly.
|
||||
|
||||
The handler cannot tell which era fulfilled it — the shim mirrors the modern client
|
||||
driver's semantics exactly:
|
||||
|
||||
- `inputResponses` are **per round** (replaced on every re-entry, never accumulated);
|
||||
multi-step flows thread earlier answers through `requestState`.
|
||||
- `requestState` is echoed byte-exact, and the configured
|
||||
`ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would
|
||||
on a modern wire retry (so TTL expiry behaves identically; a rejection answers the
|
||||
frozen `-32602`).
|
||||
- Responses arrive as the bare result objects, era-wire-shape-validated only:
|
||||
elicitation accepted content is NOT re-checked against `requestedSchema` —
|
||||
exactly as on the modern era — so the handler validates with the
|
||||
schema-aware `acceptedContent(responses, key, schema)` overload and can
|
||||
re-issue the request instead of the call dying on a mistyped form field.
|
||||
- Rounds with no embedded requests (requestState-only) are paced at 250ms.
|
||||
- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the
|
||||
2025-11-25 wire requires one; the 2026 in-band shape has none).
|
||||
|
||||
Knobs live at `ServerOptions.inputRequired`:
|
||||
|
||||
| Member | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow |
|
||||
| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply |
|
||||
| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern |
|
||||
|
||||
Failures surface **per family**: `tools/call` failures (capability refusal, a failed
|
||||
leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts
|
||||
already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC
|
||||
errors. Server bugs (malformed input-required results) fail loudly on both eras.
|
||||
|
||||
The shim emits no progress of its own. The originating request's `progressToken`
|
||||
identifies a single must-increase stream that belongs to the handler — injecting
|
||||
synthetic ticks into it cannot compose with handler-emitted progress (one stream,
|
||||
one author), so the shim never writes to it: a 2025 client watching a multi-round
|
||||
flow sees exactly what a hand-written 2025 push-style handler would have produced.
|
||||
A handler that reports progress across rounds should derive its values from its
|
||||
phase state so they increase across re-entries — the token spans the whole flow.
|
||||
|
||||
**Inherited limits** (the same ones hand-written push-style handlers have today):
|
||||
|
||||
- The shim pre-checks each embedded request kind against the client capabilities
|
||||
declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration
|
||||
counts as form support — the pre-mode meaning, same as the modern `-32021` gate).
|
||||
Capability-less clients get a clean refusal, never a hang.
|
||||
- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a
|
||||
fresh instance per request: no initialize handshake, no return path for
|
||||
server→client requests. The shim degrades to the clean capability refusal there —
|
||||
full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring.
|
||||
- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client
|
||||
requests mid-call: the transport drops them, so a shim leg waits out
|
||||
`roundTimeoutMs` before failing per family — the same undeliverable class as
|
||||
today's `elicitInput` in that configuration, which waits out its own 60s
|
||||
default. Interactive tools need a streaming-capable session.
|
||||
- The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation
|
||||
is not bridged (upstream gap F8): URL-mode legs complete like any other elicitation
|
||||
response.
|
||||
|
||||
---
|
||||
|
||||
## `subscriptions/listen`
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export type {
|
|||
ProtocolOptions,
|
||||
RequestHandlerSchemas,
|
||||
RequestOptions,
|
||||
RequestStateAccessor,
|
||||
ServerContext
|
||||
} from '../../shared/protocol';
|
||||
export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol';
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@
|
|||
* `prompts/get`, `resources/read`) requests additional client input by
|
||||
* returning an {@linkcode InputRequiredResult} instead of a final result. The
|
||||
* helpers here build that return value and its embedded requests as NEUTRAL
|
||||
* values; only the 2026-07-28 wire codec maps them to/from the wire (the
|
||||
* values; only the 2026-07-28 wire codec maps them to/from the wire. The
|
||||
* 2025-era codec has no input-required vocabulary — on a 2025-era request the
|
||||
* server seam fails such a return loudly; a handler that serves both eras
|
||||
* branches on the served era and uses the push-style APIs toward 2025-era
|
||||
* requests).
|
||||
* server's legacy shim (on by default) fulfils the embedded requests as real
|
||||
* server→client requests and re-enters the handler, so the same return shape
|
||||
* serves both eras; `ServerOptions.inputRequired.legacyShim: false` restores
|
||||
* the pre-shim loud failure.
|
||||
*
|
||||
* There is no nominal brand: `resultType: 'input_required'` is the
|
||||
* discriminator, and hand-built result literals are equally legal — the
|
||||
|
|
@ -19,13 +20,15 @@
|
|||
import { isInputRequiredResult } from '../types/guards';
|
||||
import type {
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
CreateMessageResultWithTools,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
InputRequest,
|
||||
InputRequests,
|
||||
InputRequiredResult,
|
||||
InputResponses
|
||||
InputResponses,
|
||||
Root
|
||||
} from '../types/types';
|
||||
import type { StandardSchemaV1 } from '../util/standardSchema';
|
||||
|
||||
|
|
@ -144,14 +147,87 @@ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequi
|
|||
export function acceptedContent<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
responses: InputResponses | Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): T | undefined {
|
||||
if (responses === undefined || typeof responses !== 'object' || responses === null) return undefined;
|
||||
): T | undefined;
|
||||
|
||||
/**
|
||||
* Schema-aware overload: validates the accepted content against the given
|
||||
* schema (any Standard Schema, e.g. a zod object) before returning it, so the
|
||||
* untrusted client value arrives in the handler already validated and typed.
|
||||
*
|
||||
* Returns `undefined` when the response is missing/declined/of another kind
|
||||
* (as the two-argument form does) AND when the accepted content fails schema
|
||||
* validation — handlers treat both the same way (re-issue the request or
|
||||
* give up). Only synchronous schemas are supported (zod schemas without async
|
||||
* refinements are synchronous); an asynchronously-validating schema throws a
|
||||
* `TypeError`.
|
||||
*/
|
||||
export function acceptedContent<S extends StandardSchemaV1>(
|
||||
responses: InputResponses | Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
schema: S
|
||||
): StandardSchemaV1.InferOutput<S> | undefined;
|
||||
|
||||
export function acceptedContent(
|
||||
responses: InputResponses | Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
schema?: StandardSchemaV1
|
||||
): unknown {
|
||||
const view = inputResponse(responses, key);
|
||||
if (view.kind !== 'elicit' || view.action !== 'accept' || view.content === undefined) return undefined;
|
||||
if (schema === undefined) return view.content;
|
||||
const outcome = schema['~standard'].validate(view.content);
|
||||
if (outcome instanceof Promise) {
|
||||
throw new TypeError('acceptedContent(responses, key, schema) requires a synchronously-validating schema');
|
||||
}
|
||||
return outcome.issues === undefined ? outcome.value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The discriminated view {@linkcode inputResponse} returns: which kind of
|
||||
* embedded response (if any) a retried request carried for a key. Bare
|
||||
* response objects are discriminated structurally — an `action` member means
|
||||
* an elicitation result, a `roots` array a roots listing, a `role` + `content`
|
||||
* pair a sampling result. A missing key or an entry that matches none of the
|
||||
* three shapes reads as `{ kind: 'missing' }`.
|
||||
*/
|
||||
export type InputResponseView =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'elicit'; action: 'accept' | 'decline' | 'cancel'; content?: Record<string, unknown> }
|
||||
| { kind: 'sampling'; result: CreateMessageResult | CreateMessageResultWithTools }
|
||||
| { kind: 'roots'; roots: Root[] };
|
||||
|
||||
/**
|
||||
* Reads one entry of a retried request's `inputResponses`
|
||||
* (`ctx.mcpReq.inputResponses`) as a discriminated view, covering
|
||||
* decline/cancel detection and the non-elicitation response kinds that
|
||||
* {@linkcode acceptedContent} does not surface.
|
||||
*
|
||||
* The values arrive from the client and are not re-validated here — treat
|
||||
* them as untrusted input (validate elicitation content with the
|
||||
* schema-aware {@linkcode acceptedContent} overload where it matters).
|
||||
*/
|
||||
export function inputResponse(responses: InputResponses | Record<string, unknown> | undefined, key: string): InputResponseView {
|
||||
if (responses === undefined || typeof responses !== 'object' || responses === null) return { kind: 'missing' };
|
||||
const entry = (responses as Record<string, unknown>)[key];
|
||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
|
||||
const candidate = entry as Partial<ElicitResult> & Record<string, unknown>;
|
||||
if (candidate.action !== 'accept') return undefined;
|
||||
if (candidate.content === undefined || typeof candidate.content !== 'object' || candidate.content === null) return undefined;
|
||||
return candidate.content as T;
|
||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return { kind: 'missing' };
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
if (candidate['action'] === 'accept' || candidate['action'] === 'decline' || candidate['action'] === 'cancel') {
|
||||
const content = candidate['content'];
|
||||
return {
|
||||
kind: 'elicit',
|
||||
action: candidate['action'],
|
||||
...(content !== null &&
|
||||
typeof content === 'object' &&
|
||||
!Array.isArray(content) && { content: content as Record<string, unknown> })
|
||||
};
|
||||
}
|
||||
if (Array.isArray(candidate['roots'])) {
|
||||
return { kind: 'roots', roots: candidate['roots'] as Root[] };
|
||||
}
|
||||
if (typeof candidate['role'] === 'string' && candidate['content'] !== undefined) {
|
||||
return { kind: 'sampling', result: candidate as unknown as CreateMessageResult | CreateMessageResultWithTools };
|
||||
}
|
||||
return { kind: 'missing' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -141,12 +141,23 @@ export function buildInputRequiredRetryParams(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The message both multi-round-trip loops emit when the round cap is
|
||||
* exhausted — the client driver as a typed error, the server-side legacy
|
||||
* shim as its per-family failure. One formatter so the texts cannot drift
|
||||
* (hosts and models read the tool-result copy verbatim).
|
||||
*/
|
||||
export function inputRequiredRoundsExceededMessage(method: string, maxRounds: number): string {
|
||||
return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abortable delay: resolves after `ms`, or rejects with the signal's reason
|
||||
* (wrapped in an `SdkError` when it isn't already one) if the signal aborts
|
||||
* first. Aborting after resolution is a no-op.
|
||||
* first. Aborting after resolution is a no-op. Shared with the server-side
|
||||
* legacy shim (the pacing semantics must match per era).
|
||||
*/
|
||||
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
export function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason)));
|
||||
|
|
@ -167,9 +178,14 @@ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|||
/**
|
||||
* A per-round abort linked to the caller's signal: the embedded sibling
|
||||
* dispatches share it, so the first failure (or a caller abort) cancels the
|
||||
* others instead of leaving them running.
|
||||
* others instead of leaving them running. Shared with the server-side legacy
|
||||
* shim (the abort-linkage semantics must match per era).
|
||||
*/
|
||||
function linkedRoundAbort(outer: AbortSignal | undefined): { signal: AbortSignal; abort: (reason: unknown) => void; dispose: () => void } {
|
||||
export function linkedRoundAbort(outer: AbortSignal | undefined): {
|
||||
signal: AbortSignal;
|
||||
abort: (reason: unknown) => void;
|
||||
dispose: () => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
const onOuterAbort = (): void => controller.abort(outer?.reason);
|
||||
outer?.addEventListener('abort', onOuterAbort, { once: true });
|
||||
|
|
@ -211,17 +227,13 @@ export async function runInputRequiredDriver(args: {
|
|||
while (true) {
|
||||
round += 1;
|
||||
if (round > config.maxRounds) {
|
||||
throw new SdkError(
|
||||
SdkErrorCode.InputRequiredRoundsExceeded,
|
||||
`Multi-round-trip request '${method}' still required input after ${config.maxRounds} rounds (inputRequired.maxRounds)`,
|
||||
{
|
||||
rounds: config.maxRounds,
|
||||
lastResult: {
|
||||
inputRequests: payload.inputRequests,
|
||||
...(payload.requestState !== undefined && { requestState: payload.requestState })
|
||||
}
|
||||
throw new SdkError(SdkErrorCode.InputRequiredRoundsExceeded, inputRequiredRoundsExceededMessage(method, config.maxRounds), {
|
||||
rounds: config.maxRounds,
|
||||
lastResult: {
|
||||
inputRequests: payload.inputRequests,
|
||||
...(payload.requestState !== undefined && { requestState: payload.requestState })
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Surface the round as synthetic progress: long interactive flows stay
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type {
|
|||
} from './inputRequiredDriver';
|
||||
import { runInputRequiredDriver } from './inputRequiredDriver';
|
||||
import type { BaseContext, NonCompleteResultFlow, RequestOptions } from './protocol';
|
||||
import { requestStateAccessor } from './protocol';
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
|
@ -88,6 +89,8 @@ export function synthesizeInputRequestContext(
|
|||
id: key,
|
||||
method,
|
||||
_meta: params?.['_meta'] as RequestMeta | undefined,
|
||||
// Embedded input requests never carry multi-round-trip state.
|
||||
requestState: requestStateAccessor(undefined),
|
||||
signal,
|
||||
send: (() => relatedMessagingUnavailable('send')) as BaseContext['mcpReq']['send'],
|
||||
notify: () => relatedMessagingUnavailable('notify')
|
||||
|
|
|
|||
|
|
@ -283,6 +283,43 @@ function codecResultValidator(codec: WireCodec, method: string): StandardSchemaV
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of `ctx.mcpReq.requestState`. The type parameter is
|
||||
* caller-asserted (no validation, like the two-argument `acceptedContent<T>`)
|
||||
* — pair with `createRequestStateCodec<T>` so the claim is backed by the
|
||||
* codec's verification.
|
||||
*/
|
||||
export type RequestStateAccessor = <T = unknown>() => T | undefined;
|
||||
|
||||
/**
|
||||
* Builds the `ctx.mcpReq.requestState` accessor for a resolved value. The
|
||||
* `as T` below is the one place {@linkcode RequestStateAccessor}'s
|
||||
* caller-asserted typing is implemented — no implementation can produce an
|
||||
* arbitrary `T` from a runtime value honestly.
|
||||
*/
|
||||
export function requestStateAccessor(value: unknown): RequestStateAccessor {
|
||||
return <T>(): T | undefined => value as T | undefined;
|
||||
}
|
||||
|
||||
/** Shared no-state accessor: the common case allocates nothing per request. */
|
||||
const NO_REQUEST_STATE = requestStateAccessor(undefined);
|
||||
|
||||
/**
|
||||
* Returns a context whose `requestState` accessor reads the given value —
|
||||
* how the server seam hands a verify hook's decoded payload (or the legacy
|
||||
* shim's per-round echo) to the handler without mutating the original
|
||||
* context.
|
||||
*/
|
||||
export function withRequestStateValue<ContextT extends BaseContext>(ctx: ContextT, value: unknown): ContextT {
|
||||
return {
|
||||
...ctx,
|
||||
mcpReq: {
|
||||
...ctx.mcpReq,
|
||||
requestState: requestStateAccessor(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base context provided to all request handlers.
|
||||
*/
|
||||
|
|
@ -346,19 +383,22 @@ export type BaseContext = {
|
|||
droppedInputResponseKeys?: string[];
|
||||
|
||||
/**
|
||||
* Multi-round-trip request state echoed by a retried request
|
||||
* (protocol revision 2026-07-28), lifted out of the params the
|
||||
* handler sees. Driver material — present verbatim when sent.
|
||||
* Reads the multi-round-trip request state for the current round:
|
||||
* the value the configured `ServerOptions.requestState.verify` hook
|
||||
* resolved with (e.g. `createRequestStateCodec.verify`'s decoded
|
||||
* payload — `mint<T>`/`requestState<T>()` are the typed pair), the
|
||||
* raw wire string when no hook is configured, or `undefined` when
|
||||
* the round carried no state. The type parameter is a compile-time
|
||||
* cast only.
|
||||
*
|
||||
* SECURITY: `requestState` round-trips through the client and MUST be
|
||||
* treated as attacker-controlled input. The SDK applies no integrity
|
||||
* protection: if this value influences authorization, resource
|
||||
* access, or business logic, the server MUST integrity-protect it
|
||||
* (e.g. HMAC or AEAD) when minting it and MUST verify it here,
|
||||
* rejecting state that fails verification (spec:
|
||||
* SECURITY: `requestState` round-trips through the client and MUST
|
||||
* be treated as attacker-controlled input. The SDK applies no
|
||||
* integrity protection by default — servers whose state influences
|
||||
* authorization or business logic MUST integrity-protect it and
|
||||
* verify via the `requestState.verify` hook (spec:
|
||||
* basic/patterns/mrtr, server requirements 4–5).
|
||||
*/
|
||||
requestState?: string;
|
||||
requestState: RequestStateAccessor;
|
||||
|
||||
/**
|
||||
* An abort signal used to communicate if the request was cancelled from the sender's side.
|
||||
|
|
@ -1020,7 +1060,11 @@ export abstract class Protocol<ContextT extends BaseContext> {
|
|||
partitionedInputResponses.droppedKeys.length > 0 && {
|
||||
droppedInputResponseKeys: partitionedInputResponses.droppedKeys
|
||||
}),
|
||||
...(lifted.requestState !== undefined && { requestState: lifted.requestState }),
|
||||
// The accessor surfaces the raw lifted value (captured as the
|
||||
// string primitive so the lift record stays collectable); the
|
||||
// server seam swaps in the verify hook's decoded payload
|
||||
// before the handler runs.
|
||||
requestState: lifted.requestState === undefined ? NO_REQUEST_STATE : requestStateAccessor(lifted.requestState),
|
||||
signal: abortController.signal,
|
||||
// BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow
|
||||
// literals can't carry overload signatures, so the inferred single-signature type isn't assignable to
|
||||
|
|
|
|||
|
|
@ -636,8 +636,10 @@ export interface InputResponses {
|
|||
* authorization, resource access, or business logic, the server MUST protect
|
||||
* its integrity (e.g. HMAC or AEAD) and MUST reject state that fails
|
||||
* verification (spec: basic/patterns/mrtr §Server Requirements). The SDK
|
||||
* surfaces it raw at `ctx.mcpReq.requestState` and applies no integrity
|
||||
* protection of its own.
|
||||
* applies no integrity protection by default — without a configured
|
||||
* `ServerOptions.requestState.verify` hook, `ctx.mcpReq.requestState()`
|
||||
* returns the raw, unverified string; with one, the seam rejects state the
|
||||
* hook refuses and the accessor returns the hook's decoded payload.
|
||||
*/
|
||||
export interface InputRequiredResult extends Result {
|
||||
resultType: 'input_required';
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ describe('inbound retry material (T1/D-059)', () => {
|
|||
const mcpReq = seen[0]!;
|
||||
expect(mcpReq.inputResponses).toEqual({ bare: { action: 'accept', content: { ok: true } } });
|
||||
expect(mcpReq.droppedInputResponseKeys?.sort()).toEqual(['not-an-object', 'wrapped']);
|
||||
expect(mcpReq.requestState).toBe('echoed-back');
|
||||
expect(mcpReq.requestState()).toBe('echoed-back');
|
||||
// The handler-visible params never carry the lifted retry material.
|
||||
await receiver.close();
|
||||
await clientTx.close();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Typed readers for a retried request's `inputResponses`
|
||||
* (`ctx.mcpReq.inputResponses`): the schema-aware `acceptedContent` overload
|
||||
* and the discriminated `inputResponse` view.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import { acceptedContent, inputResponse } from '../../src/shared/inputRequired';
|
||||
|
||||
const ACCEPTED = { action: 'accept', content: { count: '10', theme: 'release week' } };
|
||||
const DECLINED = { action: 'decline' };
|
||||
const SAMPLING = { role: 'assistant', content: { type: 'text', text: 'idea-1' }, model: 'test-model' };
|
||||
const ROOTS = { roots: [{ uri: 'file:///ws', name: 'ws' }] };
|
||||
|
||||
describe('acceptedContent schema overload', () => {
|
||||
const schema = z.object({ count: z.string(), theme: z.string().optional() });
|
||||
|
||||
it('returns validated, typed content for an accepted response', () => {
|
||||
const content = acceptedContent({ key: ACCEPTED }, 'key', schema);
|
||||
expect(content).toEqual({ count: '10', theme: 'release week' });
|
||||
});
|
||||
|
||||
it('returns undefined when validation fails (malformed untrusted content never reaches the handler typed)', () => {
|
||||
expect(acceptedContent({ key: { action: 'accept', content: { count: 42 } } }, 'key', schema)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for declined, missing, and non-elicit entries (same as the 2-arg form)', () => {
|
||||
expect(acceptedContent({ key: DECLINED }, 'key', schema)).toBeUndefined();
|
||||
expect(acceptedContent({}, 'key', schema)).toBeUndefined();
|
||||
expect(acceptedContent(undefined, 'key', schema)).toBeUndefined();
|
||||
expect(acceptedContent({ key: SAMPLING }, 'key', schema)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies schema transforms (the output type, not the input shape)', () => {
|
||||
const coercing = z.object({ count: z.string().transform(value => Number.parseInt(value, 10)) });
|
||||
expect(acceptedContent({ key: ACCEPTED }, 'key', coercing)).toEqual({ count: 10 });
|
||||
});
|
||||
|
||||
it('throws on an asynchronously-validating schema', () => {
|
||||
const asyncSchema = z.object({ count: z.string() }).refine(async () => true);
|
||||
expect(() => acceptedContent({ key: ACCEPTED }, 'key', asyncSchema)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('the 2-arg form is unchanged (structural read, unvalidated cast)', () => {
|
||||
expect(acceptedContent<{ count: string }>({ key: ACCEPTED }, 'key')).toEqual({ count: '10', theme: 'release week' });
|
||||
expect(acceptedContent({ key: DECLINED }, 'key')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inputResponse discriminated view', () => {
|
||||
it('discriminates elicitation responses with action and content', () => {
|
||||
expect(inputResponse({ key: ACCEPTED }, 'key')).toEqual({
|
||||
kind: 'elicit',
|
||||
action: 'accept',
|
||||
content: { count: '10', theme: 'release week' }
|
||||
});
|
||||
expect(inputResponse({ key: DECLINED }, 'key')).toEqual({ kind: 'elicit', action: 'decline' });
|
||||
expect(inputResponse({ key: { action: 'cancel' } }, 'key')).toEqual({ kind: 'elicit', action: 'cancel' });
|
||||
});
|
||||
|
||||
it('discriminates sampling and roots responses', () => {
|
||||
expect(inputResponse({ key: SAMPLING }, 'key')).toEqual({ kind: 'sampling', result: SAMPLING });
|
||||
expect(inputResponse({ key: ROOTS }, 'key')).toEqual({ kind: 'roots', roots: ROOTS.roots });
|
||||
});
|
||||
|
||||
it('reads missing keys and malformed entries as missing', () => {
|
||||
expect(inputResponse({}, 'key')).toEqual({ kind: 'missing' });
|
||||
expect(inputResponse(undefined, 'key')).toEqual({ kind: 'missing' });
|
||||
expect(inputResponse({ key: 'not-an-object' }, 'key')).toEqual({ kind: 'missing' });
|
||||
expect(inputResponse({ key: { action: 'something-else' } }, 'key')).toEqual({ kind: 'missing' });
|
||||
expect(inputResponse({ key: null }, 'key')).toEqual({ kind: 'missing' });
|
||||
expect(inputResponse({ key: [1, 2] }, 'key')).toEqual({ kind: 'missing' });
|
||||
});
|
||||
});
|
||||
|
|
@ -184,7 +184,7 @@ describe('envelope lift on inbound requests', () => {
|
|||
params: { name: 'echo', arguments: {} }
|
||||
});
|
||||
expect(seenCtx?.mcpReq.inputResponses).toEqual(inputResponses);
|
||||
expect(seenCtx?.mcpReq.requestState).toBe('opaque-state-token');
|
||||
expect(seenCtx?.mcpReq.requestState()).toBe('opaque-state-token');
|
||||
});
|
||||
|
||||
test('the custom-method (3-arg) path also surfaces the envelope via ctx', async () => {
|
||||
|
|
@ -255,7 +255,7 @@ describe('envelope lift on inbound requests', () => {
|
|||
expect(seenRequest).toBe(legacy);
|
||||
expect(seenCtx?.mcpReq.envelope).toBeUndefined();
|
||||
expect(seenCtx?.mcpReq.inputResponses).toBeUndefined();
|
||||
expect(seenCtx?.mcpReq.requestState).toBeUndefined();
|
||||
expect(seenCtx?.mcpReq.requestState()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -82,9 +82,10 @@ export type { CacheHint, CacheScope } from '@modelcontextprotocol/core-internal'
|
|||
|
||||
// Multi round-trip requests (protocol revision 2026-07-28): the authoring
|
||||
// helpers a handler uses to request additional client input by returning an
|
||||
// input-required result instead of sending a server→client request.
|
||||
export type { InputRequiredSpec } from '@modelcontextprotocol/core-internal';
|
||||
export { acceptedContent, inputRequired } from '@modelcontextprotocol/core-internal';
|
||||
// input-required result instead of sending a server→client request, and the
|
||||
// typed readers for the responses a retried request carries back.
|
||||
export type { InputRequiredSpec, InputResponseView } from '@modelcontextprotocol/core-internal';
|
||||
export { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
// re-export curated public API from core
|
||||
export * from '@modelcontextprotocol/core-internal/public';
|
||||
|
|
|
|||
311
packages/server/src/server/legacyInputRequiredShim.ts
Normal file
311
packages/server/src/server/legacyInputRequiredShim.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* The legacy `input_required` shim: serves write-once handlers on 2025-era
|
||||
* sessions. `Server` holds one instance and delegates to it from the
|
||||
* multi-round-trip seam when a handler returns an input-required result on a
|
||||
* 2025-era request: each embedded request goes out as a real server→client
|
||||
* request (`elicitation/create` / `sampling/createMessage` / `roots/list`,
|
||||
* stamped with the originating request id for stream association), and the
|
||||
* handler is re-entered with the collected `inputResponses` until it returns
|
||||
* a final result or the round cap is exhausted.
|
||||
*
|
||||
* Semantics mirror the modern client driver so a handler cannot tell which
|
||||
* era fulfilled it: per-round REPLACED `inputResponses`, byte-exact
|
||||
* `requestState` echo (re-verified by the configured hook each round), paced
|
||||
* requestState-only rounds. The loop lives inside the originating request's
|
||||
* lifetime — nothing is parked, cancellation chains through every leg.
|
||||
* Failures surface per family: tools/call → `isError` tool results,
|
||||
* prompts/resources → JSON-RPC errors; malformed results fail loudly as
|
||||
* server bugs. Package-internal — not exported from the index.
|
||||
*/
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
CreateMessageRequest,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestURLParams,
|
||||
JSONRPCRequest,
|
||||
RequestOptions,
|
||||
Result,
|
||||
ServerContext
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
import {
|
||||
inputRequiredRoundsExceededMessage,
|
||||
isInputRequiredResult,
|
||||
linkedRoundAbort,
|
||||
missingClientCapabilities,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
REQUEST_STATE_ONLY_LEG_PACING_MS,
|
||||
requestStateAccessor,
|
||||
requiredClientCapabilitiesForInputRequest,
|
||||
sleep,
|
||||
withRequestStateValue
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
|
||||
/**
|
||||
* Default handler re-entries per originating request — tighter than the
|
||||
* client driver's 10 because the shim holds a live wire request open.
|
||||
*/
|
||||
const DEFAULT_LEGACY_SHIM_MAX_ROUNDS = 8;
|
||||
|
||||
/** Default per-leg timeout: legs are human-paced, so the 60s protocol default is wrong. */
|
||||
const DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 600_000;
|
||||
|
||||
/** The `ServerOptions.inputRequired` bag with defaults applied. */
|
||||
export interface ResolvedLegacyShimOptions {
|
||||
maxRounds: number;
|
||||
roundTimeoutMs: number;
|
||||
legacyShim: boolean;
|
||||
}
|
||||
|
||||
/** Resolves and validates `ServerOptions.inputRequired`, failing loudly at construction time. */
|
||||
export function resolveLegacyShimOptions(
|
||||
options: { maxRounds?: number; roundTimeoutMs?: number; legacyShim?: boolean } | undefined
|
||||
): ResolvedLegacyShimOptions {
|
||||
if (options?.maxRounds !== undefined && (!Number.isInteger(options.maxRounds) || options.maxRounds < 1)) {
|
||||
throw new RangeError(`inputRequired.maxRounds must be a positive integer (got ${options.maxRounds})`);
|
||||
}
|
||||
if (options?.roundTimeoutMs !== undefined && (!Number.isFinite(options.roundTimeoutMs) || options.roundTimeoutMs <= 0)) {
|
||||
throw new RangeError(`inputRequired.roundTimeoutMs must be a positive number (got ${options.roundTimeoutMs})`);
|
||||
}
|
||||
return {
|
||||
maxRounds: options?.maxRounds ?? DEFAULT_LEGACY_SHIM_MAX_ROUNDS,
|
||||
roundTimeoutMs: options?.roundTimeoutMs ?? DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS,
|
||||
legacyShim: options?.legacyShim ?? true
|
||||
};
|
||||
}
|
||||
|
||||
/** The embedded input-request kinds the 2026-07-28 revision defines. */
|
||||
export type EmbeddedInputRequestMethod = 'elicitation/create' | 'sampling/createMessage' | 'roots/list';
|
||||
|
||||
/** A coerced `inputRequests` entry: the kind-narrowed embedded request. */
|
||||
export interface CoercedEmbeddedInputRequest {
|
||||
method: EmbeddedInputRequestMethod;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one `inputRequests` entry: malformed or unknown kinds are server
|
||||
* bugs and fail loudly on both eras. Shared by the modern seam's capability
|
||||
* check and the shim's gate.
|
||||
*/
|
||||
export function coerceEmbeddedInputRequest(
|
||||
method: string,
|
||||
key: string,
|
||||
entry: unknown
|
||||
): { embedded: CoercedEmbeddedInputRequest; required: ClientCapabilities } {
|
||||
if (entry === null || typeof entry !== 'object' || typeof (entry as { method?: unknown }).method !== 'string') {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InternalError,
|
||||
`Handler for ${method} returned an invalid input request '${key}': each inputRequests entry must be an ` +
|
||||
`embedded elicitation/create, sampling/createMessage, or roots/list request`
|
||||
);
|
||||
}
|
||||
const embedded = entry as { method: string; params?: Record<string, unknown> };
|
||||
const required = requiredClientCapabilitiesForInputRequest(embedded);
|
||||
if (required === undefined) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InternalError,
|
||||
`Handler for ${method} returned an input request '${key}' of kind '${embedded.method}', which is not an ` +
|
||||
`embedded request the 2026-07-28 revision defines`
|
||||
);
|
||||
}
|
||||
return { embedded: embedded as CoercedEmbeddedInputRequest, required };
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2025-11-25 URL-mode wire shape requires an `elicitationId`; the 2026
|
||||
* in-band shape has none, so URL legs mint one (CSPRNG-backed, with a
|
||||
* getRandomValues fallback for runtimes without `randomUUID`).
|
||||
*/
|
||||
function syntheticElicitationId(): string {
|
||||
const webCrypto = globalThis.crypto;
|
||||
if (webCrypto?.randomUUID !== undefined) {
|
||||
return webCrypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
webCrypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/** Per-family surfacing: tools/call → isError result (the 2025 idiom); prompts/resources → JSON-RPC error. */
|
||||
function legacyShimFailure(method: string, message: string): Result {
|
||||
if (method === 'tools/call') {
|
||||
return { content: [{ type: 'text', text: message }], isError: true };
|
||||
}
|
||||
throw new ProtocolError(ProtocolErrorCode.InternalError, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the shim needs from `Server`: the knobs, the per-request
|
||||
* resolved capability view (initialize state on sessionful legacy; empty on
|
||||
* per-request stateless instances), the requestState verify runner
|
||||
* (deny-on-error → the frozen `-32602`), and the 2025-era senders. The
|
||||
* shim's own gate is authoritative; elicitation accepted content passes
|
||||
* through UNVALIDATED for parity with the modern client driver.
|
||||
*/
|
||||
export interface LegacyInputRequiredShimHost {
|
||||
readonly maxRounds: number;
|
||||
readonly roundTimeoutMs: number;
|
||||
resolvedClientCapabilities(ctx: ServerContext): ClientCapabilities | undefined;
|
||||
verifyRequestState(state: string, ctx: ServerContext, method: string): Promise<unknown>;
|
||||
sendElicitation(params: ElicitRequestFormParams | ElicitRequestURLParams, options: RequestOptions): Promise<unknown>;
|
||||
sendSampling(params: CreateMessageRequest['params'], options: RequestOptions): Promise<unknown>;
|
||||
listRoots(params: Record<string, unknown> | undefined, options: RequestOptions): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The fulfilment loop — see the module doc for the contract. */
|
||||
export class LegacyInputRequiredShim {
|
||||
constructor(private readonly _host: LegacyInputRequiredShimHost) {}
|
||||
|
||||
async fulfill(
|
||||
method: string,
|
||||
handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise<Result>,
|
||||
request: JSONRPCRequest,
|
||||
ctx: ServerContext,
|
||||
firstResult: Result
|
||||
): Promise<Result> {
|
||||
const { maxRounds, roundTimeoutMs } = this._host;
|
||||
const outerSignal = ctx.mcpReq.signal;
|
||||
let current = firstResult;
|
||||
let round = 0;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
round += 1;
|
||||
if (round > maxRounds) {
|
||||
return legacyShimFailure(method, inputRequiredRoundsExceededMessage(method, maxRounds));
|
||||
}
|
||||
|
||||
// At-least-one re-check per round (server bug → loud, as on modern).
|
||||
const inputRequests = current.inputRequests as Record<string, unknown> | null | undefined;
|
||||
const hasInputRequests = inputRequests != null && Object.keys(inputRequests).length > 0;
|
||||
const requestState = typeof current.requestState === 'string' ? current.requestState : undefined;
|
||||
if (!hasInputRequests && requestState === undefined) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InternalError,
|
||||
`Handler for ${method} returned an input-required result with neither inputRequests nor requestState ` +
|
||||
`(every InputRequiredResult must include at least one of the two)`
|
||||
);
|
||||
}
|
||||
|
||||
let responses: Record<string, unknown> | undefined;
|
||||
if (hasInputRequests) {
|
||||
// The shim's own capability pre-check (never gated on
|
||||
// enforceStrictCapabilities). The whole round gates before
|
||||
// any wire traffic, so a refusal has no side effects.
|
||||
const declared = this._host.resolvedClientCapabilities(ctx);
|
||||
const coerced: [string, CoercedEmbeddedInputRequest][] = [];
|
||||
for (const [key, entry] of Object.entries(inputRequests!)) {
|
||||
const { embedded, required } = coerceEmbeddedInputRequest(method, key, entry);
|
||||
// Request-carrying kinds need params; absent = server bug.
|
||||
if (embedded.method !== 'roots/list' && embedded.params === undefined) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InternalError,
|
||||
`Handler for ${method} returned an input request '${key}' of kind '${embedded.method}' without params`
|
||||
);
|
||||
}
|
||||
const missing = missingClientCapabilities(required, declared);
|
||||
if (missing !== undefined) {
|
||||
return legacyShimFailure(
|
||||
method,
|
||||
`Cannot request input '${key}' (${embedded.method}): the client on this 2025-era connection did not ` +
|
||||
`declare the required capability${declared === undefined ? ' (no client capabilities are available on this connection — per-request legacy serving cannot receive server-to-client requests)' : ''}`
|
||||
);
|
||||
}
|
||||
coerced.push([key, embedded]);
|
||||
}
|
||||
|
||||
// Fulfil concurrently (driver parity); first failure aborts siblings.
|
||||
const roundAbort = linkedRoundAbort(outerSignal);
|
||||
try {
|
||||
const legOptions: RequestOptions = {
|
||||
relatedRequestId: ctx.mcpReq.id,
|
||||
timeout: roundTimeoutMs,
|
||||
resetTimeoutOnProgress: true,
|
||||
// The no-op handler stamps a progressToken on the leg —
|
||||
// without one, resetTimeoutOnProgress could never fire.
|
||||
onprogress: () => {},
|
||||
signal: roundAbort.signal
|
||||
};
|
||||
const fulfilled = await Promise.all(
|
||||
coerced.map(async ([key, embedded]) => {
|
||||
try {
|
||||
return [key, await this._dispatchLeg(embedded, legOptions)] as const;
|
||||
} catch (error) {
|
||||
roundAbort.abort(error);
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
responses = Object.fromEntries(fulfilled);
|
||||
} catch (error) {
|
||||
if (outerSignal.aborted) {
|
||||
// Cancelled requests are never answered — propagate.
|
||||
throw error;
|
||||
}
|
||||
return legacyShimFailure(
|
||||
method,
|
||||
`Fulfilling input required by '${method}' failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
roundAbort.dispose();
|
||||
}
|
||||
} else {
|
||||
// requestState-only round: paced so the loop never hot-spins
|
||||
// (driver parity); counted in the same round cap.
|
||||
await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, outerSignal);
|
||||
}
|
||||
|
||||
// Byte-exact requestState echo: build the round's context first,
|
||||
// verify against it (the order and view a modern wire retry
|
||||
// gets), then swap in the decoded payload. Deny-on-error → -32602.
|
||||
let ctxNext: ServerContext = {
|
||||
...ctx,
|
||||
mcpReq: {
|
||||
...ctx.mcpReq,
|
||||
// REPLACE semantics: this round's responses only — multi-step
|
||||
// flows thread earlier answers through requestState.
|
||||
inputResponses: responses,
|
||||
droppedInputResponseKeys: undefined,
|
||||
requestState: requestStateAccessor(requestState)
|
||||
}
|
||||
};
|
||||
if (requestState !== undefined) {
|
||||
const decoded = await this._host.verifyRequestState(requestState, ctxNext, method);
|
||||
if (decoded !== undefined) {
|
||||
ctxNext = withRequestStateValue(ctxNext, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-entry hits the same stored handler a wire retry would
|
||||
// (for McpServer: the full funnel).
|
||||
const next = await handler(request, ctxNext);
|
||||
if (!isInputRequiredResult(next)) {
|
||||
return next;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes one embedded request through the host's existing 2025-era senders (gate already ran). */
|
||||
private async _dispatchLeg(embedded: CoercedEmbeddedInputRequest, options: RequestOptions): Promise<unknown> {
|
||||
switch (embedded.method) {
|
||||
case 'elicitation/create': {
|
||||
let params = embedded.params as ElicitRequestFormParams | ElicitRequestURLParams;
|
||||
if (params.mode === 'url' && (params as ElicitRequestURLParams).elicitationId === undefined) {
|
||||
params = { ...(params as ElicitRequestURLParams), elicitationId: syntheticElicitationId() };
|
||||
}
|
||||
return await this._host.sendElicitation(params, options);
|
||||
}
|
||||
case 'sampling/createMessage': {
|
||||
return await this._host.sendSampling(embedded.params as CreateMessageRequest['params'], options);
|
||||
}
|
||||
case 'roots/list': {
|
||||
return await this._host.listRoots(embedded.params, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,9 +47,10 @@ export interface RequestStateCodecOptions {
|
|||
* JSON-serializable payload into the wire string a handler returns from
|
||||
* `inputRequired({ requestState })`; `verify` is the function to drop into
|
||||
* {@linkcode server/server.ServerOptions | ServerOptions}`.requestState.verify`
|
||||
* (it throws on any failure, which the seam answers as the frozen `-32602`)
|
||||
* AND the function a handler calls to read the payload back from
|
||||
* `ctx.mcpReq.requestState` after the seam has run.
|
||||
* (it throws on any failure, which the seam answers as the frozen `-32602`).
|
||||
* The decoded payload `verify` resolves with is handed to the handler by the
|
||||
* seam via the typed `ctx.mcpReq.requestState<T>()` accessor — `mint<T>` and
|
||||
* `requestState<T>()` are the typed encode/read pair.
|
||||
*/
|
||||
export interface RequestStateCodec<T = unknown> {
|
||||
/**
|
||||
|
|
@ -125,10 +126,9 @@ function base64UrlToBytes(s: string): Uint8Array<ArrayBuffer> {
|
|||
* The codec is **signed, not encrypted**: the body is integrity-protected but
|
||||
* the client can base64url-decode it and read the payload (`p`) in clear. Do
|
||||
* not put secrets in the payload; use an AEAD construction if confidentiality
|
||||
* is required. The handler reads its payload back by calling `verify` again on
|
||||
* `ctx.mcpReq.requestState` after the seam has run — re-calling `verify` is
|
||||
* the intended pattern (the seam already proved integrity; the second call is
|
||||
* the decode).
|
||||
* is required. The handler reads its payload back via the typed
|
||||
* `ctx.mcpReq.requestState<T>()` accessor — the seam has already run `verify`
|
||||
* (integrity proven, payload decoded) by the time the handler is entered.
|
||||
*
|
||||
* Verification is fail-closed and constant-time (WebCrypto `subtle.verify` for
|
||||
* the body MAC; a fixed-length XOR-accumulator compare for the bind tag).
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue