Approval plugin
@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(). Asking for Approval builds the gate and the asking step by step.
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 or of @FrontMcp. It isn't part of the @frontmcp/plugins umbrella package.
npm install @frontmcp/plugin-approvalimport { 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. The plugin works in CommonJS and ES module projects alike. See more examples below.
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. See 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 and @Agent. On an agent it gates invoke_<agent>, the tool clients call it with (Gating an agent). Resources, prompts and jobs can't require approval.
@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). 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. |
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). 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 shows each field.
How a call is checked
The plugin checks in a Will("execute") hook at priority 100: after the arguments are validated, before execute(). Calls made with this.callTool() are checked too.
- The tool has no
approval, orrequired: falseorskipApproval: true: it runs. - The plugin works out who's calling. The user is
this.context.authInfo.extra.userId, elseextra.sub, elseauthInfo.clientId. With FrontMCP's auth modes that'sthis.auth.user.sub: a token's subject,static:…for a static key, andanon: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 (withmcp-session-id, or an SSE client'ssessionId), andstateless-user:<user>otherwise. - 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>, likehelp-desk:delete_ticket, not the name clients see. - A record whose
stateis"denied"refuses the call withTool "…" 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. - A matching
preApprovedContextsentry lets the call run, unless the tool hasalwaysPrompt. - An approval the tool's policy accepts lets it run: its scope is in
allowedScopes, and it has expired neither by its ownexpiresAtnor bymaxTtlMs. WithalwaysPrompt, the call uses that approval up: it's deleted, without a revocation, and when two calls race for one approval, only one runs. Anything else is refused withapprovalMessage.
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.
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.
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.
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() 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. |
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. 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). |
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. |
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.
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
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. revokedByandreasonare what the call passed. WithoutrevokedBy,this.approvalrecords 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 arevokeApproval()that finds nothing to delete returnsfalseand 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 and Asking the user, then approving read them back.
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. |
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 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.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The refusal is in the result, so the model reads it, in production too (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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
- 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.subis the id the gate uses for the caller. Granting through the store, rather thanthis.approval, names the user outright.scope: ApprovalScope.USERlasts until it's revoked. For a time limit, addttlMs, as in the next example.- The description of
delete_ticketnamesapprove_tool, so the model knows what to call. Its refusal, fromapproval: true, is the defaultTool "help-desk:delete_ticket" requires approval to execute. Allow?, which doesn't say. AnapprovalMessagecan.
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.
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 })) };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 })) };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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): 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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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() and passes authContext.extra. The Playground calls without a context, so merge_tickets is refused:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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). 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 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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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), or remove approval. For a tool declared inside an @Agent, the plugin must be in that agent's plugins (Gating an agent).
A tool with approval: true runs without approval
required: falseorskipApproval: trueis set.- The call's session carries a context the tool lists in
preApprovedContexts, and the tool doesn't havealwaysPrompt. - 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).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>, likehelp-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'smaxTtlMs. 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
sessionIdthat isn't the caller's. Under 2026-07-28 the caller's session isstateless-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
@FrontMcpand on the tool's app, and the tool needs an approval in both stores, or the tool hasalwaysPrompt: true, which refused every call (Register the plugin once). - 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, notdelete_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). 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).