# Asking the User Mid-Call

> How a tool asks the person using the client a question in the middle of a call, with this.elicit(), and what happens on the wire under MCP 2026-07-28.

Source: https://frontmcp.dev/learn/asking-the-user

Some tools can't finish on their own. A tool that closes a ticket and emails the customer is about to do something that can't be taken back. A tool that assigns a ticket needs to know who should get it, and only the person using the client knows. `this.elicit()` pauses a call to ask that person a question, through a form their client shows them. The model doesn't answer it. The user does.

**You will learn**
- How to ask the user a question with `this.elicit()`
- How to handle each answer: accept, decline and cancel
- What goes over the wire under MCP 2026-07-28, and why `execute()` runs twice
- What happens with clients that can't show a form
- How to turn elicitation on in your server, and how to test a tool that asks

## A tool that acts without asking

This tool closes a ticket and emails the customer that their problem is solved. A user says "I think the login problem is sorted", and the model calls it:

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

@Tool({
  name: "close_ticket",
  description: "Close a support ticket and email the customer that it's resolved.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));

    ticket.status = "closed";
    outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`);
    return { id, closed: true };
  }
}
```

```ts store.ts
export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);

export const outbox: string[] = [];
```

The ticket is closed and the email is gone before anyone confirmed that the problem really is solved. `destructiveHint: true` lets a client warn the user before it sends the call, but it's a hint: whether anyone is asked depends on the client. If the tool itself needs a yes before it acts, the tool has to ask.

## Asking with `this.elicit()`

`this.elicit(message, schema)` asks the user a question and returns their answer. The message is the question. The schema is a `z.object()` describing the form the client shows: here, one checkbox.

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

@Tool({
  name: "close_ticket",
  description: "Close a support ticket and email the customer that it's resolved. Asks the user to confirm first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));

    const answer = await this.elicit(
      `Close ${id} ("${ticket.title}") and email the customer that it's resolved?`,
      z.object({ confirm: z.boolean().describe("Tick to close the ticket") }),
    );

    if (answer.status === "decline") return { id, closed: false, reason: "The user said no. Leave the ticket open." };
    if (answer.status === "cancel") return { id, closed: false, reason: "The user dismissed the question without answering." };
    if (answer.content?.confirm !== true) return { id, closed: false, reason: "The user didn't tick the box." };

    ticket.status = "closed";
    outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`);
    return { id, closed: true };
  }
}
```

```ts store.ts
export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);

export const outbox: string[] = [];
```

The Call tab shows the form the user would see. Tick the box and press **Accept**, then call the tool again and try **Decline** and **Cancel**. `elicit()` returns an object with a `status`, and `content` when there is one:

| `status` | What the user did | `content` |
| --- | --- | --- |
| `"accept"` | Submitted the form | The form's values, like `{ confirm: true }` |
| `"decline"` | Said no | none |
| `"cancel"` | Closed the form without choosing | none |

Handle all three, and say in the result what happened. The model reads the result: "The user said no" tells it to stop, where a bare `closed: false` might send it straight back to try again.

Keep the schema a flat object of strings, numbers, booleans and enums. Clients have to turn it into a form, and that's all the MCP spec allows in one. `.describe()` on a field goes into the schema as its description, which clients show next to the field.

This tool asks on every call. To ask once and let later calls through, put the tool behind [approval](https://frontmcp.dev/learn/asking-for-approval), which records the user's yes and checks for it before each call.

## Asking for a missing detail

Elicitation isn't only for confirmations. When a tool needs something only the user can decide, it can ask for it. Here, the model may pass an `agent`, and if it doesn't, the tool asks the user to pick one:

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

const AGENTS = ["Nour", "Sam", "Priya"] as const;
const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", agent: "Nour" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", agent: "" }],
]);

@Tool({
  name: "assign_ticket",
  description: "Assign a support ticket to an agent. If you don't know who should take it, leave `agent` out and the user will be asked.",
  inputSchema: {
    id: z.string().describe("Ticket id, like T-1"),
    agent: z.enum(AGENTS).optional().describe("Who should take the ticket"),
  },
})
export class AssignTicket extends ToolContext {
  async execute({ id, agent }: { id: string; agent?: (typeof AGENTS)[number] }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));

    if (!agent) {
      const answer = await this.elicit(
        `Who should take ${id} ("${ticket.title}")?`,
        z.object({ agent: z.enum(AGENTS).describe("Support agent") }),
      );
      const picked = answer.content?.agent;
      if (answer.status !== "accept" || !picked || !AGENTS.includes(picked)) {
        return { id, assigned: false, reason: "The user didn't pick an agent." };
      }
      agent = picked;
    }

    ticket.agent = agent;
    return { id, assigned: true, agent };
  }
}
```

Pick an agent and press **Accept**. Then change the call to include `agent` and send it again: the tool doesn't ask, because it doesn't need to.

That's the split to aim for. The model fills in `inputSchema` from what it knows. The user answers an elicitation, for what only they can decide. Asking for something the model could have passed just interrupts the user.

> **Pitfall: Check the answer**
Under protocol 2026-07-28, FrontMCP checks an accepted answer against the schema you passed to `elicit()` before `elicit()` returns. A missing or mistyped field fails the call with `INVALID_INPUT` and "The elicitation answer does not match the requested schema", so your tool never sees it. Clients on older protocol versions aren't checked this way, and a `decline` or `cancel` can carry any `content`, so still check the fields you rely on: `answer.content?.confirm !== true` rather than `!answer.content.confirm`, and `AGENTS.includes(picked)` above. The tests [further down](#testing-a-tool-that-asks) show a malformed answer being rejected.

## What happens on the wire

In MCP 2026-07-28, the server never holds a call open while it waits for the user. Open the **Wire** tab in the example above, answer the question, and look at the two `tools/call` requests:

1. The first `tools/call` runs `execute()` until it reaches `elicit()`. Instead of a normal result, the call returns `resultType: "input_required"`, with the question in `inputRequests` and a signed `requestState`:

   ```json
   {
     "resultType": "input_required",
     "inputRequests": {
       "elicitation-1": {
         "method": "elicitation/create",
         "params": {
           "message": "Who should take T-2 (\"Invoice total is wrong\")?",
           "requestedSchema": {
             "type": "object",
             "properties": {
               "agent": { "type": "string", "enum": ["Nour", "Sam", "Priya"], "description": "Support agent" }
             },
             "required": ["agent"],
             "additionalProperties": false
           }
         }
       }
     },
     "requestState": "eyJyIjp7fSwicCI6…"
   }
   ```

2. The client shows the form. Nothing is waiting on the server: the first request is finished.
3. The client sends the **same** `tools/call` again, with the answer in `inputResponses` under the same key, and the `requestState` it was given:

   ```json
   {
     "name": "assign_ticket",
     "arguments": { "id": "T-2" },
     "inputResponses": {
       "elicitation-1": { "action": "accept", "content": { "agent": "Priya" } }
     },
     "requestState": "eyJyIjp7fSwicCI6…"
   }
   ```

4. FrontMCP runs `execute()` again **from the top**. This time `elicit()` finds an answer for `elicitation-1` and returns it instead of asking, and the call finishes with a normal result.

Both messages are trimmed here; the Wire tab shows them in full, with `_meta` and the schema's `$schema`. The key counts the `elicit()` calls in one run of `execute()`: the first is `elicitation-1`, a second would be `elicitation-2`. On the wire the answer is `action`, and FrontMCP hands it to your code as `status`.

**Deep dive: What's in requestState?**
`requestState` is a token FrontMCP creates and the client passes back without reading. It holds the answers from earlier rounds, and it's signed with an HMAC, bound to the caller and to the tool name and arguments, and expires after 10 minutes. If a returned state fails any of those checks, FrontMCP ignores it and logs a warning.

The signing key is `VAULT_SECRET` or `JWT_SECRET` from the environment. Without either, each server process picks a random key, so if you run several instances behind a load balancer, give them all [the same secret](https://frontmcp.dev/learn/running-several-instances#the-secrets-every-instance-shares).

## Ask first, then act

Because `execute()` starts over when the answer arrives, every line before `elicit()` runs once per round trip. This version writes to the ticket's history before it asks. Accept the question and look at `history`:

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

const tickets = new Map([["T-1", { id: "T-1", status: "open", history: ["Opened by the customer"] }]]);

@Tool({
  name: "close_ticket",
  description: "Close a support ticket. Asks the user to confirm first.",
  inputSchema: { id: z.string() },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.history.push("Close requested"); // 🚩 runs on both round trips

    const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() }));
    if (answer.status !== "accept" || answer.content?.confirm !== true) {
      return { id, closed: false, history: ticket.history };
    }

    ticket.status = "closed";
    ticket.history.push("Closed");
    return { id, closed: true, history: ticket.history };
  }
}
```

"Close requested" is there twice. Reading data before you ask is fine; changing anything, sending anything or charging anything is not. Do those after the answer, as the `close_ticket` [above](#asking-with-thiselicit) does.

> **Pitfall: Ask for everything in one form**
Each question is a full round trip, and each one runs `execute()` again from the top. If you need several details, ask for them together, in one `z.object()` with several fields.

## Testing a tool that asks

In a test, nobody is there to fill in the form. `mcp.onElicitation()` registers the answer the test client gives when a tool asks. The handler receives the request, with its `message` and `requestedSchema`, and returns `{ action, content }`. Open the **Tests** tab:

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

test.describe("close_ticket", () => {
  test("asks about the right ticket, and closes it on yes", async ({ mcp }) => {
    let question = "";
    mcp.onElicitation((request) => {
      question = request.message;
      return { action: "accept", content: { confirm: true } };
    });

    const result = await mcp.tools.call("close_ticket", { id: "T-1" });
    expect(question).toContain("T-1");
    expect(result.json()).toEqual({ id: "T-1", closed: true });
  });

  test("leaves the ticket open when the user declines", async ({ mcp }) => {
    mcp.onElicitation(() => ({ action: "decline" }));

    const result = await mcp.tools.call("close_ticket", { id: "T-2" });
    expect(result.json()).toMatchObject({ closed: false });
  });

  test("an answer that doesn't match the schema is rejected", async ({ mcp }) => {
    mcp.onElicitation(() => ({ action: "accept", content: { confirm: "yes" } }));

    const result = await mcp.tools.call("close_ticket", { id: "T-3" });
    expect(result).toBeError("INVALID_INPUT");
    expect(result.text()).toContain("does not match the requested schema");
  });
});
```

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

@Tool({
  name: "close_ticket",
  description: "Close a support ticket and email the customer that it's resolved. Asks the user to confirm first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));

    const answer = await this.elicit(
      `Close ${id} ("${ticket.title}") and email the customer that it's resolved?`,
      z.object({ confirm: z.boolean().describe("Tick to close the ticket") }),
    );

    if (answer.status === "decline") return { id, closed: false, reason: "The user said no. Leave the ticket open." };
    if (answer.status === "cancel") return { id, closed: false, reason: "The user dismissed the question without answering." };
    if (answer.content?.confirm !== true) return { id, closed: false, reason: "The user didn't tick the box." };

    ticket.status = "closed";
    outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`);
    return { id, closed: true };
  }
}
```

```ts store.ts
export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);

export const outbox: string[] = [];
```

The last test sends `confirm: "yes"`, a string where the schema says boolean. FrontMCP rejects the answer before `elicit()` returns, so the call fails with `INVALID_INPUT` and `execute()` never gets past the question. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the rest of `@frontmcp/testing`.

## Clients that can't ask

Not every client can show a form. Under 2026-07-28, every request says what its client supports, in `_meta["io.modelcontextprotocol/clientCapabilities"]`. The Playground sends `{ elicitation: { form: {} } }`, so its calls can be asked questions. A client that leaves `elicitation` out gets a JSON-RPC error instead: `-32021`, with the capability it's missing in `data.requiredCapabilities`. The first test below sends a 2026-07-28 request the way such a client would, with an empty capabilities object:

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

test("a client without the elicitation capability gets error -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("2026-07-28 clients don't see the fallback tool", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("sendElicitationResult");
});
```

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

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

The error happens at `elicit()`, so the tool never gets an answer and the client gets no result. If a tool can do something useful without asking, like a read-only preview, make asking optional in its input rather than letting such clients fail.

The second test is about a tool you may see in other clients: `sendElicitationResult`, which you didn't write. FrontMCP adds it when elicitation is enabled, as a fallback for clients on older protocol versions that keep a session open but can't show forms. For those clients, FrontMCP tells the model what to ask instead, in the tool's result or in a log message while the call waits. The model asks the user in the chat and calls `sendElicitationResult` with the answer, and FrontMCP runs the original tool again with it. FrontMCP lists the tool only for those clients, so a 2026-07-28 client like the Playground never sees it; one without the capability gets `-32021`, as above.

## Turning elicitation on

The Playground turns elicitation on for you when an example calls `this.elicit()`. A real server has to ask for it. This example exports its own `@FrontMcp` server, so the Playground uses its configuration as written:

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

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

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

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

The call fails with `ELICITATION_DISABLED`: "Elicitation is disabled in server configuration", followed by how to enable it. Elicitation is off unless you enable it in `@FrontMcp`:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
})
export default class Server {}
```

## Recap

- `this.elicit(message, z.object({...}))` asks the user a question through their client. The model doesn't answer it.
- Handle `accept`, `decline` and `cancel`, and say in the result what happened. Under 2026-07-28, FrontMCP rejects an accepted answer that doesn't match the schema; still check the fields of `content` you rely on, for older clients.
- Use `inputSchema` for what the model knows and elicitation for what only the user can decide.
- Under 2026-07-28 the call returns `input_required`, and the client sends the same request again with `inputResponses`. `execute()` runs again from the top, so don't change anything before you ask.
- A client that doesn't declare the `elicitation` capability gets error `-32021`.
- Enable elicitation with `@FrontMcp({ elicitation: { enabled: true } })`, and answer questions in tests with `mcp.onElicitation()`.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Confirm before deleting
`delete_ticket` deletes a ticket as soon as it's called. Make it ask the user first, with a `confirm` checkbox and a message that names the ticket. Only delete when the user accepts and ticks the box.

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

const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong" }],
  ["T-3", { id: "T-3", title: "Login link expired" }],
  ["T-4", { id: "T-4", title: "Password reset email missing" }],
]);

@Tool({
  name: "delete_ticket",
  description: "Permanently delete a support ticket.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));

    tickets.delete(id);
    return { id, deleted: true };
  }
}
```

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

const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong" }],
  ["T-3", { id: "T-3", title: "Login link expired" }],
  ["T-4", { id: "T-4", title: "Password reset email missing" }],
]);

@Tool({
  name: "delete_ticket",
  description: "Permanently delete a support ticket. Asks the user to confirm first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));

    const answer = await this.elicit(
      `Permanently delete ${id} ("${ticket.title}")? This can't be undone.`,
      z.object({ confirm: z.boolean().describe("Tick to delete the ticket") }),
    );
    if (answer.status !== "accept" || answer.content?.confirm !== true) {
      return { id, deleted: false, reason: `The user didn't confirm (${answer.status}).` };
    }

    tickets.delete(id);
    return { id, deleted: true };
  }
}
```

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

test("it asks about the ticket it's deleting", async ({ mcp }) => {
  let question = "";
  mcp.onElicitation((request) => {
    question = request.message;
    return { action: "decline" };
  });
  await mcp.tools.call("delete_ticket", { id: "T-1" });
  expect(question).toContain("T-1");
});

test("declining keeps the ticket", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "decline" }));
  const result = await mcp.tools.call("delete_ticket", { id: "T-2" });
  expect(result).toBeSuccessful();
  expect(result.json()).toMatchObject({ deleted: false });
});

test("cancelling keeps the ticket", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "cancel" }));
  const result = await mcp.tools.call("delete_ticket", { id: "T-3" });
  expect(result.json()).toMatchObject({ deleted: false });
});

test("accepting without ticking `confirm` keeps the ticket", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: false } }));
  const result = await mcp.tools.call("delete_ticket", { id: "T-4" });
  expect(result.json()).toMatchObject({ deleted: false });
});

test("accepting with `confirm` ticked deletes it", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } }));
  const result = await mcp.tools.call("delete_ticket", { id: "T-4" });
  expect(result.json()).toEqual({ id: "T-4", deleted: true });
});
```

**Hint:**
Call `this.elicit()` after looking the ticket up and before `tickets.delete()`. Its second argument is `z.object({ confirm: z.boolean() })`.

**Solution:**
The tool asks first, and deletes only when `status` is `"accept"` and `confirm` is exactly `true`. Everything else, a decline, a cancel or an unticked box, keeps the ticket and says why. Asking comes after the lookup, which only reads, and before the delete, which is the one thing that mustn't run on the first round.

### Challenge: Send the email once
This `close_ticket` asks before closing, but customers get their "resolved" email twice, and sometimes when the user said no. Fix it so accepting sends exactly one email and declining sends none. `list_outbox` shows what was sent.

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

@Tool({
  name: "close_ticket",
  description: "Close a support ticket and email the customer that it's resolved. 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 ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`);

    const answer = await this.elicit(`Close ${id} and email the customer?`, z.object({ confirm: z.boolean() }));
    if (answer.status !== "accept" || answer.content?.confirm !== true) {
      return { id, closed: false };
    }

    ticket.status = "closed";
    return { id, closed: true };
  }
}
```

```ts close-ticket.tool.ts solution
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { outbox, tickets } from "./store";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket and email the customer that it's resolved. 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 ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));

    const answer = await this.elicit(`Close ${id} and email the customer?`, z.object({ confirm: z.boolean() }));
    if (answer.status !== "accept" || answer.content?.confirm !== true) {
      return { id, closed: false };
    }

    ticket.status = "closed";
    outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`);
    return { id, closed: true };
  }
}
```

```ts outbox.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";
import { outbox } from "./store";

@Tool({
  name: "list_outbox",
  description: "List the emails sent to customers.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})
export class ListOutbox extends ToolContext {
  async execute() {
    return { emails: outbox };
  }
}
```

```ts store.ts
export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
]);

export const outbox: string[] = [];
```

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

const emailsAbout = async (mcp: any, id: string) =>
  (await mcp.tools.call("list_outbox", {})).json().emails.filter((e: string) => e.includes(id));

test("accepting closes the ticket and sends exactly one email", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } }));
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", closed: true });
  expect(await emailsAbout(mcp, "T-1")).toHaveLength(1);
});

test("declining sends no email", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "decline" }));
  const result = await mcp.tools.call("close_ticket", { id: "T-2" });
  expect(result.json()).toEqual({ id: "T-2", closed: false });
  expect(await emailsAbout(mcp, "T-2")).toHaveLength(0);
});
```

**Hint:**
Count the round trips. The first run of `execute()` stops at `elicit()`; the second runs from the top with the answer. Which lines run in both?

**Solution:**
`outbox.push()` ran before `elicit()`, so it ran on the first round trip, when nobody had answered yet, and again on the second. Moving it after the check means it runs once, and only when the user said yes. The same goes for anything else with a side effect: ask first, then act.

### Challenge: Turn elicitation on
This server exports its own `@FrontMcp` configuration, and `close_ticket` fails with "Elicitation is disabled". Turn elicitation on so the tool can ask.

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

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

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

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

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

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

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

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

test("close_ticket asks the user and closes on yes", async ({ mcp }) => {
  let asked = false;
  mcp.onElicitation(() => {
    asked = true;
    return { action: "accept", content: { confirm: true } };
  });
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(asked).toBe(true);
  expect(result.json()).toEqual({ id: "T-1", closed: true });
});
```

**Hint:**
It's one option on `@FrontMcp`, next to `info` and `apps`.

**Solution:**
`elicitation: { enabled: true }` in `@FrontMcp` lets tools call `this.elicit()`. With it, FrontMCP also adds the `sendElicitationResult` fallback tool for clients on older protocol versions that can't show forms. The Playground speaks 2026-07-28, so its Capabilities tab still lists only `close_ticket`.
