h3-mcp logoh3-mcp

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.

const whoamiTool = defineMcpTool({
  name: "whoami",
  handler: (event) => {
    const answer = event.context.mcp?.inputResponses?.username as any;

    if (!answer?.content?.name) {
      return {
        resultType: "input_required",
        inputRequests: {
          username: {
            method: "elicitation/create",
            params: { mode: "form", message: "What is your name?" },
          },
        },
        requestState: signedState(),
      };
    }

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

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.

#Anatomy

FieldDirectionPurpose
inputRequestsoutNamed requests for the client to satisfy (elicitation/create, …)
requestStateoutOpaque string the client echoes back, so you can resume
inputResponsesinThe answers, keyed by the same names, on event.context.mcp
requestStateinYour 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.

#requestState Is Attacker-Controlled

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

Important

If requestState influences authorization or business logic, integrity-protect it. Sign it (HMAC) or encrypt it (AEAD), include the tool name and an expiry, and reject anything that fails verification. Storing a user id in plain requestState and trusting it on the retry is a privilege-escalation bug.

import { createHmac, timingSafeEqual } from "node:crypto";

function sign(payload: object): string {
  const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
  const mac = createHmac("sha256", process.env.STATE_SECRET!).update(body).digest("base64url");
  return `${body}.${mac}`;
}

function verify(state: string | undefined): any {
  if (!state) return undefined;
  const [body, mac] = state.split(".");
  if (!body || !mac) return undefined;
  const expected = createHmac("sha256", process.env.STATE_SECRET!).update(body).digest("base64url");
  if (mac.length !== expected.length) return undefined;
  if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) return undefined;
  const payload = JSON.parse(Buffer.from(body, "base64url").toString());
  return payload.exp > Date.now() ? payload : undefined;
}

#Requiring a Capability

Before asking for input, confirm the client can provide it. requireClientCapability throws -32021 (MissingRequiredClientCapability) listing what was missing:

event.context.mcp!.requireClientCapability?.("elicitation");

Capabilities come from _meta["io.modelcontextprotocol/clientCapabilities"] on the request in flight.

Note

There is no helper yet for constructing or signing requestStateresultType: "input_required" passes through and inputResponses / requestState are surfaced, but the ergonomic wrapper is still on the roadmap. Until then, use a signing helper like the one above.

h3-mcp logo

h3-mcp  MCP servers, built on H3.