# Approval plugin

> @frontmcp/plugin-approval refuses to run a tool until an approval is recorded for the caller. Every option, how approvals are granted, revoked and expire, what the client sees, and what the gate does and doesn't enforce.

Source: https://frontmcp.dev/reference/plugins/approval

`@frontmcp/plugin-approval` puts a gate in front of tools that shouldn't run without someone's say-so, like deleting a ticket or refunding a payment. Mark a tool with `approval: true` and every call to it is refused until an approval of that tool is recorded for the caller. Your code records approvals, and revokes them, through the plugin's store or `this.approval`, and each one keeps who granted it. The plugin doesn't ask anyone itself: in FrontMCP 1.9.4 there's no prompt, webhook or callback, so how a person says yes is up to you, for example with a form from [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit). [Asking for Approval](https://frontmcp.dev/learn/asking-for-approval) builds the gate and the asking step by step.

```ts
ApprovalPlugin.init({ storage?, storageInstance?, namespace?, cleanupIntervalSeconds?, mode?, webhook?, recheck?, enableAudit?, maxDelegationDepth? })

@Tool({ ..., approval: true | { required?, skipApproval?, alwaysPrompt?, approvalMessage?, category?, riskLevel?, defaultScope?, allowedScopes?, maxTtlMs?, preApprovedContexts? } })
```

---

## Reference

### `ApprovalPlugin.init(options)`

Install the package, then register the plugin in the `plugins` of an [`@App`](https://frontmcp.dev/reference/sdk/app) or of [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp). It isn't part of the `@frontmcp/plugins` umbrella package.

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

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { DeleteTicket, GetTicket } from "./tools";

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  // On @FrontMcp, the gate covers every app's tools
  plugins: [ApprovalPlugin.init({ storage: { type: "redis", redis: { url: process.env.REDIS_URL } } })],
})
export default class Server {}
```

`ApprovalPlugin.init()` with no argument works and means `ApprovalPlugin.init({})`. Before 1.8.6 it [threw when the module loaded](#typeerror-cannot-read-properties-of-undefined-reading-providers). The plugin works in CommonJS and ES module projects alike. [See more examples below.](#usage)

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `storage` | `StorageConfig` | `{ type: "auto" }` | Where approvals are kept. `type` is `"memory"`, `"redis"`, `"vercel-kv"`, `"upstash"`, `"cloudflare-kv"` or `"auto"`, with the matching `memory`, `redis`, `vercelKv`, `upstash` or `cloudflareKv` settings next to it. `"auto"` picks Upstash when `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set, Vercel KV for `KV_REST_API_URL` and `KV_REST_API_TOKEN`, Redis for `REDIS_URL` or `REDIS_HOST`, and memory otherwise (with a warning in production). Memory belongs to one process and is lost on restart. The Playground has no Redis, so its examples use `{ type: "memory" }`. |
| `storageInstance` | `RootStorage \| NamespacedStorage` | | A storage you already made with `createStorage()` from `@frontmcp/utils`. It wins over `storage`. |
| `namespace` | `string` | `"approval"` | The prefix of the plugin's keys in the storage. Challenges (below) use `<namespace>:challenge`. |
| `cleanupIntervalSeconds` | `number` | `60` | How often expired approvals are deleted from the storage. `0` turns it off. An expired approval is ignored either way. |
| `mode` | `"recheck" \| "webhook"` | `"recheck"` | Neither contacts an external system. `"recheck"` checks the stored approvals on every call, as the gate does in either mode, and adds nothing. `"webhook"` also registers a [`ChallengeService`](#challengeservice). See [webhook mode](#webhook-mode). |
| `webhook.challengeTtl` | `number` (seconds) | `300` | How long the `ChallengeService`'s challenges last. |
| `webhook.url`, `webhook.callbackPath`, `webhook.includeJwt` | | | Accepted and unused: no request is sent and no route is served. |
| `recheck.url`, `recheck.auth`, `recheck.interval`, `recheck.maxAttempts` | | | Accepted and unused: nothing is polled. |
| `enableAudit` | `boolean` | `true` | Unused. Every record keeps its grantor whatever the setting. |
| `maxDelegationDepth` | `number` | `3` | Unused. |

`plugins: [ApprovalPlugin]`, the class without `init()`, is the same as `ApprovalPlugin.init()`, with the defaults. Changed in 1.9.4: the class didn't create the store, so every call to a tool with `approval` failed with `Provider "[ref]" is not available` (code `PROVIDER_NOT_AVAILABLE`).

### The `approval` tool option

The plugin adds `approval` to [`@Tool`](https://frontmcp.dev/reference/sdk/tool) and [`@Agent`](https://frontmcp.dev/reference/sdk/agent). On an agent it gates `invoke_<agent>`, the tool clients call it with ([Gating an agent](#gating-an-agent)). Resources, prompts and jobs can't require approval.

```ts delete-ticket.tool.ts
@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_tool first.",
  inputSchema: { id: z.string() },
  approval: { approvalMessage: "delete_ticket needs approval. Call approve_tool first.", category: "delete", riskLevel: "high" },
})
```

`approval: true` is `{ required: true, defaultScope: "session" }`. Any object, even `{}`, requires approval unless it says `required: false`. `false` or no `approval` at all means no gate.

A server where a tool or agent has `approval` and no `ApprovalPlugin` reaches it doesn't start: it fails with `UnenforcedMetadataError` ([Troubleshooting](#unenforced-metadata-tool--declares-approval)). A tool declared inside an `@Agent` is reached only by a plugin in that agent's own `plugins`.

| Field | Type | Default | What FrontMCP 1.9.4 does with it |
| --- | --- | --- | --- |
| `required` | `boolean` | `true` | `false` turns the gate off. |
| `skipApproval` | `boolean` | `false` | `true` turns the gate off too. |
| `alwaysPrompt` | `boolean` | `false` | Each approval lets one call run, and that call uses it up: the next call needs a new approval. `preApprovedContexts` doesn't apply. See [how strict a tool is](#making-a-tool-more-or-less-strict). |
| `approvalMessage` | `string` | `Tool "<id>" requires approval to execute. Allow?` | The text of the refusal. |
| `preApprovedContexts` | `ApprovalContext[]` | | Lets the call through without an approval when `this.context.authInfo.extra.approvalContext` has the same `type` and `identifier`, unless the tool has `alwaysPrompt`. None of FrontMCP's auth modes set that value, so it never matches unless your own code does ([Approving in a context](#approving-in-a-context)). The call's arguments can't set it. |
| `category` | `"read" \| "write" \| "delete" \| "execute" \| "admin"` | | Kept on the error's `details`, which clients never see. |
| `riskLevel` | `"low" \| "medium" \| "high" \| "critical"` | | The same. |
| `defaultScope` | `ApprovalScope` | `"session"` | The same. |
| `allowedScopes` | `ApprovalScope[]` | all | The scopes an approval of this tool may have. A grant through `this.approval` of another scope throws `ApprovalScopeNotAllowedError`, and a stored approval of another scope doesn't open the gate. |
| `maxTtlMs` | `number` | | The longest an approval of this tool lasts. A grant through `this.approval` with a longer `ttlMs` throws, and one without a `ttlMs` gets `maxTtlMs`. The gate ignores every approval older than `grantedAt + maxTtlMs`, whatever its own expiry. |

The store's `grantApproval()` doesn't check `allowedScopes` or `maxTtlMs`: it stores what you give it, and the gate then ignores a record the tool's policy doesn't accept. [Making a tool more or less strict](#making-a-tool-more-or-less-strict) shows each field.

#### How a call is checked

The plugin checks in a `Will("execute")` [hook](https://frontmcp.dev/reference/sdk/hooks) at priority 100: after the arguments are validated, before `execute()`. Calls made with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) are checked too.

1. The tool has no `approval`, or `required: false` or `skipApproval: true`: it runs.
2. The plugin works out who's calling. The **user** is `this.context.authInfo.extra.userId`, else `extra.sub`, else `authInfo.clientId`. With FrontMCP's auth modes that's `this.auth.user.sub`: a token's subject, `static:…` for a static key, and `anon:` with a new id **on every request** for a caller without credentials. The **session** is the session the server verified, which a request has only when it presents one, as clients on a protocol version before 2026-07-28 do (with `mcp-session-id`, or an SSE client's `sessionId`), and `stateless-user:<user>` otherwise.
3. It reads the caller's unexpired records of the tool: those stored under that session, under that user, or under both, and, when the call's session carries a context in `authInfo.extra.approvalContext`, those stored for that context. The id is `<app id>:<tool name>`, like `help-desk:delete_ticket`, not the name clients see.
4. A record whose `state` is `"denied"` refuses the call with `Tool "…" execution denied.`, even next to an approval. The plugin never writes one, and has no method to (so it isn't shown here): only code that writes to the storage itself can.
5. A matching `preApprovedContexts` entry lets the call run, unless the tool has `alwaysPrompt`.
6. An approval the tool's policy accepts lets it run: its scope is in `allowedScopes`, and it has expired neither by its own `expiresAt` nor by `maxTtlMs`. With `alwaysPrompt`, the call uses that approval up: it's deleted, without a [revocation](#revocations), and when two calls race for one approval, only one runs. Anything else is refused with `approvalMessage`.

Changed in 1.9: `alwaysPrompt` refused every call, approved or not. Changed in 1.9.2: a matching `preApprovedContexts` entry let every call of an `alwaysPrompt` tool run.

> **Pitfall: Register the plugin once**
Registered on `@FrontMcp`, the plugin gates every app's tools. Registered in one app's `plugins`, it gates that app's tools and those of every app without an `ApprovalPlugin` of its own, using its own store ([Gating every app's tools](#gating-every-apps-tools)). But `this.approval` and the store are only in the app that registered it: the other apps' tools can't grant or check approvals. Registered on `@FrontMcp` **and** on an app, both gates cover that app's tools, and decide each call once, over both stores: an approval in either lets it run. That works, but each store has approvals the other doesn't know about, so register it once.

Changed in 1.9: with the plugin on `@FrontMcp` and on an app, that app's tools needed an approval in each store, so an approval granted with the tool's own `this.approval` never let it run. And every app's tools could reach `this.approval` and the store.

#### What the client sees

A refused call is a tool error, the kind the model reads, not a protocol error. The refusal is a `PublicMcpError`, so its message reaches the model as it is, in every environment:

| | Development | Production |
| --- | --- | --- |
| `isError` | `true` | `true` |
| `_meta.code` | `"APPROVAL_REQUIRED"` | `"APPROVAL_REQUIRED"` |
| Text | `approvalMessage` | `approvalMessage` |
| `_meta.stack` | The error's stack, with absolute paths | Not sent |

The tool's id, `category`, `riskLevel` and the rest stay on the server. `approvalMessage` is the only thing the model learns from a refusal, so say in it what to do next: which tool to call, or who to ask. The default, `Tool "<id>" requires approval to execute. Allow?`, doesn't. The tool is still listed in `tools/list`.

> **Note**
Changed in 1.8.6: before, a refusal was wrapped as an internal error. Its `_meta.code` was `"SERVER_ERROR"`, its text went on with `Original error:` and a stack trace in development, and in production it was `Internal FrontMCP error. Please contact support with error ID: …`, so the model couldn't tell a missing approval from a crash.

#### Caveats

- Approvals are keyed by `<app id>:<tool name>`. Granting `"delete_ticket"` records an approval nobody checks. An agent's is `<app id>:invoke_<agent name>`.
- A caller without credentials gets a new user id on every request, so an approval granted to them never applies to their next call. Approvals need signed-in users.
- Memory storage is per process: approvals vanish on restart and aren't shared between instances.

> **Pitfall: Under 2026-07-28, a session approval is a user approval**
Clients on protocol 2026-07-28 have no sessions, so the plugin keys "session" approvals by the user: `stateless-user:<user>`. An approval the user gave "for this conversation" lasts for every later conversation, on every client, until it's revoked, cleared or expires, or memory storage is lost. Grant time-limited approvals when that matters ([Approving for a limited time](#approving-for-a-limited-time)).

### The store: `ApprovalStoreToken`

`this.get(ApprovalStoreToken)` returns the plugin's `ApprovalStore`, in the apps the plugin is registered for: every app on `@FrontMcp`, that app on `@App`. Use it to grant or revoke for someone other than the caller, like a lead approving a teammate, because you say exactly whose approval it is.

| Method | Description |
| --- | --- |
| `grantApproval({ toolId, scope, userId?, sessionId?, ttlMs?, context?, grantedBy?, reason?, metadata? })` | Stores an approval, with `state: "approved"`, under `toolId` plus whichever of `sessionId`, `userId` and `context` you give, and returns the record. `ttlMs` sets `expiresAt`, and must be a positive number: `0`, a negative number, `NaN` or `Infinity` throws `ApprovalOperationError`, and so does `scope: TIME_LIMITED` without a `ttlMs`. `grantedBy` defaults to `{ source: "user" }`: the store doesn't know who's calling, unlike `this.approval`. It doesn't check the tool's `allowedScopes` or `maxTtlMs`; the gate does. The gate reads a record stored for the caller's session, their user, or both, and one with a `context` only in that context. |
| `revokeApproval({ toolId, sessionId?, userId?, context?, revokedBy?, reason? })` | Deletes every approval of the tool stored for that session or that user: session, user, time-limited and context approvals alike, or only that context's with `context`. Recorded denials stay. Returns whether it deleted anything. Each approval it deletes is kept for 24 hours as a revocation, with `revokedBy` (default `{ source: "user" }`) and `reason`, which [`getRevocations()`](#revocations) reads. |
| `getApprovals(toolId, sessionId, userId?, context?)` | What the gate reads: every unexpired record of the tool for that session or user, and for `context` when given. |
| `getApproval(toolId, sessionId, userId?, context?)` | One of those records, a denial first. |
| `getRevocations(toolId, sessionId, userId?, context?)` | The approvals of the tool revoked in the last 24 hours for that session or user, oldest first: each is the approval that was revoked, with `revokedAt`, `revokedBy` and `revocationReason`. The store interface leaves it optional: a store that keeps no revocations has none. |
| `consumeApproval(record, sessionId, userId?, context?)` | Deletes that approval, which `getApprovals()` returned, and returns `true` only for the one call that deleted it. The gate uses it for `alwaysPrompt` tools. The store interface leaves it optional: for a store of your own without it, the gate revokes the caller's approvals of the tool instead, so make it compare and delete in one step. |
| `isApproved(toolId, sessionId, userId?, context?)` | Whether that record is an approval. Neither checks the tool's `allowedScopes` or `maxTtlMs`. |
| `queryApprovals(query)` | Records matching every filter given: `toolId`, `toolIds`, `scope`, `scopes`, `state`, `states`, `sessionId`, `userId`, `context`. Expired ones only with `includeExpired: true`. |
| `clearSessionApprovals(sessionId)` | Deletes every record stored under exactly that session. Returns how many. |
| `clearExpiredApprovals()` | What the cleanup timer runs. Returns how many were deleted. |
| `getStats()` | `{ totalApprovals, byScope, byState }`. |
| `initialize()`, `close()` | The plugin calls `initialize()` when the server starts. |

> **Pitfall: Don't let the model approve its own calls**
The plugin can't tell who grants an approval. A tool that grants without asking anyone lets the model approve itself, and the gate protects nothing. The record's `grantedBy` tells you afterwards, not in time: through `this.approval` it says `method: "implicit"` unless the tool passes one, and nothing checks it before the tool runs. Grant only after a person says yes: in a form from `this.elicit()`, which the user answers and the model can't ([Asking the user](#asking-the-user-then-approving)), or from a caller with more rights ([Approving for a limited time](#approving-for-a-limited-time)).

### `this.approval`

The plugin adds `this.approval`, an `ApprovalService` for the caller, to every tool, resource and prompt of the apps it's registered for. Elsewhere, reading it throws `ApprovalPlugin is not installed. Add ApprovalPlugin.init() to your plugins array.` Its methods fill in the caller's session and user from [step 2 above](#how-a-call-is-checked). `toolId` is always the `<app id>:<tool name>` id.

| Method | Returns | What it does in FrontMCP 1.9.4 |
| --- | --- | --- |
| `isApproved(toolId, context?)` | `Promise<boolean>` | Whether the gate would let the caller run the tool: an approval its policy accepts and no denial. With `context`, approvals for that context count too. |
| `getApproval(toolId)` | `Promise<ApprovalRecord \| undefined>` | One of the caller's unexpired records of the tool, a denial first. It doesn't check the tool's policy. |
| `grantSessionApproval(toolId, options?)` | `Promise<ApprovalRecord>` | Approves for the caller's session. Opens the gate. |
| `grantUserApproval(toolId, options?)` | `Promise<ApprovalRecord>` | Approves for the caller, in every session. Opens the gate. Throws `Cannot grant user approval without userId` for a caller without one. |
| `grantTimeLimitedApproval(toolId, ttlMs, options?)` | `Promise<ApprovalRecord>` | Approves for the caller's session and user, for `ttlMs` milliseconds. Opens the gate until it expires. |
| `grantContextApproval(toolId, context, options?)` | `Promise<ApprovalRecord>` | Approves for the caller in one context, `{ type, identifier }`. Opens the gate only for calls whose session carries that context in `authInfo.extra.approvalContext`, which none of FrontMCP's auth modes sets ([Approving in a context](#approving-in-a-context)). |
| `revokeApproval(toolId, options?)` | `Promise<boolean>` | Deletes the caller's session, user, time-limited and context approvals of the tool. Returns whether it deleted any. Recorded denials stay. |
| `clearSessionApprovals()` | `Promise<number>` | Deletes every approval stored under the caller's session, of every tool: session, time-limited and context ones. User approvals stay. Returns how many. |
| `getSessionApprovals()` | `Promise<ApprovalRecord[]>` | The caller's unexpired approvals stored under their session. |
| `getUserApprovals()` | `Promise<ApprovalRecord[]>` | The caller's user-scoped approvals. |
| `getRevocations(toolId)` | `Promise<ApprovalRecord[]>` | The caller's approvals of the tool that were revoked in the last 24 hours, oldest first, each with `revokedAt`, `revokedBy` and `revocationReason`. Empty when the store keeps no revocations. See [Revocations](#revocations). |
| `queryApprovals(query)` | `Promise<ApprovalRecord[]>` | The store's query, limited to the caller's own records: their session's and their user's. `queryApprovals({})` returns all of them. A `sessionId` or `userId` in the query only narrows it further. |

`options` for the grants is `{ grantedBy?, reason?, metadata? }`. `grantedBy` defaults to the signed-in caller, `{ source: "user", identifier: userId, method: "implicit" }`, or to `{ source: "user", method: "implicit" }` for a caller who isn't signed in: an `anon:` subject names no one. `"implicit"` says that nobody was asked: the tool granted on its own. A tool that asked the user first passes `grantedBy: userGrantor(userId)`, which says `"interactive"`, and one that grants on someone's behalf passes theirs. `options` for `revokeApproval` is `{ revokedBy?, reason? }`. `revokedBy` defaults to the caller the same way, `{ source: "user", identifier: userId, method: "implicit" }`, and both are kept with the revocation.

> **Note**
Changed in 1.8.7: the default `grantedBy` records `method: "implicit"`. In 1.8.6 it said `"interactive"`, which an audit trail reads as the user saying yes, though a tool that grants without asking recorded the same thing. Pass `userGrantor(userId)` after a form the user answered ([Asking the user, then approving](#asking-the-user-then-approving)). Changed in 1.8.6: before that, `grantedBy` defaulted to `{ source: "policy" }` in every grant through `this.approval`, even in a tool the user calls to say yes.

The grants check the tool's policy first. A scope the tool's `allowedScopes` leaves out throws `ApprovalScopeNotAllowedError`, and a `ttlMs` that isn't a positive number, or is more than `maxTtlMs`, throws `ApprovalOperationError`. A grant without a `ttlMs` on a tool with `maxTtlMs` gets `maxTtlMs`. A `toolId` that names no tool, like `"delete_ticket"` without its app id, isn't checked, and its approval opens nothing.

`this.approval` is built for each request, from the caller verified for it, so it always acts for the caller.

### Scopes, states and expiry

`ApprovalScope` and `ApprovalState` are string enums exported by the package.

| `ApprovalScope` | Value | In FrontMCP 1.9.4 |
| --- | --- | --- |
| `SESSION` | `"session"` | Stored under the session. Opens the gate. Under 2026-07-28 the session is the user (see above). |
| `USER` | `"user"` | Stored under the user. Opens the gate. |
| `TIME_LIMITED` | `"time_limited"` | Needs a `ttlMs`. Opens the gate until it expires. A time-limited approval for a session alone or a user alone is stored apart from that session's or user's other approval, so it doesn't replace it. |
| `CONTEXT_SPECIFIC` | `"context_specific"` | Stored with a context. Opens the gate only for calls in that context. |
| `TOOL_SPECIFIC` | `"tool_specific"` | A label. Nothing in the plugin grants it. |

The plugin only ever writes `ApprovalState.APPROVED` (`"approved"`). `DENIED` (`"denied"`) is honoured when some other code writes it, and `PENDING` and `EXPIRED` only appear in the error's `details`.

An approval with `ttlMs` gets `expiresAt = grantedAt + ttlMs`. From `expiresAt` on, or from `grantedAt + maxTtlMs` when the tool sets `maxTtlMs`, the gate ignores it and refuses with the same message as before it was granted.

An `ApprovalRecord` has `toolId`, `state`, `scope`, `grantedAt`, `grantedBy`, and when given, `expiresAt`, `ttlMs`, `sessionId`, `userId`, `context`, `reason` and `metadata`. A revocation's copy of the record also has `revokedAt`, `revokedBy` and `revocationReason`. The type lists `approvalChain` too, which nothing sets.

### Grantors and revokers

`grantedBy` records who approved: an `ApprovalGrantor`, `{ source, identifier?, displayName?, method?, origin?, delegationContext? }`, or just a `source` string. `source` is `"user"`, `"policy"`, `"admin"`, `"system"`, `"agent"`, `"api"`, `"oauth"`, `"test"`, or any string of your own. These functions build one:

| Function | Result |
| --- | --- |
| `userGrantor(userId, displayName?, { method?, origin? }?)` | `{ source: "user", identifier: userId, displayName, method: "interactive", origin }` |
| `adminGrantor(adminId, displayName?, { method?, origin? }?)` | The same with `source: "admin"` |
| `policyGrantor(policyId, policyName?)` | `{ source: "policy", identifier, displayName, method: "implicit" }` |
| `systemGrantor(systemId = "system")` | `{ source: "system", identifier, method: "implicit" }` |
| `agentGrantor(agentId, delegationContext, displayName?)` | `{ source: "agent", identifier, displayName, method: "delegation", delegationContext }`. `delegationContext` is `{ delegatorId, delegateId, purpose?, constraints? }`. |
| `apiGrantor(apiKeyPrefix, serviceName?)` | `{ source: "api", identifier, displayName, method: "api", origin: "api" }` |
| `oauthGrantor(tokenId, provider?)` | `{ source: "oauth", identifier, displayName, method: "api", origin: "oauth" }` |
| `testGrantor()` | `{ source: "test", identifier: "test", method: "implicit" }` |
| `customGrantor(source, identifier?, { displayName?, method?, origin?, delegationContext? }?)` | Any `source` |
| `normalizeGrantor(input)` | A string becomes `{ source }`; nothing becomes `{ source: "user" }` |

`isGrantorSource(grantor, source)`, `isHumanGrantor` (user or admin), `isAutoGrantor` (policy, system or test), `isDelegatedGrantor` (agent with a `delegationContext`) and `isApiGrantor` (api or oauth) test one.

The revoker functions, `userRevoker`, `adminRevoker`, `expiryRevoker`, `sessionEndRevoker`, `policyRevoker` and `normalizeRevoker`, build `ApprovalRevoker` objects in the same way. A revoked approval is deleted, so the gate no longer honours it, and a copy with its `revokedBy` is kept for 24 hours: see [Revocations](#revocations).

### Revocations

Revoking an approval deletes it, so the gate stops honouring it, and keeps a copy for 24 hours. The copy is the approval's record with `revokedAt`, `revokedBy` and `revocationReason` added. `this.approval.getRevocations(toolId)` returns the caller's copies of a tool, oldest first, and the store's `getRevocations(toolId, sessionId, userId?, context?)` those of the session and user you name.

- Each approval a `revokeApproval()` deletes leaves one copy: a tool with a session approval and a user approval for the caller leaves two.
- `revokedBy` and `reason` are what the call passed. Without `revokedBy`, `this.approval` records the caller as `{ source: "user", identifier: userId, method: "implicit" }` and the store `{ source: "user" }`.
- Only `revokeApproval()` leaves a copy. `clearSessionApprovals()`, and the cleanup of expired approvals, delete without one, and a `revokeApproval()` that finds nothing to delete returns `false` and records nothing.
- The copies are kept in the same storage as the approvals, under a namespace of their own, and expire 24 hours after the revocation.

[Using `this.approval`](#using-thisapproval) and [Asking the user, then approving](#asking-the-user-then-approving) read them back.

> **Note**
Changed in 1.8.7: a revoked approval was deleted and nothing was kept, so `revokedBy` and `reason` were accepted and dropped.

### Errors

| Class | Code | `statusCode` | Thrown by | Message |
| --- | --- | --- | --- | --- |
| `ApprovalRequiredError` | `APPROVAL_REQUIRED` | 403 | The gate | `approvalMessage`, or `Tool "…" execution denied.` for a denial. `details` is `{ toolId, state, message, approvalOptions }`, and `toJsonRpcError()` returns code `-32600` with them, but FrontMCP never calls it: clients get [the tool error above](#what-the-client-sees). |
| `ChallengeValidationError` | `CHALLENGE_VALIDATION_FAILED` | 400 | `ChallengeService.verifyAndConsume()` | `Invalid or expired challenge` (`reason: "not_found"`), `Challenge expired` (`"expired"`). |
| `ApprovalScopeNotAllowedError` | `APPROVAL_SCOPE_NOT_ALLOWED` | 400 | `this.approval`'s grants | `Approval scope 'user' is not allowed for this tool. Allowed scopes: session`. Has `requestedScope` and `allowedScopes`. |
| `ApprovalOperationError` | `APPROVAL_OPERATION_FAILED` | 400 | Grants, through the store or `this.approval` | `Approval grant failed: ` and the reason: `ttlMs must be a positive number of milliseconds, got 0`, `a time-limited approval needs ttlMs` (the store), or `ttlMs 60000 exceeds the maximum of 300 ms that tool "…" allows` (`this.approval`). |
| `ApprovalExpiredError` | `APPROVAL_EXPIRED` | 403 | Nothing in the plugin | Exported for your own code. |
| `ApprovalError` | `APPROVAL_REQUIRED` | 403 | | The base class of all of them, which pass their own code and status. Each of the others has a `toJsonRpcError()`. |

They all extend `PublicMcpError`, so an error a tool doesn't catch reaches the client as an `isError` result with its message and code, in production too. [Making a tool more or less strict](#making-a-tool-more-or-less-strict) shows a grant that's refused.

A server where `approval` is declared and no `ApprovalPlugin` reaches it fails at startup with `UnenforcedMetadataError` from `@frontmcp/sdk`, code `UNENFORCED_METADATA`: see [Troubleshooting](#unenforced-metadata-tool--declares-approval).

`grantUserApproval()` for a caller without a user id throws a plain `Error`, `Cannot grant user approval without userId`. With FrontMCP's auth modes every caller has one.

### `ChallengeService`

With `mode: "webhook"`, `this.get(ChallengeServiceToken)` returns a `ChallengeService`, a store of one-time PKCE challenges for building an external approval flow of your own. The plugin doesn't use it.

| Method | Description |
| --- | --- |
| `createChallenge({ toolId, sessionId, userId?, requestedScope, requestInfo, ttlSeconds? })` | Makes a code verifier and its SHA-256 challenge, stores the request under the challenge, and returns `{ codeVerifier, codeChallenge, expiresAt }`. `requestInfo` is `{ toolName, category?, riskLevel?, customMessage? }`. |
| `verifyAndConsume(codeVerifier)` | Returns the stored request and deletes it, or throws `ChallengeValidationError`. A verifier works once. |
| `getChallenge(codeChallenge)`, `markWebhookSent(codeChallenge)`, `deleteChallenge(codeChallenge)` | Read, flag or delete a stored challenge. |

`createMemoryChallengeService(options?)` makes one outside the plugin.

### Other exports

`ApprovalStorageStore` and `createApprovalMemoryStore()` are the store's class and an in-memory one; `ApprovalService` and `createApprovalService(store, sessionId, userId?, requirementOf?)` the service's, where `requirementOf(toolId)` returns a tool's `approval` to check grants against. `ApprovalCheckPlugin` is the gate, which `ApprovalPlugin` registers for you: don't list it yourself. It declares `@Plugin({ enforcesMetadata: ["approval"] })`, which is how the server knows the field is enforced. `installApprovalContextExtension()` adds `this.approval` by hand, which the plugin already does. Changed in 1.9: in an ES module project it threw `Dynamic require of "@frontmcp/sdk" is not supported`. `ApprovalPluginClass` is `ApprovalPlugin` again.

---

## Usage

### Requiring approval before a tool runs

`delete_ticket` requires approval; `get_ticket` doesn't. The Playground calls `delete_ticket`, which nobody has approved:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval.",
  inputSchema: { id: z.string() },
  approval: { approvalMessage: "delete_ticket needs the user's approval first.", category: "delete", riskLevel: "high" },
})
class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

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

```ts approval.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";

test("a tool without approval is refused with approvalMessage", async ({ mcp }) => {
  const result = await mcp.tools.call("delete_ticket", { id: "T-1" });
  expect(result).toBeError("APPROVAL_REQUIRED");
  expect(result.text()).toBe("delete_ticket needs the user's approval first.");
});

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

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

test("the arguments are validated first", async ({ mcp }) => {
  expect(await mcp.tools.call("delete_ticket", {})).toBeError("INVALID_INPUT");
});

test("init() without options works", () => {
  expect(() => ApprovalPlugin.init()).not.toThrow();
});

test("the class without init() is the same as `init()`", async () => {
  @Tool({ name: "close_ticket", inputSchema: { id: z.string() }, approval: true })
  class CloseTicket extends ToolContext {
    async execute({ id }: { id: string }) {
      return { id, closed: true };
    }
  }

  @Tool({ name: "approve_close", inputSchema: {} })
  class ApproveClose extends ToolContext {
    async execute() {
      await this.approval.grantSessionApproval("desk:close_ticket");
      return { approved: true };
    }
  }

  @App({ id: "desk", name: "Desk", tools: [CloseTicket, ApproveClose], plugins: [ApprovalPlugin] })
  class WithoutInit {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [WithoutInit] });
  await expect(server.callTool("close_ticket", { id: "T-1" })).rejects.toThrow('Tool "desk:close_ticket" requires approval');
  await server.callTool("approve_close", {});
  expect((await server.callTool("close_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", closed: true });
  await server.dispose();
});
```

The refusal is in the result, so the model reads it, in production too ([What the client sees](#what-the-client-sees)). Nothing in the Playground can approve the tool yet: that's the next example.

### Asking the user, then approving

`approve_tool` asks the user with a form, and on a yes stores an approval for them with the store. The model can call `approve_tool`, but only the user can answer the form. `revoke_tool` withdraws the approval.

The Playground calls without a token, as a new anonymous user on every request, so an approval you give there doesn't carry over to your next call. The tests sign in as two users, with tokens signed ahead of time, and send them through `callAs()`, which answers the form the way the user would. They use one server for every call, so the approvals stay in memory between calls.

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

const approvable = z.enum(["delete_ticket"]);

@Tool({
  name: "approve_tool",
  description: "Ask the user to approve a tool that needs approval, like delete_ticket.",
  inputSchema: { tool: approvable },
})
export class ApproveTool extends ToolContext {
  async execute({ tool }: { tool: z.infer<typeof approvable> }) {
    // The user answers this form in the client. The model can't.
    const answer = await this.elicit(`Allow the assistant to use ${tool} for your account?`, z.object({ allow: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.allow) return { approved: false };

    await this.get(ApprovalStoreToken).grantApproval({
      toolId: `help-desk:${tool}`, // "<app id>:<tool name>"
      scope: ApprovalScope.USER,
      userId: this.auth.user.sub,
      grantedBy: userGrantor(this.auth.user.sub, this.auth.user.name),
      reason: "Confirmed in the client",
    });
    return { approved: true };
  }
}

@Tool({ name: "revoke_tool", description: "Withdraw the user's approval of a tool", inputSchema: { tool: approvable } })
export class RevokeTool extends ToolContext {
  async execute({ tool }: { tool: z.infer<typeof approvable> }) {
    const toolId = `help-desk:${tool}`;
    const revoked = await this.get(ApprovalStoreToken).revokeApproval({
      toolId,
      userId: this.auth.user.sub,
      revokedBy: userRevoker(this.auth.user.sub, this.auth.user.name),
      reason: "Withdrawn in the client",
    });
    // The approval is deleted, and a copy is kept for 24 hours
    const kept = (await this.approval.getRevocations(toolId)).map((r) => ({ scope: r.scope, revokedBy: r.revokedBy, reason: r.revocationReason }));
    return { revoked, kept };
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApproveTool, RevokeTool } from "./approval-tools";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs the user's approval: call approve_tool first.",
  inputSchema: { id: z.string() },
  approval: true,
})
class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  elicitation: { enabled: true },
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

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

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

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

/** Sends one tools/call as a client with this token would. If the tool asks the user something, `answer` is the reply. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, answer?: Answer) {
  server ??= FrontMcpInstance.createFetchHandler(config); // one server for every call
  const handler = await server;
  let reply: Record<string, unknown> = {};
  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": name,
          ...(token ? { authorization: `Bearer ${token}` } : {}),
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: {
            name,
            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 the answer back with the request's state
    const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((key) => [key, answer ?? { action: "decline" }]));
    reply = { inputResponses, requestState: result.requestState };
  }
}
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: scope "tickets:read tickets:write"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: scope "tickets:read"
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

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

const yes = { action: "accept" as const, content: { allow: true } };

test("delete_ticket runs once the user approves it", async () => {
  expect(await callAs(NOUR, "delete_ticket", { id: "T-1" })).toMatchObject({ isError: true });
  expect((await callAs(NOUR, "approve_tool", { tool: "delete_ticket" }, yes)).structuredContent).toEqual({ approved: true });
  expect((await callAs(NOUR, "delete_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", deleted: true });
});

test("an approval belongs to the user who gave it", async () => {
  await callAs(NOUR, "approve_tool", { tool: "delete_ticket" }, yes);
  expect(await callAs(SAM, "delete_ticket", { id: "T-1" })).toMatchObject({ isError: true });
});

test("saying no, or closing the form, approves nothing", async () => {
  await callAs(SAM, "approve_tool", { tool: "delete_ticket" }, { action: "accept", content: { allow: false } });
  await callAs(SAM, "approve_tool", { tool: "delete_ticket" }, { action: "decline" });
  expect(await callAs(SAM, "delete_ticket", { id: "T-1" })).toMatchObject({ isError: true });
});

test("revoking refuses the tool again", async () => {
  await callAs(NOUR, "approve_tool", { tool: "delete_ticket" }, yes);
  expect((await callAs(NOUR, "revoke_tool", { tool: "delete_ticket" })).structuredContent).toMatchObject({ revoked: true });
  expect(await callAs(NOUR, "delete_ticket", { id: "T-1" })).toMatchObject({ isError: true });
});

test("a revocation keeps who withdrew the approval, and why", async () => {
  await callAs(NOUR, "approve_tool", { tool: "delete_ticket" }, yes);
  const { kept } = (await callAs(NOUR, "revoke_tool", { tool: "delete_ticket" })).structuredContent;
  expect(kept.at(-1)).toEqual({
    scope: "user",
    revokedBy: { source: "user", identifier: "nour", displayName: "Nour Haddad", method: "interactive" },
    reason: "Withdrawn in the client",
  });
});

test("a caller without a token can't keep an approval", async () => {
  expect((await callAs(undefined, "approve_tool", { tool: "delete_ticket" }, yes)).structuredContent).toEqual({ approved: true });
  expect(await callAs(undefined, "delete_ticket", { id: "T-1" })).toMatchObject({ isError: true });
});
```

- **The tool id** is the app's id and the tool's name: `help-desk:delete_ticket`. The input is an enum, so only the tools you list can be approved this way.
- **`userId: this.auth.user.sub`** is the id the gate uses for the caller. Granting through the store, rather than `this.approval`, names the user outright.
- **`scope: ApprovalScope.USER`** lasts until it's revoked. For a time limit, add `ttlMs`, as in the next example.
- **The description** of `delete_ticket` names `approve_tool`, so the model knows what to call. Its refusal, from `approval: true`, is the default `Tool "help-desk:delete_ticket" requires approval to execute. Allow?`, which doesn't say. An `approvalMessage` can.

### Approving for a limited time

Here a lead approves a teammate, for a number of minutes. `approve_for_user` checks that the caller may approve (`tickets:write`), and records them as the grantor with `adminGrantor`. `approval_status` shows the record.

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

const DELETE_TICKET = "help-desk:delete_ticket";

@Tool({
  name: "approve_for_user",
  description: "Let a teammate use delete_ticket for some minutes. Team leads only.",
  inputSchema: { user: z.string(), minutes: z.number().positive().max(480) },
})
export class ApproveForUser extends ToolContext {
  async execute({ user, minutes }: { user: string; minutes: number }) {
    if (!this.auth.hasScope("tickets:write")) {
      this.fail(new PublicMcpError("Only team leads can approve delete_ticket.", "FORBIDDEN"));
    }
    const record = await this.get(ApprovalStoreToken).grantApproval({
      toolId: DELETE_TICKET,
      scope: ApprovalScope.TIME_LIMITED,
      userId: user, // the teammate's approval, granted by the lead
      ttlMs: minutes * 60_000,
      grantedBy: adminGrantor(this.auth.user.sub, this.auth.user.name),
    });
    return { user, expiresAt: new Date(record.expiresAt!).toISOString() };
  }
}

@Tool({ name: "approval_status", description: "Show who may use delete_ticket, and until when", inputSchema: {} })
export class ApprovalStatus extends ToolContext {
  async execute() {
    const records = await this.get(ApprovalStoreToken).queryApprovals({ toolId: DELETE_TICKET });
    return { approvals: records.map((r) => ({ user: r.userId, scope: r.scope, grantedBy: r.grantedBy.identifier, expiresAt: r.expiresAt })) };
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApprovalStatus, ApproveForUser } from "./approval-tools";

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Needs a team lead's approval.",
  inputSchema: { id: z.string() },
  approval: { approvalMessage: "delete_ticket needs a team lead's approval." },
})
class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

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

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

let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

/** Sends one tools/call as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}) {
  server ??= FrontMcpInstance.createFetchHandler(config); // one server for every call
  const response = await (await server)(
    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": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result;
}
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: scope "tickets:read tickets:write"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: scope "tickets:read"
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts time-limited.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { NOUR, SAM } from "./tokens";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("a teammate can't approve themselves", async () => {
  expect(await callAs(SAM, "approve_for_user", { user: "sam", minutes: 30 })).toMatchObject({ isError: true, _meta: { code: "FORBIDDEN" } });
  expect(await callAs(SAM, "delete_ticket", { id: "T-1" })).toMatchObject({ isError: true });
});

test("a lead's approval lets the teammate run the tool, and records the lead", async () => {
  await callAs(NOUR, "approve_for_user", { user: "sam", minutes: 30 });
  expect((await callAs(SAM, "delete_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", deleted: true });
  expect((await callAs(NOUR, "approval_status")).structuredContent.approvals).toEqual([
    { user: "sam", scope: "time_limited", grantedBy: "nour", expiresAt: expect.any(Number) },
  ]);
});

test("the approval expires; a new grant replaces the old one", async () => {
  await callAs(NOUR, "approve_for_user", { user: "sam", minutes: 0.005 }); // 300 ms
  expect((await callAs(SAM, "delete_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", deleted: true });
  await sleep(400);
  expect(await callAs(SAM, "delete_ticket", { id: "T-1" })).toMatchObject({ isError: true });
});
```

The Playground calls without a token, so `approve_for_user` refuses it. Once an approval expires, the refusal is the same as before it was granted. Grant it again to extend it: a new grant for the same user replaces the record.

### Using `this.approval`

`this.approval` grants, checks and withdraws for the caller, without naming them. The tests sign in as two users, nour and sam, and each one's approvals stay their own.

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

// In a real server, grant only after the user confirms, as in "Asking the user"
const approvable = z.enum(["delete_ticket"]);
type Approvable = z.infer<typeof approvable>;
const toolId = (tool: Approvable) => `help-desk:${tool}`;
const input = { tool: approvable };

@Tool({ name: "approve_for_session", description: "Approve a tool for this session", inputSchema: input })
export class ApproveForSession extends ToolContext {
  async execute({ tool }: { tool: Approvable }) {
    const record = await this.approval.grantSessionApproval(toolId(tool), { reason: "Confirmed in the client" });
    return { scope: record.scope, grantedBy: record.grantedBy };
  }
}

@Tool({ name: "approve_for_account", description: "Approve a tool for the user, in every session", inputSchema: input })
export class ApproveForAccount extends ToolContext {
  async execute({ tool }: { tool: Approvable }) {
    const record = await this.approval.grantUserApproval(toolId(tool));
    return { scope: record.scope };
  }
}

@Tool({ name: "approve_for_minutes", description: "Approve a tool for some minutes", inputSchema: { ...input, minutes: z.number() } })
export class ApproveForMinutes extends ToolContext {
  async execute({ tool, minutes }: { tool: Approvable; minutes: number }) {
    const record = await this.approval.grantTimeLimitedApproval(toolId(tool), minutes * 60_000);
    return { scope: record.scope, approved: await this.approval.isApproved(toolId(tool)) };
  }
}

@Tool({ name: "revoke", description: "Withdraw an approval", inputSchema: { ...input, reason: z.string().optional() } })
export class Revoke extends ToolContext {
  async execute({ tool, reason }: { tool: Approvable; reason?: string }) {
    return { revoked: await this.approval.revokeApproval(toolId(tool), { reason }) };
  }
}

@Tool({ name: "my_revocations", description: "The caller's approvals withdrawn in the last 24 hours", inputSchema: input })
export class MyRevocations extends ToolContext {
  async execute({ tool }: { tool: Approvable }) {
    const records = await this.approval.getRevocations(toolId(tool));
    return { revocations: records.map((r) => ({ scope: r.scope, revokedBy: r.revokedBy, reason: r.revocationReason })) };
  }
}

@Tool({ name: "end_session", description: "Withdraw every approval given for this session", inputSchema: {} })
export class EndSession extends ToolContext {
  async execute() {
    return { cleared: await this.approval.clearSessionApprovals() };
  }
}

@Tool({ name: "approval_status", description: "Whether a tool is approved", inputSchema: input })
export class ApprovalStatus extends ToolContext {
  async execute({ tool }: { tool: Approvable }) {
    const record = await this.approval.getApproval(toolId(tool));
    return { approved: await this.approval.isApproved(toolId(tool)), scope: record?.scope ?? null };
  }
}

@Tool({ name: "my_approvals", description: "List the caller's approvals", inputSchema: {} })
export class MyApprovals extends ToolContext {
  async execute() {
    const records = await this.approval.queryApprovals({});
    return { approvals: records.map((r) => ({ tool: r.toolId, scope: r.scope })) };
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { ApprovalStatus, ApproveForAccount, ApproveForMinutes, ApproveForSession, EndSession, MyApprovals, MyRevocations, Revoke } from "./approval-tools";

@Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs approval.", inputSchema: { id: z.string() }, approval: true })
class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, ApproveForSession, ApproveForAccount, ApproveForMinutes, Revoke, EndSession, ApprovalStatus, MyApprovals, MyRevocations],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
class HelpDesk {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

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

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

let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

/** Sends one tools/call as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}) {
  server ??= FrontMcpInstance.createFetchHandler(config); // one server for every call
  const response = await (await server)(
    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": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result;
}

// nour and sam: signed ahead of time with the key in main.ts
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

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

const deleteTicket = async (token = NOUR) => ((await callAs(token, "delete_ticket", { id: "T-1" })).isError ? "refused" : "ran");
const tool = { tool: "delete_ticket" };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("grantSessionApproval() opens the gate; grantedBy defaults to the caller, and says nobody was asked", async () => {
  expect((await callAs(NOUR, "approve_for_session", tool)).structuredContent).toEqual({
    scope: "session",
    grantedBy: { source: "user", identifier: "nour", method: "implicit" },
  });
  expect(await deleteTicket()).toBe("ran");
  expect((await callAs(NOUR, "approval_status", tool)).structuredContent).toEqual({ approved: true, scope: "session" });
  await callAs(NOUR, "end_session");
});

test("revokeApproval() withdraws it", async () => {
  await callAs(NOUR, "approve_for_session", tool);
  expect((await callAs(NOUR, "revoke", tool)).structuredContent).toEqual({ revoked: true });
  expect(await deleteTicket()).toBe("refused");
  expect((await callAs(NOUR, "revoke", tool)).structuredContent).toEqual({ revoked: false });
});

test("clearSessionApprovals() withdraws it too", async () => {
  await callAs(NOUR, "approve_for_session", tool);
  expect((await callAs(NOUR, "end_session")).structuredContent).toEqual({ cleared: 1 });
  expect(await deleteTicket()).toBe("refused");
});

test("grantTimeLimitedApproval() opens the gate until it expires", async () => {
  expect((await callAs(NOUR, "approve_for_minutes", { ...tool, minutes: 0.005 })).structuredContent).toEqual({ scope: "time_limited", approved: true }); // 300 ms
  expect(await deleteTicket()).toBe("ran");
  await sleep(400);
  expect(await deleteTicket()).toBe("refused");
  await callAs(NOUR, "revoke", tool); // the expired record is still stored until the cleanup runs
});

test("a user approval survives clearSessionApprovals(); revokeApproval() removes it", async () => {
  expect((await callAs(NOUR, "approve_for_account", tool)).structuredContent).toEqual({ scope: "user" });
  expect((await callAs(NOUR, "end_session")).structuredContent).toEqual({ cleared: 0 });
  expect(await deleteTicket()).toBe("ran");
  expect((await callAs(NOUR, "revoke", tool)).structuredContent).toEqual({ revoked: true });
  expect(await deleteTicket()).toBe("refused");
});

test("for a caller who isn't signed in, grantedBy is a user without an id", async () => {
  expect((await callAs(undefined, "approve_for_session", tool)).structuredContent).toEqual({
    scope: "session",
    grantedBy: { source: "user", method: "implicit" },
  });
});

test("revokeApproval() keeps a copy of each approval for 24 hours, with who revoked it and why", async () => {
  await callAs(SAM, "approve_for_session", tool);
  await callAs(SAM, "approve_for_account", tool);
  expect((await callAs(SAM, "revoke", { ...tool, reason: "Shift ended" })).structuredContent).toEqual({ revoked: true });
  expect(await deleteTicket(SAM)).toBe("refused");

  const { revocations } = (await callAs(SAM, "my_revocations", tool)).structuredContent;
  expect(revocations.map((r: { scope: string }) => r.scope).sort()).toEqual(["session", "user"]);
  for (const revocation of revocations) {
    expect(revocation).toMatchObject({ revokedBy: { source: "user", identifier: "sam", method: "implicit" }, reason: "Shift ended" });
  }
  // Revoking again finds nothing to delete, so it records nothing
  expect((await callAs(SAM, "revoke", tool)).structuredContent).toEqual({ revoked: false });
  expect((await callAs(SAM, "my_revocations", tool)).structuredContent.revocations).toHaveLength(2);

  const realNow = Date.now;
  Date.now = () => realNow() + 25 * 60 * 60 * 1000;
  try {
    expect((await callAs(SAM, "my_revocations", tool)).structuredContent).toEqual({ revocations: [] });
  } finally {
    Date.now = realNow;
  }
});

test("clearSessionApprovals() keeps no copy", async () => {
  await callAs(SAM, "approve_for_session", tool);
  expect((await callAs(SAM, "end_session")).structuredContent).toEqual({ cleared: 1 });
  expect((await callAs(SAM, "my_revocations", tool)).structuredContent).toEqual({ revocations: [] });
});

test("a grant that's refused reaches the client with its code", async () => {
  const result = await callAs(NOUR, "approve_for_minutes", { ...tool, minutes: 0 });
  expect(result).toMatchObject({ isError: true, _meta: { code: "APPROVAL_OPERATION_FAILED" } });
  expect(result.content[0].text).toBe("Approval grant failed: ttlMs must be a positive number of milliseconds, got 0");
});

test("each caller grants, sees and revokes only their own approvals", async () => {
  await callAs(NOUR, "approve_for_account", tool);
  expect((await callAs(NOUR, "my_approvals")).structuredContent).toEqual({ approvals: [{ tool: "help-desk:delete_ticket", scope: "user" }] });
  expect((await callAs(SAM, "my_approvals")).structuredContent).toEqual({ approvals: [] });
  expect(await deleteTicket(SAM)).toBe("refused");
  expect((await callAs(SAM, "revoke", tool)).structuredContent).toEqual({ revoked: false });
  expect(await deleteTicket(NOUR)).toBe("ran");
  await callAs(NOUR, "revoke", tool);
});
```

A user approval, from `grantUserApproval()`, survives `clearSessionApprovals()`, which only removes approvals stored under the session. `revokeApproval()` removes both kinds. With the store, name whose approval to remove, as `revoke_tool` does in [Asking the user](#asking-the-user-then-approving).

The grants record the caller as `grantedBy`, `{ source: "user", identifier: "nour", method: "implicit" }` for nour, and `{ source: "user", method: "implicit" }` for the caller without a token: `"implicit"` because the tool granted without asking anyone. `revokeApproval()` keeps what it deletes for 24 hours, and `getRevocations()` reads it ([Revocations](#revocations)): the approvals sam had, with who revoked them and why. A grant the tool's policy refuses throws, and since the errors are public, the client reads the reason and the code, as the `ttlMs` of 0 shows.

### Making a tool more or less strict

`skipApproval` and `required: false` turn the gate off, while keeping `category` and `riskLevel` in the code. `alwaysPrompt` makes each approval good for one call. `allowedScopes` limits which kinds of approval count, and `maxTtlMs` how long any approval of the tool lasts. Both apply to approvals written to the store too, which the store accepts without checking:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin, ApprovalScope, ApprovalStoreToken } from "@frontmcp/plugin-approval";

@Tool({ name: "export_tickets", description: "Export every ticket as CSV", inputSchema: {}, approval: { skipApproval: true, category: "read" } })
class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}

@Tool({ name: "tag_ticket", description: "Add a tag to a ticket", inputSchema: {}, approval: { required: false, category: "write" } })
class TagTicket extends ToolContext {
  async execute() {
    return { tagged: true };
  }
}

@Tool({ name: "purge_tickets", description: "Delete every closed ticket", inputSchema: {}, approval: { alwaysPrompt: true, riskLevel: "critical" } })
class PurgeTickets extends ToolContext {
  async execute() {
    return { purged: 12 };
  }
}

@Tool({ name: "reopen_ticket", description: "Reopen a closed ticket", inputSchema: {}, approval: { allowedScopes: [ApprovalScope.SESSION] } })
class ReopenTicket extends ToolContext {
  async execute() {
    return { reopened: true };
  }
}

// 300 ms, so the tests can wait for it. Minutes or hours in a real server.
@Tool({ name: "assign_ticket", description: "Assign a ticket to someone", inputSchema: {}, approval: { maxTtlMs: 300 } })
class AssignTicket extends ToolContext {
  async execute() {
    return { assigned: true };
  }
}

const gated = z.enum(["purge_tickets", "reopen_ticket", "assign_ticket"]);

// Stand in for a proper approval flow, to test with
@Tool({ name: "approve", description: "Approve a tool for the caller", inputSchema: { tool: gated, scope: z.enum(["session", "user"]) } })
class Approve extends ToolContext {
  async execute({ tool, scope }: { tool: z.infer<typeof gated>; scope: "session" | "user" }) {
    const id = `help-desk:${tool}`;
    const record = scope === "session" ? await this.approval.grantSessionApproval(id) : await this.approval.grantUserApproval(id);
    return { approved: true, ttlMs: record.ttlMs ?? null };
  }
}

@Tool({ name: "store_approval", description: "Write a user approval to the store", inputSchema: { tool: gated } })
class StoreApproval extends ToolContext {
  async execute({ tool }: { tool: z.infer<typeof gated> }) {
    await this.get(ApprovalStoreToken).grantApproval({ toolId: `help-desk:${tool}`, scope: ApprovalScope.USER, userId: this.auth.user.sub });
    return { stored: true };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [ExportTickets, TagTicket, PurgeTickets, ReopenTicket, AssignTicket, Approve, StoreApproval],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
class HelpDesk {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

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

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

let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

/** Sends one tools/call as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}) {
  server ??= FrontMcpInstance.createFetchHandler(config); // one server for every call
  const response = await (await server)(
    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": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result;
}

// nour: signed ahead of time with the key in main.ts
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
```

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

const run = async (name: string) => ((await callAs(NOUR, name)).isError ? "refused" : "ran");
const approve = async (tool: string, scope: "session" | "user") => (await callAs(NOUR, "approve", { tool, scope })).structuredContent;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("skipApproval and required: false run without an approval", async () => {
  expect([await run("export_tickets"), await run("tag_ticket")]).toEqual(["ran", "ran"]);
});

test("before any approval, the others are refused", async () => {
  expect([await run("purge_tickets"), await run("reopen_ticket"), await run("assign_ticket")]).toEqual(["refused", "refused", "refused"]);
});

test("alwaysPrompt: each approval lets one call run", async () => {
  expect(await approve("purge_tickets", "user")).toEqual({ approved: true, ttlMs: null });
  expect(await run("purge_tickets")).toBe("ran");
  expect(await run("purge_tickets")).toBe("refused");
});

test("allowedScopes: only a session approval opens reopen_ticket", async () => {
  const refused = await callAs(NOUR, "approve", { tool: "reopen_ticket", scope: "user" });
  expect(refused).toMatchObject({ isError: true, _meta: { code: "APPROVAL_SCOPE_NOT_ALLOWED" } });
  expect(refused.content[0].text).toBe("Approval scope 'user' is not allowed for this tool. Allowed scopes: session");
  await callAs(NOUR, "store_approval", { tool: "reopen_ticket" }); // a user approval, straight to the store
  expect(await run("reopen_ticket")).toBe("refused");
  expect(await approve("reopen_ticket", "session")).toEqual({ approved: true, ttlMs: null });
  expect(await run("reopen_ticket")).toBe("ran");
});

test("maxTtlMs: every approval of assign_ticket lasts 300 ms at most", async () => {
  expect(await approve("assign_ticket", "user")).toEqual({ approved: true, ttlMs: 300 });
  expect(await run("assign_ticket")).toBe("ran");
  await sleep(400);
  expect(await run("assign_ticket")).toBe("refused");
  await callAs(NOUR, "store_approval", { tool: "assign_ticket" }); // no expiry of its own
  expect(await run("assign_ticket")).toBe("ran");
  await sleep(400);
  expect(await run("assign_ticket")).toBe("refused");
});
```

A grant through `this.approval` that the tool doesn't allow throws, and the model reads why: the error reaches the client with its message and the code `APPROVAL_SCOPE_NOT_ALLOWED`. The store takes the same approval without complaint, and the gate ignores it.

### Approving in a context

`preApprovedContexts` and `grantContextApproval()` work with the context a call's session belongs to, like a workspace: `{ type: "workspace", identifier: "acme" }`, read from `authInfo.extra.approvalContext`. None of FrontMCP's auth modes sets that value, and the call's arguments can't. Your own code can, for example when it calls the server in process with [`createDirect()`](https://frontmcp.dev/reference/sdk/create#frontmcpinstancecreatedirectconfig) and passes `authContext.extra`. The Playground calls without a context, so `merge_tickets` is refused:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";

const ACME = { type: "workspace", identifier: "acme" };

@Tool({ name: "merge_tickets", description: "Merge duplicate tickets", inputSchema: {}, approval: { preApprovedContexts: [ACME] } })
class MergeTickets extends ToolContext {
  async execute() {
    return { merged: 2 };
  }
}

@Tool({ name: "delete_ticket", description: "Delete a support ticket for good", inputSchema: {}, approval: true })
class DeleteTicket extends ToolContext {
  async execute() {
    return { deleted: true };
  }
}

@Tool({
  name: "purge_merged",
  description: "Delete tickets merged into others",
  inputSchema: {},
  approval: { alwaysPrompt: true, preApprovedContexts: [ACME] },
})
class PurgeMerged extends ToolContext {
  async execute() {
    return { purged: 3 };
  }
}

// Stands in for a proper approval flow, to test with
@Tool({
  name: "approve_in_workspace",
  description: "Approve a tool in the acme workspace",
  inputSchema: { tool: z.enum(["delete_ticket", "purge_merged"]) },
})
class ApproveInWorkspace extends ToolContext {
  async execute({ tool }: { tool: "delete_ticket" | "purge_merged" }) {
    const record = await this.approval.grantContextApproval(`help-desk:${tool}`, ACME);
    return { scope: record.scope, context: record.context, session: record.sessionId };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [MergeTickets, DeleteTicket, PurgeMerged, ApproveInWorkspace],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
class HelpDesk {}

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

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

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

// nour's calls from the acme workspace, and from no workspace
const inAcme = { authContext: { sessionId: "desk-1", user: { sub: "nour" }, extra: { approvalContext: { type: "workspace", identifier: "acme" } } } };
const outside = { authContext: { sessionId: "desk-1", user: { sub: "nour" } } };

const server = FrontMcpInstance.createDirect(config);
const run = async (name: string, options: typeof outside) =>
  (await server).callTool(name, {}, options).then(
    (result) => (result.isError ? "refused" : "ran"),
    (error) => (error.name === "ApprovalRequiredError" ? "refused" : Promise.reject(error)),
  );

test("preApprovedContexts: runs in the acme workspace without an approval", async () => {
  expect(await run("merge_tickets", inAcme)).toBe("ran");
  expect(await run("merge_tickets", outside)).toBe("refused");
});

test("a context approval opens the gate only in that context", async () => {
  expect(await run("delete_ticket", inAcme)).toBe("refused");
  expect(await (await server).callTool("approve_in_workspace", { tool: "delete_ticket" }, inAcme)).toMatchObject({
    structuredContent: { scope: "context_specific", context: { type: "workspace", identifier: "acme" }, session: "desk-1" },
  });
  expect(await run("delete_ticket", inAcme)).toBe("ran");
  expect(await run("delete_ticket", outside)).toBe("refused");
});

test("with alwaysPrompt, preApprovedContexts doesn't count: each call uses up an approval", async () => {
  expect(await run("purge_merged", inAcme)).toBe("refused");
  await (await server).callTool("approve_in_workspace", { tool: "purge_merged" }, inAcme);
  expect([await run("purge_merged", inAcme), await run("purge_merged", inAcme)]).toEqual(["ran", "refused"]);
});
```

`createDirect()` throws a refusal, an `ApprovalRequiredError`, instead of returning it as a tool error. The `sessionId` given to `createDirect()` counts as verified, so it keys the session's approvals, as a client's session would.

### Gating every app's tools

Registered on `@FrontMcp`, the plugin checks every app's tools. Registered in one app's `plugins`, it checks that app's tools and those of every app that has no approval plugin of its own, with its own store, but only its own app's tools have `this.approval`: grant from there, with the other app's tool id. Registered nowhere, it checks nothing, so the server refuses to start.

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { BillingApp, HelpDeskApp } from "./apps";

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [HelpDeskApp, BillingApp],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
})
export default class Server {}
```

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

@Tool({ name: "delete_ticket", description: "Delete a support ticket for good", inputSchema: { id: z.string() }, approval: true })
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() }, approval: true })
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, refunded: true };
  }
}

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

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

```ts every-app.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { BillingApp, DeleteTicket, HelpDeskApp } from "./apps";

const info = { name: "support", version: "1.0.0" };
const nour = { authContext: { user: { sub: "nour" } } };

// Stands in for a proper approval flow, to test with
@Tool({ name: "approve", description: "Approve a tool for the caller", inputSchema: { tool: z.string() } })
class Approve extends ToolContext {
  async execute({ tool }: { tool: string }) {
    await this.approval.grantUserApproval(tool);
    return { approved: true };
  }
}

test("on @FrontMcp, every app's tools need approval; ids start with the app's id", async ({ mcp }) => {
  expect((await mcp.tools.call("delete_ticket", { id: "T-1" })).text()).toMatch(/^Tool "help-desk:delete_ticket" requires approval/);
  expect((await mcp.tools.call("refund_invoice", { id: "INV-7" })).text()).toMatch(/^Tool "billing:refund_invoice" requires approval/);
});

test("on one app, it also gates the apps without a plugin, with its own store", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, Approve], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })] })
  class HelpDeskWithPlugin {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [HelpDeskWithPlugin, BillingApp] });
  await expect(server.callTool("refund_invoice", { id: "INV-7" }, nour)).rejects.toThrow('Tool "billing:refund_invoice" requires approval');
  await server.callTool("approve", { tool: "billing:refund_invoice" }, nour);
  expect((await server.callTool("refund_invoice", { id: "INV-7" }, nour)).structuredContent).toEqual({ id: "INV-7", refunded: true });
});

test("on @FrontMcp and on an app, an approval in either store lets that app's tools run", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, Approve], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })] })
  class HelpDeskWithPlugin {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [HelpDeskWithPlugin], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })] });
  await expect(server.callTool("delete_ticket", { id: "T-1" }, nour)).rejects.toThrow('Tool "help-desk:delete_ticket" requires approval');
  await server.callTool("approve", { tool: "help-desk:delete_ticket" }, nour); // reaches one of the two stores
  expect((await server.callTool("delete_ticket", { id: "T-1" }, nour)).structuredContent).toEqual({ id: "T-1", deleted: true });
});

test("on one app, the other apps' tools have no `this.approval`", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })] })
  class HelpDeskWithPlugin {}

  @App({ id: "billing", name: "Billing", tools: [Approve] })
  class BillingWithApprove {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [HelpDeskWithPlugin, BillingWithApprove] });
  await expect(server.callTool("approve", { tool: "help-desk:delete_ticket" }, nour)).rejects.toThrow(
    "ApprovalPlugin is not installed. Add ApprovalPlugin.init() to your plugins array.",
  );
});

test("without the plugin, the server doesn't start", async () => {
  const problem = `Unenforced metadata: Tool "delete_ticket" declares 'approval' (enforced by ApprovalPlugin from @frontmcp/plugin-approval); Tool "refund_invoice" declares 'approval'`;
  await expect(FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp, BillingApp] })).rejects.toThrow(problem);

  await expect(FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, BillingApp] })).rejects.toThrow(problem);
});
```

With the plugin on `@FrontMcp` and on `help-desk`, both gates cover `delete_ticket`, and an approval in the app's store lets it run ([Register the plugin once](#how-a-call-is-checked)). Before 1.9 it needed one in each store. Without the plugin, `createDirect()` and `createFetchHandler()` both fail when they're called, because they start the server before they return. Changed in 1.8.4: before, `createFetchHandler()` returned a handler, and the error came from its first request.

### Gating an agent

`approval` on an [`@Agent`](https://frontmcp.dev/reference/sdk/agent) gates `invoke_<agent>`, like any tool, with the id `<app id>:invoke_<agent>`. A tool declared inside the agent is checked only by a plugin in the agent's own `plugins`, with a store of its own, and its id is `agent:<agent>:<tool>`. There a refusal doesn't fail the call: the agent's model reads it, like any failed tool.

```ts refunds.agent.ts active
import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { model } from "./model.example";

@Tool({ name: "void_invoice", description: "Void an invoice", inputSchema: { id: z.string() }, approval: true })
export class VoidInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, voided: true };
  }
}

@Agent({
  name: "refunds",
  description: "Refund or void an invoice. Needs approval.",
  inputSchema: { query: z.string() },
  llm: { adapter: model },
  approval: true, // gates invoke_refunds
  tools: [VoidInvoice],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], // gates void_invoice, inside the agent
})
export class Refunds extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { Refunds } from "./refunds.agent";

// Stands in for a proper approval flow, to test with
@Tool({ name: "approve_refunds", description: "Approve the refunds agent for the caller", inputSchema: {} })
class ApproveRefunds extends ToolContext {
  async execute() {
    await this.approval.grantUserApproval("billing:invoke_refunds");
    return { approved: true };
  }
}

@App({ id: "billing", name: "Billing", agents: [Refunds], tools: [ApproveRefunds] })
class BillingApp {}

export const config = {
  info: { name: "billing", version: "1.0.0" },
  apps: [BillingApp],
  plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })],
};

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach: it calls
// void_invoice once, then answers with what the tool said.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages[prompt.messages.length - 1];
    if (last.role === "user") {
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "void_invoice", arguments: { id: "INV-7" } }] };
    }
    return { content: `void_invoice said: ${last.content}`, finishReason: "stop" };
  },
};
```

```ts agent.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { config } from "./main";
import { model } from "./model.example";
import { VoidInvoice } from "./refunds.agent";

test("invoke_refunds needs approval; inside, void_invoice needs its own", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const nour = { authContext: { user: { sub: "nour" } } };
  await expect(server.callTool("invoke_refunds", { query: "Refund INV-7" }, nour)).rejects.toThrow('Tool "billing:invoke_refunds" requires approval');
  await server.callTool("approve_refunds", {}, nour);
  expect((await server.callTool("invoke_refunds", { query: "Refund INV-7" }, nour)).structuredContent).toEqual({
    response: 'void_invoice said: {"error":"Tool \\"agent:refunds:void_invoice\\" requires approval to execute. Allow?"}',
  });
});

test("a plugin outside the agent doesn't reach the agent's tools", async () => {
  @Agent({ name: "voider", description: "Void an invoice", inputSchema: { query: z.string() }, llm: { adapter: model }, tools: [VoidInvoice] })
  class Voider extends AgentContext {}

  @App({ id: "billing", name: "Billing", agents: [Voider], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })] })
  class Billing {}

  await expect(FrontMcpInstance.createDirect({ info: { name: "billing", version: "1.0.0" }, apps: [Billing] })).rejects.toThrow(
    `Tool "voider:void_invoice" declares 'approval'`,
  );
});
```

### Webhook mode

`mode: "webhook"` doesn't contact `webhook.url` or serve `callbackPath`. It only adds the `ChallengeService`, so you can build an external approval flow on its one-time challenges. Here `fetch` is replaced to record any request to the approval system:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin, ChallengeServiceToken } from "@frontmcp/plugin-approval";

// Records every request to the approval system, and answers it
export const requests: string[] = [];
const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "approvals.example.com") return realFetch(input, init);
  requests.push(url.href);
  return Response.json({ received: true });
};

@Tool({ name: "delete_ticket", description: "Delete a support ticket for good", inputSchema: { id: z.string() }, approval: true })
class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

@Tool({ name: "start_external_approval", description: "Create a challenge for an external approval", inputSchema: { tool: z.string() } })
class StartExternalApproval extends ToolContext {
  async execute({ tool }: { tool: string }) {
    const challenges = this.get(ChallengeServiceToken);
    const { codeVerifier, codeChallenge } = await challenges.createChallenge({
      toolId: `help-desk:${tool}`,
      sessionId: `user:${this.auth.user.sub}`,
      requestedScope: "user",
      requestInfo: { toolName: tool },
    });
    // Your code would send codeChallenge to the approval system, and keep codeVerifier
    const first = await challenges.verifyAndConsume(codeVerifier);
    const second = await challenges.verifyAndConsume(codeVerifier).catch((error) => `${error.name} (${error.code}): ${error.message}`);
    return { codeChallenge, first: first.toolId, second };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [DeleteTicket, StartExternalApproval],
  plugins: [
    ApprovalPlugin.init({
      storage: { type: "memory" },
      mode: "webhook",
      webhook: { url: "https://approvals.example.com/hook", callbackPath: "/approval/callback", challengeTtl: 120 },
    }),
  ],
})
class HelpDesk {}

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

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

```ts webhook.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { ApprovalExpiredError } from "@frontmcp/plugin-approval";
import { config, requests } from "./main";

test("a refused call sends no webhook", async ({ mcp }) => {
  expect(await mcp.tools.call("delete_ticket", { id: "T-1" })).toBeError();
  expect(requests).toEqual([]);
});

test("callbackPath isn't served", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(new Request("https://desk.example.com/approval/callback", { method: "POST", body: "{}" }));
  expect(response.status).toBe(404);
});

test("an error nothing in the plugin throws still has its code", () => {
  expect(new ApprovalExpiredError("help-desk:delete_ticket", 0)).toMatchObject({ code: "APPROVAL_EXPIRED", statusCode: 403 });
});

test("a challenge's verifier works once", async ({ mcp }) => {
  const result = (await mcp.tools.call("start_external_approval", { tool: "delete_ticket" })).json();
  expect(result).toEqual({
    codeChallenge: expect.any(String),
    first: "help-desk:delete_ticket",
    second: "ChallengeValidationError (CHALLENGE_VALIDATION_FAILED): Invalid or expired challenge",
  });
});
```

---

## Troubleshooting

### `TypeError: Cannot read properties of undefined (reading 'providers')`

`ApprovalPlugin.init()` was called without an argument on FrontMCP before 1.8.6, and it threw as soon as the module loaded. Upgrade, or pass an object: `ApprovalPlugin.init({})`, or with `storage`.

### `Provider "[ref]" is not available: not found in local or parent registries`

A call to a tool with `approval` fails with code `PROVIDER_NOT_AVAILABLE`: the plugin was registered as a class, `plugins: [ApprovalPlugin]`, on FrontMCP 1.9.3 or earlier. Update to 1.9.4, where the class is the same as `ApprovalPlugin.init()`, or use `ApprovalPlugin.init({ ... })`.

### `Unenforced metadata: Tool "…" declares 'approval'`

The server didn't start: a tool or agent has `approval`, and no `ApprovalPlugin` reaches it, so nothing would check it. Register the plugin on `@FrontMcp` or on an app ([Gating every app's tools](#gating-every-apps-tools)), or remove `approval`. For a tool declared inside an `@Agent`, the plugin must be in that agent's `plugins` ([Gating an agent](#gating-an-agent)).

### A tool with `approval: true` runs without approval

- `required: false` or `skipApproval: true` is set.
- The call's session carries a context the tool lists in `preApprovedContexts`, and the tool doesn't have `alwaysPrompt`.
- An approval is still in force. A user approval survives `clearSessionApprovals()`, and under 2026-07-28 a session approval lasts for every conversation of that user ([the Pitfall above](#caveats)). `this.approval.getApproval(toolId)` shows one of them.

### The tool stays refused after I approved it

- The approval names the tool by its listed name. Use `<app id>:<tool name>`, like `help-desk:delete_ticket`.
- The caller has no token: every request is a new anonymous user.
- Its scope isn't in the tool's `allowedScopes`, or it's older than the tool's `maxTtlMs`. The store accepts such an approval, and the gate ignores it.
- It's a context approval, and the call's session has no `approvalContext`, or another one.
- It was stored with a `sessionId` that isn't the caller's. Under 2026-07-28 the caller's session is `stateless-user:<user>`: store a user approval instead.
- The tool has `alwaysPrompt: true`, and a call already used the approval up.
- On a FrontMCP older than 1.9.0: the plugin is registered on `@FrontMcp` and on the tool's app, and the tool needs an approval in both stores, or the tool has `alwaysPrompt: true`, which refused every call ([Register the plugin once](#how-a-call-is-checked)).
- The approval expired.

### The tool still runs after I revoked the approval

- The store's `revokeApproval()` removes the approvals of the session or user you name. A user approval isn't stored under the session, and a session approval has no user. `this.approval.revokeApproval(toolId)` removes both for the caller.
- `clearSessionApprovals()` doesn't remove user approvals.
- The tool id is missing its app id: `help-desk:delete_ticket`, not `delete_ticket`.

### `Approval scope 'user' is not allowed for this tool`

A grant through `this.approval` asked for a scope the tool's `allowedScopes` leaves out, and threw `ApprovalScopeNotAllowedError`. Grant one of the scopes the message lists.

### `Approval grant failed: ttlMs … exceeds the maximum`

A grant through `this.approval` asked for longer than the tool's `maxTtlMs`. Ask for `maxTtlMs` or less, or leave `ttlMs` out and get `maxTtlMs`. `ttlMs must be a positive number of milliseconds` means `0`, a negative number, `NaN` or `Infinity`.

### `Internal FrontMCP error. Please contact support with error ID: …`

On FrontMCP before 1.8.6, that's what a refused call looked like to the model in production. From 1.8.6 the model reads `approvalMessage` and the code `APPROVAL_REQUIRED` there too ([What the client sees](#what-the-client-sees)). If you still see it, the call failed with some other error that isn't public.

### `Dynamic require of "@frontmcp/sdk" is not supported`

`installApprovalContextExtension()` was called in an ES module project, on a FrontMCP older than 1.9.0. You don't need it: the plugin adds `this.approval` itself.

### `ApprovalPlugin is not installed. Add ApprovalPlugin.init() to your plugins array.`

A tool read `this.approval` in an app the plugin isn't registered for. The gate covers apps without a plugin of their own, but `this.approval` doesn't reach them: register the plugin on `@FrontMcp`, or grant from a tool in the plugin's app with the other app's tool id ([Gating every app's tools](#gating-every-apps-tools)).
