Asking for Approval

IntermediateMCP 2026-07-28

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 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:

Open
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) };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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:

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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), so put in approvalMessage what the model should do next: "Call approve_delete first."

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, server.ts gives Nour and Sam a static key 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:

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.
  • 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).

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:

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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).

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:

Open
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 })) };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

GrantLastsUnder MCP 2026-07-28
grantSessionApproval(toolId)Until it's withdrawn, for the caller's sessionThere are no sessions, so it's kept for the signed-in caller, in every conversation
grantUserApproval(toolId)Until it's withdrawn, for the callerThe same
grantTimeLimitedApproval(toolId, ttlMs)ttlMs millisecondsThe 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.

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.

Try some challenges

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

Challenge 1 of 3

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.

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.