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