# Asking for Approval

> How to stop a destructive FrontMCP tool from running until the user approves it, with @frontmcp/plugin-approval. What the model sees when a call is refused, how to record and withdraw an approval, how long each kind lasts under MCP 2026-07-28, and why the model must never approve itself.

Source: https://frontmcp.dev/learn/asking-for-approval

Some tools shouldn't run just because the model decided to. Deleting a ticket can't be undone. `destructiveHint` tells the client so, but the client may run the tool without asking anyone, and [authorization](https://frontmcp.dev/learn/authorizing-calls) decides who may delete tickets, not whether this user agreed to let the assistant do it. `@frontmcp/plugin-approval` puts a gate in front of a tool: every call is refused until an approval of that tool is recorded for the caller. Your code records the approval once the user says yes, and can withdraw it. In FrontMCP 1.8.7 the plugin doesn't ask anyone itself, so this lesson builds the asking too.

**You will learn**
- How to require approval before a tool runs
- What the model sees when a call is refused, and how to tell it what to do next
- How to record an approval once the user says yes, and how to withdraw it
- How long an approval lasts under MCP 2026-07-28
- Why the model must never be able to approve its own calls

## A tool the model can run on its own

`delete_ticket` deletes a ticket for good, and says so with `destructiveHint`:

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

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}
```

```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: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);
```

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

test("the tool says it's destructive", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.annotations).toMatchObject({ destructiveHint: true });
});

test("🚩 and the call deletes the ticket, with nobody asked", async ({ mcp }) => {
  const result = await mcp.tools.call("delete_ticket", { id: "T-2" });
  expect(result.json()).toEqual({ id: "T-2", deleted: true });
  expect(tickets.has("T-2")).toBe(false);
});
```

A hint is only a hint. Some clients ask the user before a destructive call, some ask once and remember, and some don't ask at all, and your server can't tell which one it's talking to. [Asking the User Mid-Call](https://frontmcp.dev/learn/asking-the-user) shows how a tool can ask on every call. An approval is different: the user says yes once, your server records it, and a gate outside the tool checks for it on every call, until it expires or is withdrawn.

## Putting a tool behind approval

Install the plugin:

```bash
npm install @frontmcp/plugin-approval
```

Register it on the app with `ApprovalPlugin.init({ storage: { type: "memory" } })`, and give each tool that needs approval an `approval` field. `approvalMessage` is what a refused call says:

```ts help-desk.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { tickets } from "./store";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval first." },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets.get(id) ?? { id, found: false };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, DeleteTicket],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class HelpDeskApp {}
```

```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: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);
```

```ts gate.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { DeleteTicket } from "./help-desk.app";
import { tickets } from "./store";

test("an unapproved call is refused, and the ticket stays", async ({ mcp }) => {
  const result = await mcp.tools.call("delete_ticket", { id: "T-2" });
  expect(result).toBeError("APPROVAL_REQUIRED");
  expect(result.text()).toBe("delete_ticket needs the user's approval first.");
  expect(tickets.has("T-2")).toBe(true);
});

test("tools without `approval` aren't affected", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toMatchObject({ title: "Cannot log in" });
});

test("the gated tool is still listed", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("delete_ticket");
});

test("🚩 without the plugin, the server doesn't start", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket] })
  class Unprotected {}

  await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Unprotected] })).rejects.toThrow(
    `Tool "delete_ticket" declares 'approval' (enforced by ApprovalPlugin from @frontmcp/plugin-approval)`,
  );
});
```

The plugin checks after the arguments are validated and before `execute()` runs, so a refused call changes nothing. `get_ticket`, which has no `approval`, runs as before. `approval: true` works too, with a default message; either way, a tool with `approval` on a server with no approval plugin doesn't start, so a gate can't be left off by accident.

The refusal is a tool error, the kind the model reads. Open the Call tab: the text is `approvalMessage` and nothing more, and `_meta.code` is `APPROVAL_REQUIRED`. It reads the same in production ([What the client sees](https://frontmcp.dev/reference/plugins/approval#what-the-client-sees)), so put in `approvalMessage` what the model should do next: "Call approve_delete first."

> **Note**
Changed in 1.8.6: before, a refusal reached the client as an internal error. Its `_meta.code` was `SERVER_ERROR`, in development its text went on with `Original error:` and a stack trace, and in production it was only `Internal FrontMCP error. Please contact support with error ID: …`, so the model never learned that it needed approval.

## Recording an approval

Nothing can approve `delete_ticket` yet. The plugin adds `this.approval` to the tools of the app it's registered on (every app's, on `@FrontMcp`), which grants, checks and withdraws approvals for the caller. `approve_delete` grants one with `grantUserApproval()`, which lasts until it's withdrawn.

An approval is kept for a caller, so the tests need callers who are signed in. As in [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls), `server.ts` gives Nour and Sam a [static key](https://frontmcp.dev/learn/authenticating-clients#requiring-a-shared-key-static-mode) each, and `callAs()` sends a tool call with a key the way an MCP 2026-07-28 client would, to one server for every call. It also answers a form, if a tool asks one, the way the user would; this section doesn't need that yet:

```ts approve-delete.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

// An approval names its tool by the app's id and the tool's name
export const DELETE_TICKET = "help-desk:delete_ticket";

@Tool({ name: "approve_delete", description: "Approve delete_ticket for you.", inputSchema: {} })
export class ApproveDelete extends ToolContext {
  async execute() {
    const record = await this.approval.grantUserApproval(DELETE_TICKET); // 🚩 without asking anyone
    return { approved: true, scope: record.scope, grantedBy: record.grantedBy };
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveDelete } from "./approve-delete.tool";
import { tickets } from "./store";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, ApproveDelete],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class HelpDeskApp {}
```

```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: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);
```

```ts server.ts
import { HelpDeskApp } from "./help-desk.app";

// Two support agents, each with a key of their own.
// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
  auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] },
};
```

```ts call-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";

type Answer = { action: "accept" | "decline"; content?: Record<string, unknown> };
let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

/**
 * Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server.
 * If the tool asks the user something, `answer` is what the user replies; without one, they decline.
 */
export async function callAs(key: string, tool: string, args: Record<string, unknown> = {}, answer?: Answer) {
  server ??= FrontMcpInstance.createFetchHandler(config);
  const handler = await server;
  let reply = {};
  for (;;) {
    const response = await handler(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: {
            name: tool,
            arguments: args,
            ...reply,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
            },
          },
        }),
      }),
    );
    const { result } = await response.json();
    if (result.resultType !== "input_required") return result;
    // The tool asked the user: send their answer back, with the state the server returned
    const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }]));
    reply = { inputResponses, requestState: result.requestState };
  }
}
```

```ts approve.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { tickets } from "./store";

const NOUR = "agent-nour-key";
const SAM = "agent-sam-key";

test("delete_ticket runs once it's approved", async () => {
  expect((await callAs(NOUR, "delete_ticket", { id: "T-1" })).isError).toBe(true);
  expect((await callAs(NOUR, "approve_delete")).structuredContent).toMatchObject({ approved: true, scope: "user" });
  expect((await callAs(NOUR, "delete_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", deleted: true });
});

test("the approval names the caller as its grantor, and says nobody was asked", async () => {
  const { grantedBy } = (await callAs(NOUR, "approve_delete")).structuredContent;
  expect(grantedBy).toEqual({ source: "user", identifier: expect.stringMatching(/^static:/), method: "implicit" });
});

test("an approval belongs to the caller who got it", async () => {
  const result = await callAs(SAM, "delete_ticket", { id: "T-3" });
  expect(result.isError).toBe(true);
  expect(tickets.has("T-3")).toBe(true);
});

test("🚩 an anonymous caller's approval is gone by their next call", async ({ mcp }) => {
  // The Playground's own server, whose callers don't sign in
  expect(await mcp.tools.call("approve_delete", {})).toBeSuccessful();
  expect(await mcp.tools.call("delete_ticket", { id: "T-3" })).toBeError();
});
```

Once Nour has an approval, her `delete_ticket` runs. Sam has none, so his is still refused. The last test calls the Playground's own server: its caller is approved, and then refused anyway.

The result shows the record's `grantedBy`: without one in the grant's options, it's the caller who made the call, for a static key its `static:…` id, with `method: "implicit"`. `"implicit"` is the record saying that nobody was asked: the tool granted on its own. A caller who isn't signed in is `{ source: "user", method: "implicit" }`.

- **An approval belongs to a caller.** The gate looks for one recorded for the caller of each call: a signed-in user by their token's `sub`, a static key by its `static:…` id. A caller without credentials is a new anonymous caller on every MCP 2026-07-28 request, so an approval never carries over to their next call. Approvals need callers who [sign in](https://frontmcp.dev/learn/authenticating-clients).
- **An approval names its tool as `<app id>:<tool name>`**, like `help-desk:delete_ticket`, not the name clients see. `grantUserApproval("delete_ticket")` records an approval that nothing checks, without an error.
- **The store is in memory.** `type: "memory"` keeps approvals in the server's process, so a restart withdraws them all, and each instance has its own. In production, use Redis, Vercel KV, Upstash or Cloudflare KV ([Options](https://frontmcp.dev/reference/plugins/approval#options)).

## Only a person can say yes

Look at the first test again, from the model's side: it called `approve_delete`, then `delete_ticket`, and the ticket is gone. Nobody was asked. A tool that grants an approval without asking anyone lets the model approve itself, and the gate protects nothing.

The yes has to come from someone the model can't speak for. `this.elicit()` shows the user a form in their client, and under MCP 2026-07-28 only the user can answer it. `approve_delete` now asks first:

```ts approve-delete.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { userGrantor } from "@frontmcp/plugin-approval";

export const DELETE_TICKET = "help-desk:delete_ticket";

@Tool({
  name: "approve_delete",
  description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.",
  inputSchema: {},
})
export class ApproveDelete extends ToolContext {
  async execute() {
    // ✅ The user answers this form in their client. The model can't.
    const answer = await this.elicit("Allow the assistant to delete support tickets for you?", z.object({ allow: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.allow) return { approved: false };

    // The user was asked, so the record says so
    const record = await this.approval.grantUserApproval(DELETE_TICKET, { grantedBy: userGrantor(this.auth.user.sub) });
    return { approved: true, grantedBy: record.grantedBy };
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveDelete } from "./approve-delete.tool";
import { tickets } from "./store";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, ApproveDelete],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class HelpDeskApp {}
```

```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: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);
```

```ts server.ts
import { HelpDeskApp } from "./help-desk.app";

// Two support agents, each with a key of their own.
// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
  auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] },
};
```

```ts call-as.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";

type Answer = { action: "accept" | "decline"; content?: Record<string, unknown> };
let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

export async function callAs(key: string, tool: string, args: Record<string, unknown> = {}, answer?: Answer) {
  server ??= FrontMcpInstance.createFetchHandler(config);
  const handler = await server;
  let reply = {};
  for (;;) {
    const response = await handler(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: {
            name: tool,
            arguments: args,
            ...reply,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
            },
          },
        }),
      }),
    );
    const { result } = await response.json();
    if (result.resultType !== "input_required") return result;
    const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }]));
    reply = { inputResponses, requestState: result.requestState };
  }
}
```

```ts ask.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";

const NOUR = "agent-nour-key";
const SAM = "agent-sam-key";

test("saying no, or closing the form, approves nothing", async () => {
  expect((await callAs(SAM, "approve_delete", {}, { action: "accept", content: { allow: false } })).structuredContent).toEqual({ approved: false });
  expect((await callAs(SAM, "approve_delete", {}, { action: "decline" })).structuredContent).toEqual({ approved: false });
  expect((await callAs(SAM, "delete_ticket", { id: "T-2" })).isError).toBe(true);
});

test("the user's yes lets delete_ticket run, and records who gave it", async () => {
  const approved = await callAs(NOUR, "approve_delete", {}, { action: "accept", content: { allow: true } });
  expect(approved.structuredContent).toMatchObject({
    approved: true,
    grantedBy: { source: "user", identifier: expect.stringMatching(/^static:/), method: "interactive" },
  });
  expect((await callAs(NOUR, "delete_ticket", { id: "T-2" })).structuredContent).toEqual({ id: "T-2", deleted: true });
});
```

Press **Call** in the Call tab: `approve_delete` shows the form the user would see. The model can still call `approve_delete`, and it should when `delete_ticket` is refused, as its description says. What it can't do is answer. The tests answer the form both ways: no, or closing it, approves nothing, and yes records an approval.

Without a `grantedBy` option, the grant records the caller as `method: "implicit"`, form or no form: the plugin can't tell that a tool asked. `approve_delete` passes `grantedBy: userGrantor(this.auth.user.sub)`, which says `"interactive"`, because the user answered a form first. Pass `grantedBy` yourself too when the grant is made on someone else's behalf, like a team lead approving a teammate, which is what the grantor functions are for ([Grantors and revokers](https://frontmcp.dev/reference/plugins/approval#grantors-and-revokers)).

> **Note**
Changed in 1.8.7: the default `grantedBy` records `method: "implicit"`, because a tool that grants through `this.approval` asked no one. In 1.8.6 it said `"interactive"`, which an audit trail reads as the user saying yes, and before 1.8.6 it was `{ source: "policy" }`. A tool that did ask passes `grantedBy: userGrantor(id)`.

> **Note**
A client on a protocol version before 2026-07-28 that can't show forms gets FrontMCP's fallback instead: the model is told to ask the user in the chat and passes the answer on with `sendElicitationResult` ([this.elicit](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult)). There, the yes comes through the model. Where that matters, have someone with more rights grant the approval, like a team lead approving a teammate for a limited time ([Approving for a limited time](https://frontmcp.dev/reference/plugins/approval#approving-for-a-limited-time)).

## Withdrawing an approval, and letting it expire

A user approval lasts until it's withdrawn, which is more than most users mean by yes. `this.approval.revokeApproval(toolId)` withdraws the caller's approvals of a tool and says whether there were any. And `grantTimeLimitedApproval(toolId, ttlMs)` grants one that expires by itself. Here `approve_delete` grants for a number of minutes, 15 unless the model asks for another number, and says so in the form. The tests move the clock forward by replacing `Date.now()`, which is what the plugin reads:

```ts approval-tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

export const DELETE_TICKET = "help-desk:delete_ticket";

@Tool({
  name: "approve_delete",
  description: "Ask the user to let you delete support tickets for them, for some minutes. Call it when delete_ticket says it needs approval.",
  inputSchema: { minutes: z.number().int().min(1).max(60).default(15).describe("How long the approval lasts") },
})
export class ApproveDelete extends ToolContext {
  async execute({ minutes }: { minutes: number }) {
    const question = `Allow the assistant to delete support tickets for you for the next ${minutes} minutes?`;
    const answer = await this.elicit(question, z.object({ allow: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.allow) return { approved: false };

    const record = await this.approval.grantTimeLimitedApproval(DELETE_TICKET, minutes * 60_000);
    return { approved: true, expiresAt: new Date(record.expiresAt!).toISOString() };
  }
}

@Tool({ name: "revoke_delete", description: "Withdraw your approval of delete_ticket.", inputSchema: {} })
export class RevokeDelete extends ToolContext {
  async execute() {
    return { revoked: await this.approval.revokeApproval(DELETE_TICKET) };
  }
}

@Tool({ name: "my_revocations", description: "The approvals of delete_ticket you withdrew in the last day.", inputSchema: {} })
export class MyRevocations extends ToolContext {
  async execute() {
    const revoked = await this.approval.getRevocations(DELETE_TICKET);
    return { revocations: revoked.map((r) => ({ scope: r.scope, revokedBy: r.revokedBy })) };
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveDelete, MyRevocations, RevokeDelete } from "./approval-tools";
import { tickets } from "./store";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, ApproveDelete, RevokeDelete, MyRevocations],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class HelpDeskApp {}
```

```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: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);
```

```ts server.ts
import { HelpDeskApp } from "./help-desk.app";

// Two support agents, each with a key of their own.
// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
  auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] },
};
```

```ts call-as.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";

type Answer = { action: "accept" | "decline"; content?: Record<string, unknown> };
let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

export async function callAs(key: string, tool: string, args: Record<string, unknown> = {}, answer?: Answer) {
  server ??= FrontMcpInstance.createFetchHandler(config);
  const handler = await server;
  let reply = {};
  for (;;) {
    const response = await handler(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: {
            name: tool,
            arguments: args,
            ...reply,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
            },
          },
        }),
      }),
    );
    const { result } = await response.json();
    if (result.resultType !== "input_required") return result;
    const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }]));
    reply = { inputResponses, requestState: result.requestState };
  }
}
```

```ts lifetime.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";

const NOUR = "agent-nour-key";
const SAM = "agent-sam-key";
const yes = { action: "accept" as const, content: { allow: true } };
const deletes = async (key: string, id: string) => ((await callAs(key, "delete_ticket", { id })).isError ? "refused" : "ran");

/** Runs `fn` with the clock `minutes` ahead. */
async function inMinutes<T>(minutes: number, fn: () => Promise<T>) {
  const realNow = Date.now;
  const now = realNow();
  Date.now = () => now + minutes * 60_000;
  try {
    return await fn();
  } finally {
    Date.now = realNow;
  }
}

test("revoking withdraws the approval", async () => {
  await callAs(NOUR, "approve_delete", {}, yes);
  expect((await callAs(NOUR, "revoke_delete")).structuredContent).toEqual({ revoked: true });
  expect(await deletes(NOUR, "T-1")).toBe("refused");
  expect((await callAs(NOUR, "revoke_delete")).structuredContent).toEqual({ revoked: false });
});

test("a withdrawn approval is kept for a day, with who withdrew it", async () => {
  await callAs(NOUR, "approve_delete", {}, yes);
  await callAs(NOUR, "revoke_delete");
  const { revocations } = (await callAs(NOUR, "my_revocations")).structuredContent;
  expect(revocations.at(-1)).toEqual({
    scope: "time_limited",
    revokedBy: { source: "user", identifier: expect.stringMatching(/^static:/), method: "implicit" },
  });
  const nextDay = await inMinutes(24 * 60 + 1, () => callAs(NOUR, "my_revocations"));
  expect(nextDay.structuredContent).toEqual({ revocations: [] });
});

test("a 15-minute approval works for 15 minutes", async () => {
  await callAs(SAM, "approve_delete", { minutes: 15 }, yes);
  expect(await inMinutes(14, () => deletes(SAM, "T-2"))).toBe("ran");
  expect(await inMinutes(16, () => deletes(SAM, "T-3"))).toBe("refused");
});
```

Once an approval is withdrawn or has expired, the refusal is the same as before it was granted, so the model knows to call `approve_delete` again, and the user can say yes for another 15 minutes. A withdrawn approval isn't forgotten at once: the plugin keeps a copy for 24 hours, with who withdrew it, and `this.approval.getRevocations(toolId)` returns the caller's, as `my_revocations` does. `this.approval` has three grants for the caller:

| Grant | Lasts | Under MCP 2026-07-28 |
| --- | --- | --- |
| `grantSessionApproval(toolId)` | Until it's withdrawn, for the caller's session | There are no sessions, so it's kept for the signed-in caller, in every conversation |
| `grantUserApproval(toolId)` | Until it's withdrawn, for the caller | The same |
| `grantTimeLimitedApproval(toolId, ttlMs)` | `ttlMs` milliseconds | The same |

A tool can also cap every approval of it, however it was granted, with `maxTtlMs` in its `approval`: the gate ignores an approval older than that. Every field of `approval` is in [the reference](https://frontmcp.dev/reference/plugins/approval#the-approval-tool-option).

> **Pitfall: Under 2026-07-28, a session approval is a user approval**
An approval the user gave "for this session" sounds like it ends with the conversation. MCP 2026-07-28 has no sessions, so the plugin keeps it for the signed-in caller, and it lasts for every later conversation, on every client, until it's withdrawn or the store is lost. When an approval should end, give it a time limit.

> **Note**
Register the plugin once. On `@FrontMcp`, it gates every app's tools. In one app's `plugins`, it gates that app's tools and those of every app without an approval plugin of its own, but only its own app's tools get `this.approval`. On both, the two gates decide together, and an approval in either store lets the tool run, but each store holds approvals the other doesn't know about ([Register the plugin once](https://frontmcp.dev/reference/plugins/approval#how-a-call-is-checked)).

## Recap

- `@frontmcp/plugin-approval` refuses every call to a tool with `approval` until an approval of it is recorded for the caller. Register `ApprovalPlugin.init({ storage: { type: "memory" } })`, and give the tool `approval: true` or `approval: { approvalMessage }`.
- A refused call is a tool error with the text `approvalMessage` and the code `APPROVAL_REQUIRED`, in production too, so say in it what the model should do next.
- `this.approval` grants, checks and withdraws approvals for the caller, and records the caller as `grantedBy`. Tools are named `<app id>:<tool name>`, and approvals need callers who sign in.
- Grant only after a person says yes, in a form from `this.elicit()`, or from a caller with more rights. A tool that grants without asking lets the model approve itself, though its `grantedBy` says `"implicit"`.
- `revokeApproval()` withdraws an approval, `grantTimeLimitedApproval()` grants one that expires, and `maxTtlMs` caps every approval of a tool. Under MCP 2026-07-28, a session approval lasts for the signed-in caller.
- The store, the grantors, contexts and every option are in the [Approval plugin reference](https://frontmcp.dev/reference/plugins/approval).

## Try some challenges

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

### Challenge: Put the refund behind approval
`refund_invoice` sends money back to a customer, and nothing stops the model from calling it. `approve_refund` is already written. Make `refund_invoice` refuse every call that isn't approved, with a refusal that tells the model to call `approve_refund`. `get_invoice` must keep working.

```ts billing.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApproveRefund } from "./approve-refund.tool";
import { invoices } from "./store";

@Tool({
  name: "refund_invoice",
  description: "Refund an invoice in full.",
  inputSchema: { id: z.string().describe("Invoice id, like INV-7") },
  annotations: { destructiveHint: true },
})
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    invoices.set(id, "refunded");
    return { id, status: "refunded" };
  }
}

@Tool({ name: "get_invoice", description: "Get an invoice's status.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: invoices.get(id) ?? "unknown" };
  }
}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice, GetInvoice, ApproveRefund] })
export class BillingApp {}
```

```ts billing.app.ts solution
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveRefund } from "./approve-refund.tool";
import { invoices } from "./store";

@Tool({
  name: "refund_invoice",
  description: "Refund an invoice in full.",
  inputSchema: { id: z.string().describe("Invoice id, like INV-7") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "refund_invoice needs the user's approval. Call approve_refund first." },
})
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    invoices.set(id, "refunded");
    return { id, status: "refunded" };
  }
}

@Tool({ name: "get_invoice", description: "Get an invoice's status.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: invoices.get(id) ?? "unknown" };
  }
}

@App({
  id: "billing",
  name: "Billing",
  tools: [RefundInvoice, GetInvoice, ApproveRefund],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class BillingApp {}
```

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

@Tool({ name: "approve_refund", description: "Ask the user to let you refund invoices for them.", inputSchema: {} })
export class ApproveRefund extends ToolContext {
  async execute() {
    const answer = await this.elicit("Allow the assistant to refund invoices for you?", z.object({ allow: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.allow) return { approved: false };
    await this.approval.grantUserApproval("billing:refund_invoice");
    return { approved: true };
  }
}
```

```ts store.ts
export const invoices = new Map([
  ["INV-7", "paid"],
  ["INV-8", "paid"],
]);
```

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

test("an unapproved refund is refused, and nothing is refunded", async ({ mcp }) => {
  const result = await mcp.tools.call("refund_invoice", { id: "INV-8" });
  expect(result).toBeError();
  expect(invoices.get("INV-8")).toBe("paid");
});

test("the refusal tells the model to call `approve_refund`", async ({ mcp }) => {
  const result = await mcp.tools.call("refund_invoice", { id: "INV-8" });
  expect(result.text()).toContain("approve_refund");
});

test("`get_invoice` still works", async ({ mcp }) => {
  expect((await mcp.tools.call("get_invoice", { id: "INV-7" })).json()).toMatchObject({ id: "INV-7" });
});
```

**Hint:**
The gate takes two changes: one on the app, one on the tool. What the refusal says is a field of the tool's `approval`.

**Solution:**
`ApprovalPlugin.init({ storage: { type: "memory" } })` on the app installs the gate, and `approval` on `refund_invoice` puts the tool behind it, with an `approvalMessage` that names `approve_refund`: that's the text the model reads when the call is refused. `get_invoice` has no `approval`, so it isn't gated.

### Challenge: Ask before approving
`approve_delete` grants an approval as soon as it's called, so the model can approve its own deletes. Make it ask the user first, and grant only when they say yes.

```ts approve-delete.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

export const DELETE_TICKET = "help-desk:delete_ticket";

@Tool({
  name: "approve_delete",
  description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.",
  inputSchema: {},
})
export class ApproveDelete extends ToolContext {
  async execute() {
    await this.approval.grantUserApproval(DELETE_TICKET);
    return { approved: true };
  }
}
```

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

export const DELETE_TICKET = "help-desk:delete_ticket";

@Tool({
  name: "approve_delete",
  description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.",
  inputSchema: {},
})
export class ApproveDelete extends ToolContext {
  async execute() {
    const answer = await this.elicit("Allow the assistant to delete support tickets for you?", z.object({ allow: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.allow) return { approved: false };

    await this.approval.grantUserApproval(DELETE_TICKET);
    return { approved: true };
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveDelete } from "./approve-delete.tool";
import { tickets } from "./store";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, ApproveDelete],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class HelpDeskApp {}
```

```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: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);
```

```ts server.ts
import { HelpDeskApp } from "./help-desk.app";

// Two support agents, each with a key of their own.
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
  auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] },
};
```

```ts call-as.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";

type Answer = { action: "accept" | "decline"; content?: Record<string, unknown> };
let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

export async function callAs(key: string, tool: string, args: Record<string, unknown> = {}, answer?: Answer) {
  server ??= FrontMcpInstance.createFetchHandler(config);
  const handler = await server;
  let reply = {};
  for (;;) {
    const response = await handler(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: {
            name: tool,
            arguments: args,
            ...reply,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
            },
          },
        }),
      }),
    );
    const { result } = await response.json();
    if (result.resultType !== "input_required") return result;
    const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }]));
    reply = { inputResponses, requestState: result.requestState };
  }
}
```

```ts ask.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";

const deletes = async (key: string, id: string) => ((await callAs(key, "delete_ticket", { id })).isError ? "refused" : "ran");

test("the user is asked, and saying no approves nothing", async () => {
  await callAs("agent-sam-key", "approve_delete", {}, { action: "accept", content: { allow: false } });
  await callAs("agent-sam-key", "approve_delete", {}, { action: "decline" });
  expect(await deletes("agent-sam-key", "T-2")).toBe("refused");
});

test("the user's yes lets `delete_ticket` run", async () => {
  await callAs("agent-nour-key", "approve_delete", {}, { action: "accept", content: { allow: true } });
  expect(await deletes("agent-nour-key", "T-1")).toBe("ran");
});
```

**Hint:**
A tool can pause and ask the person using the client a question. The answer has a `status`, and, when they accepted, `content`.

**Solution:**
`this.elicit()` shows the user a form with one checkbox, and `approve_delete` grants only when the answer is `accept` with `allow: true`. The checks answer the form as the user would: no, a closed form, and yes. The model can still call `approve_delete`, but under MCP 2026-07-28 it can't answer the form.

### Challenge: Make approvals last an hour at most
An approval of `delete_ticket` lasts until it's withdrawn, and users forget they gave one. Make every approval of `delete_ticket` stop working an hour after it's granted.

```ts help-desk.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveDelete } from "./approve-delete.tool";
import { tickets } from "./store";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, ApproveDelete],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class HelpDeskApp {}
```

```ts help-desk.app.ts solution
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveDelete } from "./approve-delete.tool";
import { tickets } from "./store";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval, which lasts an hour: call approve_delete first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first.", maxTtlMs: 60 * 60_000 },
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, ApproveDelete],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export class HelpDeskApp {}
```

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

@Tool({
  name: "approve_delete",
  description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.",
  inputSchema: {},
})
export class ApproveDelete extends ToolContext {
  async execute() {
    const answer = await this.elicit("Allow the assistant to delete support tickets for you?", z.object({ allow: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.allow) return { approved: false };
    await this.approval.grantUserApproval("help-desk:delete_ticket");
    return { approved: 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: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
]);
```

```ts server.ts
import { HelpDeskApp } from "./help-desk.app";

// Two support agents, each with a key of their own.
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
  auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] },
};
```

```ts call-as.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";

type Answer = { action: "accept" | "decline"; content?: Record<string, unknown> };
let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

export async function callAs(key: string, tool: string, args: Record<string, unknown> = {}, answer?: Answer) {
  server ??= FrontMcpInstance.createFetchHandler(config);
  const handler = await server;
  let reply = {};
  for (;;) {
    const response = await handler(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: {
            name: tool,
            arguments: args,
            ...reply,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
            },
          },
        }),
      }),
    );
    const { result } = await response.json();
    if (result.resultType !== "input_required") return result;
    const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }]));
    reply = { inputResponses, requestState: result.requestState };
  }
}
```

```ts expiry.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";

const NOUR = "agent-nour-key";
const yes = { action: "accept" as const, content: { allow: true } };
const deletes = async (id: string) => ((await callAs(NOUR, "delete_ticket", { id })).isError ? "refused" : "ran");

async function inMinutes<T>(minutes: number, fn: () => Promise<T>) {
  const realNow = Date.now;
  const now = realNow();
  Date.now = () => now + minutes * 60_000;
  try {
    return await fn();
  } finally {
    Date.now = realNow;
  }
}

test("an approval works for the first hour", async () => {
  await callAs(NOUR, "approve_delete", {}, yes);
  expect(await inMinutes(59, () => deletes("T-1"))).toBe("ran");
});

test("and not after it", async () => {
  await callAs(NOUR, "approve_delete", {}, yes);
  expect(await inMinutes(61, () => deletes("T-2"))).toBe("refused");
});
```

**Hint:**
You could change how `approve_delete` grants. But the rule is about `delete_ticket`, whoever grants its approvals: one field of its `approval` says how long any approval of it lasts.

**Solution:**
`maxTtlMs: 60 * 60_000` in `delete_ticket`'s `approval` is a rule on the tool: the gate ignores any approval of it older than an hour, and a grant through `this.approval` without its own `ttlMs` gets that hour. Granting with `grantTimeLimitedApproval(toolId, 60 * 60_000)` in `approve_delete` would pass the checks too, but only for approvals granted there; `maxTtlMs` also covers any other tool that grants one. The description tells the model how long an approval lasts.
