# this.elicit

> Ask the user for input in the middle of a tool call, and continue with their answer as typed data.

Source: https://frontmcp.dev/reference/sdk/elicit

`this.elicit()` asks the user a question while a tool is running. The client shows a form, and the tool continues with the answer. Use it to [confirm an action before taking it](https://frontmcp.dev/learn/asking-the-user), or to collect a detail the model can't know. To remember the answer, so the user isn't asked every time, see the [Approval plugin](https://frontmcp.dev/reference/plugins/approval#asking-the-user-then-approving).

```ts
const answer = await this.elicit(message, requestedSchema, options?)
```

---

## Reference

### `this.elicit(message, requestedSchema, options?)`

Call `this.elicit()` inside a tool's `execute()`, and turn elicitation on for the server with `elicitation: { enabled: true }`.

```ts server.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a ticket after the user confirms", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.confirm) return { closed: false };
    return { closed: true, id };
  }
}

@App({ id: "desk", name: "Help desk", tools: [CloseTicket] })
class HelpDesk {}

@FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk], elicitation: { enabled: true } })
export default class Server {}
```

[See more examples below.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `message` | `string` | The question, shown above the form. Say what will happen, like "Close ticket T-1? The customer will be notified." |
| `requestedSchema` | Zod object, or an object of Zod types | The form's fields. FrontMCP sends it as JSON Schema, with each `.describe()` as the field's description. Pass a `z.object()` to get typed `content`. |
| `options` | `ElicitOptions` | Optional. See below. |

`options`:

| Option | Type | Description |
| --- | --- | --- |
| `mode` | `"form" \| "url"` | `"form"` (the default) asks for the fields in the client. `"url"` asks the client to send the user to a web page, `url`: see [Sending the user to a web page](#sending-the-user-to-a-web-page). |
| `ttl` | `number` | How long to wait for an answer, in milliseconds. Default `300000` (5 minutes). Only protocol versions before 2026-07-28 wait; see [how it travels](#how-an-elicitation-travels). |
| `url` | `string` | The page to send the user to, for URL mode. Required with `mode: "url"`: without it, `this.elicit()` throws `url is required when mode is "url"`, and the call fails with `INVALID_INPUT`. (New in 1.9.2.) |
| `elicitationId` | `string` | An id for URL mode, sent with the request so the web page can say which request it completes. Pass your own, which your page and your tool can both use; left out, FrontMCP makes one up, `elicit-` and a random UUID. (Changed in 1.9.3: before, it was required, and the call failed with `INVALID_INPUT` without it.) |

#### Returns

A promise of an `ElicitResult`:

| Field | Type | Description |
| --- | --- | --- |
| `status` | `"accept" \| "decline" \| "cancel"` | `accept`: the user submitted the form. `decline`: they said no. `cancel`: they dismissed it without choosing. An answer with no action counts as `cancel`. |
| `content` | the schema's type | What the user entered. Present when `status` is `"accept"`, in form mode; a URL-mode answer has none. |

#### Server options

`@FrontMcp({ elicitation })` takes:

| Option | Type | Description |
| --- | --- | --- |
| `enabled` | `boolean` | Default `false`. While it's off, every `this.elicit()` throws, and the tool call fails with `ELICITATION_DISABLED`. |
| `redis` | `RedisOptions` | Where to keep pending elicitations for protocol versions before 2026-07-28. Defaults to the server's `redis`, else its `sqlite`, or memory. See [Several instances and encryption](#several-instances-and-encryption). |

Turning elicitation on also changes what clients see. For clients on protocol versions before 2026-07-28 that can't show forms, FrontMCP adds the `sendElicitationResult` tool to `tools/list`, for [its fallback](#the-model-is-asked-to-call-sendelicitationresult). And every tool that declares an `outputSchema` advertises a `oneOf` of that schema and an `elicitationPending` result.

#### How an elicitation travels

Under MCP 2026-07-28 the server never sends a request to the client, and nothing waits on the server. Instead:

1. The tool runs until `this.elicit()`. FrontMCP stops it and answers the original request with `resultType: "input_required"`, the question in `inputRequests`, and a signed `requestState`:

   ```json
   {
     "resultType": "input_required",
     "inputRequests": {
       "elicitation-1": {
         "method": "elicitation/create",
         "params": { "message": "Close ticket T-1?", "requestedSchema": { "type": "object", "properties": { "confirm": { "type": "boolean" } } } }
       }
     },
     "requestState": "eyJyIjp7fS…"
   }
   ```

2. The client shows the form. It then sends the same request again, with a new JSON-RPC id, adding the answer under the same key and the `requestState` it got:

   ```json
   {
     "name": "close_ticket",
     "arguments": { "id": "T-1" },
     "inputResponses": { "elicitation-1": { "action": "accept", "content": { "confirm": true } } },
     "requestState": "eyJyIjp7fS…"
   }
   ```

3. FrontMCP runs the tool again **from the top**. This time `this.elicit()` returns the answer, and the tool finishes. A second `this.elicit()` in the same run starts another round, with the key `elicitation-2`.

Before 2026-07-28, FrontMCP sends an `elicitation/create` request to the client instead, and `this.elicit()` waits for the answer, up to `ttl`.

#### Several instances and encryption

Under 2026-07-28, any instance can serve any round: the question and the earlier answers travel in `requestState`, and nothing is stored. Every instance needs the same `VAULT_SECRET` or `JWT_SECRET` to read it ([caveats](#caveats)).

Before 2026-07-28, the tool waits on the instance that asked, holding the call's stream open, and the client sends its answer in a `POST` of its own, which a load balancer can send to another instance. So FrontMCP keeps each pending question in an elicitation store, under the session's id, where the instance that receives the answer can find it:

| Store | Used when | An answer that reaches another instance |
| --- | --- | --- |
| Redis | `elicitation.redis` is set, else the server's [`redis`](https://frontmcp.dev/reference/deployment/redis) | Is passed on. That instance reads the question from Redis, under `<keyPrefix>pending:<session id>`, and publishes the answer on the channel `<keyPrefix>result:<elicit id>`, which the asking instance subscribed to. The tool goes on there, and the client gets `202` for its answer. |
| SQLite | The server has [`sqlite`](https://frontmcp.dev/reference/deployment/sqlite) and no `redis` | Isn't. The answer is passed on inside one process: a second process that shares the file answers `202`, and the tool waits out `ttl` and fails with `ELICITATION_TIMEOUT`. |
| Redis, from the environment | None of these, and `REDIS_URL` or `REDIS_HOST` is [set](https://frontmcp.dev/reference/deployment/redis#from-the-environment) | Isn't, in practice: sessions stay in memory, so the other instance answers `404` `session not found`. |
| Memory | None of these | Isn't, for the same reason. |

The startup log names the store, its `keyPrefix` (the `redis` option's, `mcp:` by default, or `mcp:elicit:`) and whether it encrypts: `Created elicitation store { type: 'redis', keyPrefix: 'mcp:', supportsPubSub: true, encrypted: true }`. For the answer to reach the store at all, the instance has to serve the session, which takes `redis` and the same `MCP_SESSION_SECRET` everywhere ([How a session moves between instances](https://frontmcp.dev/reference/deployment/high-availability#how-a-session-moves-between-instances)). Without Redis, give clients on older protocol versions affinity at the load balancer, so every request of a session reaches the instance that holds it. The [fallback](#the-model-is-asked-to-call-sendelicitationresult) uses the same store: its question is kept under `<keyPrefix>fallback:<elicit id>`, and the instance that receives `sendElicitationResult` runs the tool again itself.

The store needs publish and subscribe, so some places refuse it:

- **Vercel KV** has no publish and subscribe. `elicitation: { enabled: true }` with Vercel KV as the only store fails with `ElicitationNotSupportedError: Vercel KV is not supported for elicitation stores`; give `elicitation.redis` a Redis ([Vercel](https://frontmcp.dev/reference/deployment/vercel#storage)).
- **An edge isolate**, like a Cloudflare Worker, refuses a store in memory. Without one in Redis, the server doesn't start: every request answers `503` `SERVER_START_FAILED`, and the log says `ElicitationNotSupportedError: Elicitation requires distributed storage when running on Edge runtime`. That happens whatever protocol version the clients use, and a Worker can't reach Redis ([Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers#storage)), so leave `elicitation` off on an edge isolate.

**Encryption.** When `MCP_ELICITATION_SECRET`, `MCP_SESSION_SECRET` or `MCP_SERVER_SECRET` is set, checked in that order, FrontMCP encrypts what it stores: the question, with its message and schema; the fallback's record, with the tool's name and arguments; and each answer it publishes to another instance. It uses AES-256-GCM, with a key derived (HKDF-SHA256) from the secret and the session's id, so reading a record takes both, and each session's records have a key of their own. A stored question keeps only `sessionId`, `elicitId`, `createdAt` and `expiresAt` readable, next to `__encrypted: { alg: "A256GCM", iv, tag, data }`. There's no option for it: with none of the three variables set, the store holds plain JSON. Production requires `MCP_SESSION_SECRET` for clients on older protocol versions, so a production server that serves them encrypts their questions.

Every instance needs the same secret. With different `MCP_ELICITATION_SECRET`s, the instance that received the answer logged `[EncryptedElicitationStore] Failed to decrypt pending record` and still answered the client `202`, and the tool on the asking instance failed with `ELICITATION_TIMEOUT` when `ttl` ran out.

This was checked with FrontMCP 1.9.4 on Node: two instances sharing Valkey 8 in Docker, and a client on 2025-11-25 that opened its session and called the tool on one and sent its answer to the other: with no secret, with `MCP_SESSION_SECRET` on both, with different `MCP_ELICITATION_SECRET`s, and with the fallback. Also two instances without Redis, two with only `REDIS_URL`, and two processes sharing a SQLite file. The edge isolate was checked in Node with the global that marks one, as on [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler).

#### Caveats

- `this.elicit()` is a protected method of `ToolContext` (and `AgentContext`: see [Asking the user from an agent](https://frontmcp.dev/reference/sdk/agent#asking-the-user-from-an-agent)). Call it from inside the class.
- **Code before `this.elicit()` runs once per round.** Put side effects, like writes and emails, after the last `this.elicit()`.
- Under 2026-07-28, **FrontMCP checks an accepted form answer against the schema** and fails the call with `INVALID_INPUT` if it doesn't fit. Answers from clients on older protocol versions aren't checked, so parse `content` yourself if you serve them. An in-process [`connect()`](https://frontmcp.dev/reference/sdk/connect) client's are, though: one that doesn't fit fails the call with `INVALID_INPUT` and `Invalid elicitation result content`. See [Checking the answer](#checking-the-answer).
- Keep the schema to a flat object of strings, numbers, booleans and enums. That's what MCP clients render as a form. FrontMCP doesn't enforce it.
- **Ask for everything in one form when you can.** Each question is another round trip, and runs `execute()` again; see [Asking more than once](#asking-more-than-once).
- **Under 2026-07-28, set `VAULT_SECRET` (or `JWT_SECRET`) to the same value on every instance.** FrontMCP signs `requestState` with `VAULT_SECRET`, else `JWT_SECRET`, else a random key each process makes for itself. With that random key, a round that lands on another instance counts as a changed state, and the tool starts over. In production, FrontMCP (since 1.8.7) logs a warning at startup when the key is per-process and `redis` or `transport.persistence` is set, and the log line for a rejected state carries a hint that names `VAULT_SECRET`. This was checked on a Node server.
- `mode: "url"` needs `url`, and a client that declares `elicitation: { url: {} }`. (Changed in 1.9.2: before, FrontMCP sent no URL with the request under 2026-07-28, so the client had no page to open.)

---

## Usage

### Asking the user to confirm

Ask before doing something the user might regret, and handle every `status`. The server here turns elicitation on itself; Playgrounds that call `this.elicit()` without a server do it for you. The **Call** tab shows the form the user would see.

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const Confirm = z.object({
  confirm: z.boolean().describe("Close the ticket"),
  note: z.string().optional().describe("A note for the customer"),
});

@Tool({
  name: "close_ticket",
  description: "Close a support ticket. Asks the user to confirm first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}? The customer will be notified.`, Confirm);
    if (answer.status !== "accept") return { closed: false, reason: `The user chose ${answer.status}` };
    if (!answer.content?.confirm) return { closed: false, reason: "The user didn't confirm" };
    return { closed: true, id, note: answer.content.note ?? "" };
  }
}
```

```ts server.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

@App({ id: "desk", name: "Help desk", tools: [CloseTicket] })
class HelpDesk {}

@FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk], elicitation: { enabled: true } })
export class Server {}
```

```ts close-ticket.test.ts
import { test, expect } from "@frontmcp/testing";

test("closes the ticket when the user confirms", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true, note: "Fixed in 2.3" } }));
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ closed: true, id: "T-1", note: "Fixed in 2.3" });
});

test("leaves it open when the user declines", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "decline" }));
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ closed: false, reason: "The user chose decline" });
});

test("the form names the ticket", async ({ mcp }) => {
  let asked: any;
  mcp.onElicitation((request) => {
    asked = request;
    return { action: "cancel" };
  });
  await mcp.tools.call("close_ticket", { id: "T-7" });
  expect(asked.message).toContain("T-7");
  expect(asked.requestedSchema.required).toEqual(["confirm"]);
});
```

Answer the form with **Accept**, **Decline** or **Cancel**, then open the **Wire** tab: the first `tools/call` came back `input_required`, and your answer went out as a second `tools/call`.

### Checking the answer

The answer comes from the client. Under 2026-07-28, FrontMCP checks an accepted answer against the schema you asked with before `this.elicit()` returns, fills in defaults, and drops fields the schema doesn't have. An answer that doesn't fit fails the call with `INVALID_INPUT`, and the text says which fields were wrong. Clients on older protocol versions aren't checked, so a tool that serves them should parse the answer too and fail with a message the model can read, as this one does.

```ts escalate.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const Escalation = z.object({
  team: z.enum(["billing", "engineering", "security"]).describe("Who should take the ticket"),
  reason: z.string().min(10).describe("Why it can't wait"),
});

@Tool({
  name: "escalate_ticket",
  description: "Escalate a ticket to another team. Asks the user which team, and why.",
  inputSchema: { id: z.string() },
})
export class EscalateTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Escalate ticket ${id}. Which team, and why?`, Escalation);
    if (answer.status !== "accept") return { escalated: false };
    const parsed = Escalation.safeParse(answer.content);
    if (!parsed.success) this.fail(new PublicMcpError("The escalation form wasn't filled in: pick a team and give a reason of 10 characters or more."));
    return { escalated: true, id, ...parsed.data };
  }
}
```

```ts escalate.test.ts
import { test, expect } from "@frontmcp/testing";

test("a valid answer escalates the ticket", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { team: "security", reason: "Customer data may be exposed" } }));
  const result = await mcp.tools.call("escalate_ticket", { id: "T-3" });
  expect(result.json()).toEqual({ escalated: true, id: "T-3", team: "security", reason: "Customer data may be exposed" });
});

test("an answer that doesn't fit the schema is rejected", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { team: "sales", reason: "now" } }));
  const result = await mcp.tools.call("escalate_ticket", { id: "T-3" });
  expect(result).toBeError("INVALID_INPUT");
  expect(result).toHaveTextContent("does not match the requested schema");
});
```

### Keeping side effects after the question

FrontMCP runs the tool again from the top for each round, so everything before `this.elicit()` runs twice. Here the counter stands in for a database write:

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

let auditEntries = 0;

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    auditEntries++; // 🚩 runs before the question, so once per round
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { closed: answer.status === "accept" && answer.content?.confirm === true, auditEntries };
  }
}
```

```ts replay.test.ts
import { test, expect } from "@frontmcp/testing";

test("one call, two runs of execute()", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } }));
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ closed: true, auditEntries: 2 });
});
```

Move the write below the last `this.elicit()`, and it runs once.

### What goes over the wire

This test plays a 2026-07-28 client by hand, so you can see both requests. The **Wire** tab shows the same exchange.

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { closed: answer.status === "accept" && answer.content?.confirm === true, id };
  }
}
```

```ts wire.test.ts
import { test, expect } from "@frontmcp/testing";

const closeT1 = (extra: Record<string, unknown> = {}) =>
  ({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "close_ticket", arguments: { id: "T-1" }, ...extra } }) as const;

test("the first request comes back input_required", async ({ mcp }) => {
  const first = await mcp.raw.request(closeT1());
  expect(first.result.resultType).toBe("input_required");
  expect(first.result.inputRequests["elicitation-1"]).toMatchObject({
    method: "elicitation/create",
    params: { message: "Close ticket T-1?" },
  });
  expect(first.result.requestState).toEqual(expect.any(String));
});

test("sending the answer back completes the call", async ({ mcp }) => {
  const first = await mcp.raw.request(closeT1());
  const second = await mcp.raw.request(
    closeT1({
      inputResponses: { "elicitation-1": { action: "accept", content: { confirm: true } } },
      requestState: first.result.requestState,
    }),
  );
  expect(second.result.resultType).toBe("complete");
  expect(second.result.structuredContent).toEqual({ closed: true, id: "T-1" });
});
```

### Asking more than once

Each `this.elicit()` in a run gets the next key: `elicitation-1`, `elicitation-2`, and so on. A later question can depend on an earlier answer, like choosing an agent from the team just picked.

```ts reassign.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const agents = { billing: ["Sam", "Priya"], engineering: ["Nour", "Lee"] } as const;

@Tool({ name: "reassign_ticket", description: "Move a ticket to another team and agent", inputSchema: { id: z.string() } })
export class ReassignTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const first = await this.elicit(`Which team should take ${id}?`, z.object({ team: z.enum(["billing", "engineering"]) }));
    if (first.status !== "accept" || !first.content) return { reassigned: false };
    const team = first.content.team;
    const second = await this.elicit(`Which ${team} agent?`, z.object({ agent: z.enum(agents[team]) }));
    if (second.status !== "accept" || !second.content) return { reassigned: false };
    return { reassigned: true, id, team, agent: second.content.agent };
  }
}
```

```ts reassign.test.ts
import { test, expect } from "@frontmcp/testing";

const reassign = (inputResponses?: Record<string, unknown>, requestState?: string) =>
  ({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "reassign_ticket", arguments: { id: "T-2" }, ...(inputResponses ? { inputResponses } : {}), ...(requestState ? { requestState } : {}) } }) as const;

test("each question gets the next key", async ({ mcp }) => {
  const team = { action: "accept", content: { team: "billing" } };

  const first = await mcp.raw.request(reassign());
  expect(Object.keys(first.result.inputRequests)).toEqual(["elicitation-1"]);

  const second = await mcp.raw.request(reassign({ "elicitation-1": team }));
  expect(second.result.inputRequests["elicitation-2"].params.message).toBe("Which billing agent?");

  const done = await mcp.raw.request(reassign({ "elicitation-1": team, "elicitation-2": { action: "accept", content: { agent: "Priya" } } }));
  expect(done.result.structuredContent).toEqual({ reassigned: true, id: "T-2", team: "billing", agent: "Priya" });
});

test("requestState carries the earlier answers", async ({ mcp }) => {
  const first = await mcp.raw.request(reassign());
  const second = await mcp.raw.request(reassign({ "elicitation-1": { action: "accept", content: { team: "billing" } } }, first.result.requestState));
  const done = await mcp.raw.request(reassign({ "elicitation-2": { action: "accept", content: { agent: "Priya" } } }, second.result.requestState));
  expect(done.result.structuredContent).toEqual({ reassigned: true, id: "T-2", team: "billing", agent: "Priya" });
});
```

A client can send every answer so far, as the first test does, or only the latest one with the `requestState` it was given, as the second does: the state carries the earlier answers. A `requestState` that was changed, or that comes back with different arguments, is ignored, and the tool asks its first question again.

### Sending the user to a web page

Some answers don't belong in a form: a password, a payment, or signing in to another service. With `mode: "url"`, the client sends the user to a page of yours instead, with `url`, and FrontMCP adds an `elicitationId`, yours or one it makes up, so the page can tell which request it completes. The client needs to declare `elicitation: { url: {} }`:

```ts connect-billing.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "connect_billing", description: "Connect the help desk to the user's billing account", inputSchema: {} })
export class ConnectBilling extends ToolContext {
  async execute() {
    const state = "abc123"; // in a real server, one per request, kept to check the page's callback
    const answer = await this.elicit("Sign in to your billing account to connect it", z.object({}), {
      mode: "url",
      url: `https://billing.example/connect?state=${state}`,
      elicitationId: `billing-${state}`,
    });
    return { connected: answer.status === "accept" };
  }
}
```

```ts connect-payroll.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "connect_payroll", description: "Connect the help desk to the user's payroll account", inputSchema: {} })
export class ConnectPayroll extends ToolContext {
  async execute() {
    const answer = await this.elicit("Sign in to your payroll account to connect it", z.object({}), {
      mode: "url",
      url: "https://payroll.example/connect?state=def456",
    });
    return { connected: answer.status === "accept" };
  }
}
```

```ts url.test.ts
import { test, expect } from "@frontmcp/testing";

const connect = (capabilities: Record<string, unknown>, inputResponses?: Record<string, unknown>) =>
  ({
    jsonrpc: "2.0",
    id: 1,
    method: "tools/call",
    params: {
      name: "connect_billing",
      arguments: {},
      ...(inputResponses ? { inputResponses } : {}),
      _meta: { "io.modelcontextprotocol/clientCapabilities": capabilities },
    },
  }) as const;

test("the client is asked to open the page", async ({ mcp }) => {
  const response = await mcp.raw.request(connect({ elicitation: { url: {} } }));
  expect(response.result.inputRequests["elicitation-1"].params).toMatchObject({
    message: "Sign in to your billing account to connect it",
    mode: "url",
    url: "https://billing.example/connect?state=abc123",
    elicitationId: "billing-abc123",
  });
});

test("its answer runs the tool again", async ({ mcp }) => {
  const response = await mcp.raw.request(connect({ elicitation: { url: {} } }, { "elicitation-1": { action: "accept" } }));
  expect(response.result.structuredContent).toEqual({ connected: true });
});

test("without an `elicitationId`, FrontMCP makes one up", async ({ mcp }) => {
  const response = await mcp.raw.request({
    jsonrpc: "2.0",
    id: 1,
    method: "tools/call",
    params: {
      name: "connect_payroll",
      arguments: {},
      _meta: { "io.modelcontextprotocol/clientCapabilities": { elicitation: { url: {} } } },
    },
  });
  expect(response.result.inputRequests["elicitation-1"].params.elicitationId).toMatch(/^elicit-[0-9a-f-]{36}$/);
});

test("a client that only shows forms gets -32021", async ({ mcp }) => {
  const response = await mcp.raw.request(connect({ elicitation: { form: {} } }));
  expect(response).toHaveErrorCode(-32021);
  expect(response.error.data).toEqual({ requiredCapabilities: { elicitation: { url: {} } } });
});
```

The answer only says that the user finished, or declined: whatever they did on the page reaches your server through the page, not through the client. So the page should keep what it learned under the `elicitationId` or the `state` it was given, for the tool to read on its next run.

A URL-mode answer carries no `content`, as MCP specifies, so FrontMCP doesn't check it against the schema: pass `z.object({})`, and an accepted answer is `{ status: "accept" }`. `connect_payroll` leaves `elicitationId` out, and FrontMCP makes one up, as the third test shows. The tool never sees that id, though, so pass your own when the page reports back under it. (Changed in 1.9.3: before, an accepted URL-mode answer was checked against the schema, so `z.object({})` failed the call with `INVALID_INPUT` unless it was made `.optional()`, and a URL-mode elicitation without an `elicitationId` failed with `INVALID_INPUT`. Changed in 1.9.2: before, under 2026-07-28, FrontMCP sent no `url` or `elicitationId` with the request, so the client had no page to open.)

---

## Troubleshooting

### "Elicitation is disabled in server configuration"

The server doesn't have `elicitation: { enabled: true }`, so `this.elicit()` threw and the call failed with `ELICITATION_DISABLED`. In production the message is shorter, "Elicitation is disabled on this server.", and the code is the same.

```ts server.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { closed: answer.status === "accept" };
  }
}

@App({ id: "desk", name: "Help desk", tools: [CloseTicket] })
class HelpDesk {}

// 🚩 No `elicitation: { enabled: true }`
@FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk] })
export class Server {}
```

### Error `-32021`: "This request requires the `elicitation` client capability"

Under 2026-07-28, a client declares what it supports in each request's `_meta`, under `io.modelcontextprotocol/clientCapabilities`. This client didn't declare `elicitation`, so FrontMCP refused to ask it anything. `error.data.requiredCapabilities` says what was missing: `{ elicitation: { form: {} } }`, or `{ elicitation: { url: {} } }` for URL mode.

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { closed: answer.status === "accept" };
  }
}
```

```ts capability.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

@App({ id: "desk", name: "Help desk", tools: [CloseTicket] })
class HelpDesk {}

test("a client without the elicitation capability gets -32021", async ({ mcp }) => {
  const response = await mcp.raw.request({
    jsonrpc: "2.0",
    id: 1,
    method: "tools/call",
    params: {
      name: "close_ticket",
      arguments: { id: "T-1" },
      _meta: { "io.modelcontextprotocol/clientCapabilities": {} },
    },
  });
  expect(response).toHaveErrorCode(-32021);
  expect(response.error.data).toEqual({ requiredCapabilities: { elicitation: { form: {} } } });
});

test("a request that names no protocol version gets ELICITATION_NOT_SUPPORTED instead", async () => {
  // No MCP-Protocol-Version header and no _meta: FrontMCP serves it as 2026-07-28 anyway.
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], elicitation: { enabled: true } });
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "close_ticket", arguments: { id: "T-1" } } }),
    }),
  );
  const { result } = await response.json();
  expect(result).toMatchObject({ isError: true, _meta: { code: "ELICITATION_NOT_SUPPORTED" } });
});
```

A request that names no protocol version at all, in a header or its `_meta`, like one from a client built for 2025-03-26, is served as 2026-07-28, but gets `ELICITATION_NOT_SUPPORTED` as a tool error instead, as the second test shows (changed in 1.8.5: before, it got `-32021` too).

Declare `elicitation: { form: {} }` in the client's capabilities, with `url: {}` too if you use URL mode. If some clients can't show forms, give the tool an input that skips the question, like `confirmed: true`, so they can still use it.

### The model is asked to call `sendElicitationResult`

That's FrontMCP's fallback for clients on protocol versions before 2026-07-28 that don't support elicitation. Instead of a form, the tool's result tells the model to ask the user, and carries the question in `_meta.elicitationPending` (`elicitId`, `message`, `schema`). The model then calls `sendElicitationResult` with:

| Argument | Type | Description |
| --- | --- | --- |
| `elicitId` | `string` | The id from `elicitationPending`. |
| `action` | `"accept" \| "decline" \| "cancel"` | What the user chose. |
| `content` | any | The user's answer, for `accept`. |

FrontMCP runs the original tool again with the same arguments, as the caller who answered, so `this.auth` is theirs: `this.elicit()` returns that answer, and the model gets the tool's final result. (Changed in 1.8.4: before, a `createDirect()` caller's re-run had no auth info, and a tool that read `this.auth` failed.) Only the caller who was asked can answer: from the same session, or, without one, as the same signed-in user. An answer from anyone else fails with `ELICITATION_NOT_OWNED` (`-32003`), and the question stays open for its owner. An unknown or expired `elicitId` fails too:

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket",
  inputSchema: { id: z.string() },
  outputSchema: z.object({ closed: z.boolean(), by: z.string() }),
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { closed: answer.status === "accept", by: this.auth.user.sub };
  }
}
```

```ts fallback.test.ts
import { test, expect } from "@frontmcp/testing";

test("2026-07-28 clients don't see the fallback tool", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("sendElicitationResult");
});

test("the output schema also allows the fallback result", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket");
  expect(tool?.outputSchema?.oneOf).toHaveLength(2);
  expect(tool?.outputSchema?.oneOf?.[1]).toMatchObject({ required: ["elicitationPending"] });
});

test("an unknown elicitId is an error", async ({ mcp }) => {
  const result = await mcp.tools.call("sendElicitationResult", { elicitId: "elicit-123", action: "accept", content: { confirm: true } });
  expect(result).toBeError();
  expect(result).toHaveTextContent("No pending elicitation found for ID: elicit-123");
});
```

```ts owner.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

test("only the caller who was asked can answer, and the re-run is theirs", async () => {
  // createDirect()'s client can't show forms, so it gets the fallback.
  const server = await FrontMcpInstance.createDirect(config);
  const nour = { authContext: { user: { sub: "nour" } } };
  const sam = { authContext: { user: { sub: "sam" } } };

  const asked = await server.callTool("close_ticket", { id: "T-1" }, nour);
  const { elicitId } = (asked._meta as { elicitationPending: { elicitId: string } }).elicitationPending;
  const answer = { elicitId, action: "accept", content: { confirm: true } };

  await expect(server.callTool("sendElicitationResult", answer, sam)).rejects.toThrow(`Elicitation "${elicitId}" was not requested by this caller`);
  expect((await server.callTool("sendElicitationResult", answer, nour)).structuredContent).toEqual({ closed: true, by: "nour" });
  await server.dispose();
});
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true } };

@FrontMcp(config)
export default class Server {}
```

FrontMCP doesn't list `sendElicitationResult` to 2026-07-28 clients, or to older clients that declare elicitation support, though it still answers if called, as the last test shows. A 2026-07-28 client without the capability gets [`-32021`](#error--32021-this-request-requires-the-elicitation-client-capability) rather than the fallback.

### "Elicitation request timed out"

Only protocol versions before 2026-07-28 wait for the answer. The user didn't answer within `ttl`, so `this.elicit()` threw `ElicitationTimeoutError` and the call failed with `ELICITATION_TIMEOUT`. Raise `ttl` for long forms, and let the error end the call.

On several instances, the answer may have reached one that couldn't pass it on, though the client got `202` for it: an instance without Redis, a second process on a SQLite file, or one with another `MCP_ELICITATION_SECRET`, whose log says `Failed to decrypt pending record`. See [Several instances and encryption](#several-instances-and-encryption).

### The same side effect happens twice

The code ran before `this.elicit()`, and FrontMCP ran the tool again for the answer. Move it after the last `this.elicit()`. See [Keeping side effects after the question](#keeping-side-effects-after-the-question).
