
# Multi Round-Trip Requests

> Ask the client for input by returning, not by calling.

Modern servers no longer send `sampling/createMessage`, `elicitation/create`, or `roots/list` as requests of their own. Instead a `tools/call`, `resources/read`, or `prompts/get` handler returns an **interim result** describing what it needs, and the client retries the original request with the answers attached.

```ts
import { defineTool, getElicitedContent, mcpElicit, inputRequired } from "h3-mcp";

const askName = mcpElicit({
  message: "What is your name?",
  requestedSchema: {
    type: "object",
    properties: { name: { type: "string" } },
    required: ["name"],
  },
});

// One map, used to ask *and* to read.
const requests = { username: askName };

const whoamiTool = defineTool({
  name: "whoami",
  handler: (event) => {
    const name = getElicitedContent(event, requests, "username")?.name; // string | undefined

    if (!name) {
      return inputRequired(event, { inputRequests: requests });
    }

    return { content: [{ type: "text", text: `Hello, ${name}!` }] };
  },
});
```

Nothing there is asserted. The key is checked against `requests`, and `name` is a `string` because the schema said `type: "string"` and listed it in `required` — see [Typed by the Schema That Asked](#typed-by-the-schema-that-asked).

The shape of the handler is the important part: it is **re-entrant**. The same code runs on the first call and on the retry, branching on whether the answers are present. There is no suspended continuation on the server — that is what makes the modern era stateless.

That `if (!name)` is the smallest thing that works, not the safest: it treats a user who _declined_ exactly like a user who has not been asked yet, and asks again. See [“No” Is an Answer](#no-is-an-answer).

## Anatomy

| Field            | Direction | Purpose                                                            |
| ---------------- | --------- | ------------------------------------------------------------------ |
| `inputRequests`  | out       | Named requests for the client to satisfy (`elicitation/create`, …) |
| `requestState`   | out       | Opaque string the client echoes back, so you can resume            |
| `inputResponses` | in        | The answers, keyed by the same names, on `event.context.mcp`       |
| `requestState`   | in        | Your string, verbatim, on `event.context.mcp`                      |

Several inputs can be requested at once — key them and read them back by key. Interim results never carry caching hints.

> [!IMPORTANT]
> **Answers do not accumulate.** `inputResponses` carries the answers to the interim result the client is replying to — nothing guarantees that round one's answers are still there in round three. Anything you have to remember across rounds belongs in `requestState`, not in the responses.
>
> `requestState` does not accumulate either: it is whatever the **last** interim result carried. Return an interim result without one and the state is gone, so the next round starts over — from a handler that reads `open()` and branches on it, that looks like a flow that silently restarts instead of progressing. Re-seal on **every** round.

## The Four Request Kinds

Each value in `inputRequests` is one embedded request, built by the helper named after it:

| Builder                | Method                     | Response              |
| ---------------------- | -------------------------- | --------------------- |
| `mcpElicit(params)`    | `elicitation/create`, form | `ElicitResult`        |
| `mcpElicitUrl(params)` | `elicitation/create`, URL  | `ElicitResult`        |
| `createMessage(…)`     | `sampling/createMessage`   | `CreateMessageResult` |
| `listRoots()`          | `roots/list`               | `ListRootsResult`     |

Sampling and roots are `@deprecated` as of `2026-07-28` (SEP-2577) and stay in the spec for at least twelve months; elicitation is not.

## Write the Handler Write-Once

One handler runs on every round: read what already arrived, then ask for only what is still missing. `getMissingInputs` narrows the map to the entries this request has no answer for, and `getInputResponses` types each answer by the request that asked for it, so the retry branch is checked rather than cast.

```ts
import {
  getInputResponses,
  getMissingInputs,
  createMessage,
  mcpElicit,
  inputRequired,
} from "h3-mcp";

const requests = {
  user_name: mcpElicit({
    message: "What is your name?",
    requestedSchema: {
      type: "object",
      properties: { name: { type: "string" } },
      required: ["name"],
    },
  }),
  greeting: createMessage({
    messages: [{ role: "user", content: { type: "text", text: "Write a greeting" } }],
    maxTokens: 50,
  }),
};

const greetTool = defineTool({
  name: "greet",
  handler: (event) => {
    const missing = getMissingInputs(event, requests);
    if (Object.keys(missing).length > 0) {
      return inputRequired(event, { inputRequests: missing });
    }

    const answers = getInputResponses(event, requests);
    // answers.user_name?: ElicitResult<{ name: string }>
    // answers.greeting?:  CreateMessageResult
    return { content: [{ type: "text", text: `${answers.greeting!.model} said hello` }] };
  },
});
```

Asking for `requests` wholesale instead would re-ask what the client already answered, and the client has no way to know it may skip those — the user gets prompted for their name a second time.

`getElicitedContent(event, requests, key)` is the shortcut for the common case: it returns the submitted fields of an **accepted** form elicitation, and `undefined` for an answer that is missing, declined, or cancelled alike. Reach for `getInputResponses` when a refusal has to be told apart from a first entry.

> [!IMPORTANT]
> Responses are client input. Both readers type them for convenience; neither validates them. Validate anything you act on, exactly as you would `params`.

### Typed by the Schema That Asked

`mcpElicit` captures the `requestedSchema` as a literal type, so the answer is typed by the schema that asked for it — `string`/`number`/`integer`/`boolean`/`array` become `string`/`number`/`boolean`/`string[]`, and a name outside `required` is optional:

```ts
const askProfile = mcpElicit({
  message: "About you",
  requestedSchema: {
    type: "object",
    properties: {
      name: { type: "string" },
      age: { type: "integer" },
      subscribe: { type: "boolean" },
    },
    required: ["name"],
  },
});

const profile = getElicitedContent(event, { profile: askProfile }, "profile");
// profile?: { name: string; age?: number; subscribe?: boolean }
```

Nothing is re-stated, so nothing can drift from the wire schema. The key is checked too: pass the **same map** to `inputRequired` and to the reader, and asking under `user_name` while reading back `username` stops compiling — a typo that otherwise has no runtime symptom at all, only an endless re-ask.

An `enum` stays `string`, deliberately: the value is unvalidated client input, and a literal union would invite an exhaustive `switch` over something nothing enforces.

> [!NOTE]
> The inference describes what was **asked for**, not what arrived. It is a convenience over unvalidated input, exactly like the old type argument — validate anything you act on.

A schema whose literal types are gone — annotated as `ElicitRequestedSchema`, or assembled at runtime — still compiles and falls back to the wire-level `ElicitContent` (`Record<string, string | number | boolean | string[]>`).

Hoist a shared schema with `as const`. A plain `const` is not a fallback but an error: TypeScript widens its `type: "object"` to `string`, which no version of `mcpElicit` has ever accepted.

`getElicitedContent(event, key)` without a map is still there for an ad-hoc read, with the content type as an explicit assertion: `getElicitedContent<{ name: string }>(event, "user_name")`. Nothing checks the key in that form.

### "No" Is an Answer

This is the shape that loops forever:

```ts
const nameRequests = { user_name: askName };

const name = getElicitedContent(event, nameRequests, "user_name")?.name;
if (!name) {
  return inputRequired(event, { inputRequests: nameRequests }); // ⚠️
}
```

`getElicitedContent` collapses **missing**, **declined** and **cancelled** into one `undefined`, so a user who dismisses the dialog is asked again, dismisses it again, and is asked again. Nothing errors and nothing times out; the flow just never ends. A key typo used to fail the same silent way — ask under `user_name`, read back `username`, and a perfectly answered request looks unanswered on every round — which is why the three-argument form checks the key against the map.

The shortcut is only safe when re-asking is genuinely the right move for a refusal, which usually means the request is idempotent and cosmetic. Otherwise branch on the response itself:

```ts
const answer = getInputResponses(event, nameRequests).user_name;
if (answer === undefined) {
  return inputRequired(event, { inputRequests: nameRequests }); // first entry
}
if (answer.action !== "accept") {
  return { content: [{ type: "text", text: "No problem — carrying on without a name." }] };
}
```

`getMissingInputs` follows the same rule: a declined or cancelled entry counts as answered and is not re-requested.

## What `inputRequired` Checks

It refuses to build a result the wire could not carry, so the mistake surfaces at the server instead of one round later:

- **The era.** The legacy transport strips `resultType`, so an interim result would silently degrade into a broken final one. A legacy request gets an error naming `2026-07-28`.
- **Client capabilities.** The spec forbids sending an `inputRequests` entry the client never declared support for. Every embedded request's capability is checked against `_meta["io.modelcontextprotocol/clientCapabilities"]`, and `-32021` (`MissingRequiredClientCapability`) names all of them at once, down to the sub-capability:

  | Request            | Needs                                                                                                                                                                                                                                     |
  | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `mcpElicit` (form) | `elicitation: { form: {} }`, or the bare `elicitation: {}` that means form-only                                                                                                                                                           |
  | `mcpElicitUrl`     | `elicitation: { url: {} }`                                                                                                                                                                                                                |
  | `createMessage`    | `sampling: {}` — plus `sampling: { context: {} }` for a deprecated `includeContext`, and `sampling: { tools: {} }` for `tools` / `toolChoice` (which `CreateMessageParams` does not model: reach them with a hand-written request object) |
  | `listRoots`        | `roots: {}`                                                                                                                                                                                                                               |

  A capability whose value is not an object is not a declaration, and an `elicitation` request whose `mode` is neither `form` nor `url` is rejected outright rather than checked against form.

- **The method.** Only the three the spec allows; anything else is a `500`.
- **A non-empty spec.** At least one of `inputRequests` or `requestState` MUST be present.
- **The state length.** A `requestState` over `MAX_REQUEST_STATE_LENGTH` (65 536 characters, the same bound the transport applies on the way back in) is rejected on the way out.

## Asking Only What This Client Can Answer

Those checks throw, which is right for a handler that has nothing else to offer — but a handler that can degrade should ask first. `canRequestInput` returns the same verdict without the throw, and `getSupportedInputs` narrows a whole map to what this client declared:

```ts
import { getSupportedInputs, canRequestInput, inputRequired } from "h3-mcp";

// One request, one verdict.
if (!canRequestInput(event, askName)) {
  return { content: [{ type: "text", text: "Hello, stranger" }] };
}

// Or a whole menu at once: ask for the subset that is answerable.
const supported = getSupportedInputs(event, {
  user_name: askName,
  client_roots: listRoots(),
});
if (Object.keys(supported).length > 0) {
  return inputRequired(event, { inputRequests: supported });
}
```

Both are `false`/empty on a **legacy** request too, so one check covers both reasons an interim result could not be delivered. They compose with `getMissingInputs` in either order.

Do not hand-roll this. `clientCapabilities.elicitation !== undefined` looks equivalent and is not: a client declaring `elicitation: { url: {} }` cannot answer a form request, and a client sending `elicitation: true` declared nothing at all. Both pass the naive test and then take a `-32021`.

The older `event.context.mcp!.requireClientCapability?.("elicitation")` is still there and still **looser**: it takes top-level names only, so it cannot express a mode, and it does not verify that the declared value is an object. A handler that pre-checks with it can still be turned down by `inputRequired`. Prefer `canRequestInput`, which is the same code path `inputRequired` runs.

## `requestState` Is Attacker-Controlled

`requestState` round-trips through the client, which means the value you read back is whatever the client chose to send. The transport validates only that it is a bounded string.

> [!IMPORTANT]
> If `requestState` influences authorization, resource access, or business logic, integrity-protect it — and reject anything that fails verification. Storing a user id in plain `requestState` and trusting it on the retry is a privilege-escalation bug.

`defineRequestState` is that, ready-made: an HMAC-SHA256 codec over the Web Crypto API, with an expiry and an optional context binding.

```ts
import { defineRequestState, mcpElicit, inputRequired } from "h3-mcp";

const state = defineRequestState<{ step: number }>({
  key: process.env.MCP_STATE_SECRET!, // ≥ 32 bytes, shared by every instance
  ttlMs: 300_000, // default 10 minutes
  bind: (event) => requireUserId(event), // reject cross-user replay; never fall back to ""
});

const wizardTool = defineTool({
  name: "wizard",
  handler: async (event) => {
    const step = (await state.open(event))?.step ?? 0;

    if (step < 2) {
      return inputRequired(event, {
        inputRequests: { [`step${step + 1}`]: askName },
        requestState: await state.seal({ step: step + 1 }, event),
      });
    }

    return { content: [{ type: "text", text: "done" }] };
  },
});
```

`open(event)` returns `undefined` only when the request carries **no** state at all — the first round. Anything present but not intact (bad MAC, expired, wrong binding, malformed) is rejected with `-32602` and an opaque `"Invalid request state"` before your handler can act on it. Which check failed is deliberately not reported: it would be an oracle for a client probing the format.

Note what the loop above does on **every** round that is not the last one: it re-seals. A round that returns an interim result without a `requestState` drops the state, and the round after it sees `open() === undefined` and starts from step 0 — a flow that quietly restarts rather than one that errors.

`seal(payload, event?)` needs the event only to evaluate `bind`, so an unbound codec can mint a state outside a request (a test, a fixture, a pre-seeded flow). A **bound** codec throws if the event is missing, rather than minting an unbound state that `open()` would reject a round later.

`defineRequestState` validates its options **when you call it**, not on first use: a key under 32 bytes throws `RangeError`, so does a non-finite or non-positive `ttlMs`, and a runtime without `crypto.subtle` throws `TypeError`. Since the codec is normally a module-level constant, a short development secret is an import-time crash rather than a failure on the first round trip.

The state is **signed, not encrypted**. The payload is tamper-evident, but anyone holding the string can base64url-decode and read it, so keep secrets out of it.

> [!NOTE]
> The spec also asks servers to bound replay: bind the state to the authenticated principal (`bind`), keep the TTL short, tie the state to the request that minted it, and — where a state must be redeemed exactly once — enforce that server-side. `bind` and `ttlMs` cover the first two. `bind` sees only the `H3Event`, so it cannot name the method being retried; put the tool name in the sealed payload and check it after `open()`, as the conformance fixtures do. Single-use is yours to implement, because only you know what "consumed" means — the sealed string is canonical, so it is safe to key a redeemed-set on.
