npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@frontmcp/plugin-approval

v1.9.7

Published

Approval plugin for FrontMCP: refuses to run marked tools until an approval is recorded for the caller

Readme

@frontmcp/plugin-approval

A FrontMCP plugin that refuses to run a tool until an approval is recorded for the caller. Use it for tools that shouldn't run without someone's say-so, like deleting a ticket or refunding a payment. Mark a tool with approval, and every call to it is refused until your code grants an approval, through this.approval or the plugin's store. Each approval records who granted it, and can be revoked or set to expire. The plugin doesn't ask anyone itself: there's no prompt, webhook or callback, so you decide how a person says yes, for example with a form from this.elicit().

npm License

Install

npm install @frontmcp/plugin-approval @frontmcp/sdk

Requires Node.js 24 or later. Keep @frontmcp/sdk on the same version as this package. Approvals are kept in memory unless you configure storage. Redis support (ioredis) is included; for other stores, install their client yourself:

  • @vercel/kv, an optional peer dependency, for storage: { type: 'vercel-kv' }.
  • @upstash/redis for storage: { type: 'upstash' }.

This plugin isn't part of the @frontmcp/plugins meta-package.

Usage

Register the plugin on @FrontMcp, to gate every app's tools, or on an @App. Mark the tools that need approval, and give the user a way to grant it:

import { ApprovalPlugin, userGrantor } from '@frontmcp/plugin-approval';
import { App, FrontMcp, Tool, ToolContext, z } from '@frontmcp/sdk';

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

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

    await this.approval.grantSessionApproval('help-desk:delete_ticket', {
      grantedBy: userGrantor(this.auth.user.sub),
    });
    return { approved: true };
  }
}

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

@FrontMcp({
  info: { name: 'help-desk', version: '1.0.0' },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
  plugins: [ApprovalPlugin.init()],
})
export default class Server {}
  • Tool ids are <app id>:<tool name>, like help-desk:delete_ticket. Granting 'delete_ticket' records an approval nothing checks.
  • Approvals belong to the caller, so use the plugin with signed-in users. An anonymous caller can get a new identity on every request, and an approval granted to them then never applies to their next call.
  • Don't let the model approve its own calls. A tool that grants without asking a person makes the gate protect nothing. Grant after a form the user answers, as above, or from a caller with more rights.
  • A field needs its plugin. A server where a tool has approval and no ApprovalPlugin reaches it doesn't start: it fails with UnenforcedMetadataError.
  • ApprovalPlugin.init() with no argument, and plugins: [ApprovalPlugin], use the defaults.

What the client sees

A refused call is a tool error the model can read: isError: true, _meta.code: 'APPROVAL_REQUIRED', and the text of the tool's approvalMessage, in production too (the approval errors extend PublicMcpError). The default message, Tool "<id>" requires approval to execute. Allow?, doesn't tell the model what to do next, so write your own. The tool's category, riskLevel and other fields stay on the server.

The approval field on @Tool

approval: true means { required: true, defaultScope: ApprovalScope.SESSION }. Any object requires approval unless it says required: false.

| Field | What it does | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | required | false turns the gate off. Default true. | | skipApproval | true turns the gate off too. | | alwaysPrompt | Each approval lets one call run and is used up by it. | | approvalMessage | The text of the refusal. | | allowedScopes | The scopes an approval of this tool may have. A grant through this.approval of another scope throws ApprovalScopeNotAllowedError. | | maxTtlMs | The longest an approval of this tool lasts. | | preApprovedContexts | Lets a call run without an approval when authInfo.extra.approvalContext matches. FrontMCP's auth modes don't set it, so your own code has to. | | category, riskLevel, defaultScope | Kept on the error's details, which clients don't see. |

this.approval

The plugin adds this.approval to the tools, resources and prompts of the apps it's registered for. It acts for the current caller, and takes tool ids in the <app id>:<tool name> form.

| Method | What it does | | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | isApproved(toolId, context?) | Whether the gate would let the caller run the tool. | | grantSessionApproval(toolId, options?) | Approves for the caller's session. | | grantUserApproval(toolId, options?) | Approves for the caller in every session. | | grantTimeLimitedApproval(toolId, ttlMs, options?) | Approves for ttlMs milliseconds. | | grantContextApproval(toolId, context, options?) | Approves in one context, { type, identifier } (see preApprovedContexts). | | revokeApproval(toolId, options?) | Deletes the caller's approvals of the tool, keeping a copy for 24 hours. | | getRevocations(toolId) | The caller's approvals of the tool revoked in the last 24 hours. | | getApproval, getSessionApprovals, getUserApprovals, queryApprovals, clearSessionApprovals | Read or clear the caller's records. |

Grant options are { grantedBy?, reason?, metadata? }, and revoke options { revokedBy?, reason? }. Without grantedBy, a grant records the signed-in caller with method: 'implicit', which says nobody was asked: { source: 'user', identifier: <user id>, method: 'implicit' }, or { source: 'user', method: 'implicit' } with no signed-in user. After the user says yes, pass a grantor that says so:

import { policyGrantor, userGrantor } from '@frontmcp/plugin-approval';

// { source: 'user', identifier: 'user-123', displayName: 'Jane Doe', method: 'interactive' }
await this.approval.grantUserApproval('ops:deploy', { grantedBy: userGrantor('user-123', 'Jane Doe') });

// { source: 'policy', identifier: 'safe-list', method: 'implicit' }
await this.approval.grantSessionApproval('ops:deploy', { grantedBy: policyGrantor('safe-list') });

The other grantor helpers are adminGrantor, systemGrantor, agentGrantor, apiGrantor, oauthGrantor, testGrantor and customGrantor, with matching revoker helpers (userRevoker, adminRevoker, policyRevoker, and others).

To grant or revoke for someone other than the caller, like a lead approving a teammate, use the store: this.get(ApprovalStoreToken) returns it, with grantApproval({ toolId, scope, userId?, sessionId?, ttlMs?, grantedBy?, ... }) and revokeApproval(...).

Scopes

ApprovalScope is a string enum:

| Scope | Value | What it does | | ------------------ | -------------------- | ---------------------------------------------------------------------------------------- | | SESSION | 'session' | Approves for the session. | | USER | 'user' | Approves for the user, in every session. | | TIME_LIMITED | 'time_limited' | Needs a ttlMs, and approves until it expires. | | CONTEXT_SPECIFIC | 'context_specific' | Approves only for calls in that context, which authInfo.extra.approvalContext carries. | | TOOL_SPECIFIC | 'tool_specific' | A label. Nothing in the plugin grants it. |

Under MCP protocol 2026-07-28, clients have no sessions, so a session approval is kept per user and lasts until it's revoked or expires. Grant time-limited approvals when that matters.

Options

ApprovalPlugin.init({
  storage: { type: 'redis', redis: { url: process.env.REDIS_URL } },
});

| Option | Default | Description | | ------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | storage | { type: 'auto' } | Where approvals are kept: 'memory', 'redis', 'vercel-kv', 'upstash', 'cloudflare-kv' or 'auto'. 'auto' picks Upstash, Vercel KV or Redis from their environment variables, and memory otherwise. | | storageInstance | | A storage you made with createStorage() from @frontmcp/utils. Wins over storage. | | namespace | 'approval' | The prefix of the plugin's keys. | | cleanupIntervalSeconds | 60 | How often expired approvals are deleted. 0 turns it off. | | mode | 'recheck' | 'webhook' also registers a ChallengeService (below). Neither mode contacts an external system. | | webhook.challengeTtl | 300 | How long the ChallengeService's challenges last, in seconds. |

webhook.url, webhook.callbackPath, webhook.includeJwt, the recheck options, enableAudit and maxDelegationDepth are accepted and not used: nothing is sent, polled or served.

Webhook mode

With mode: 'webhook', this.get(ChallengeServiceToken) returns a ChallengeService: a store of one-time PKCE (Proof Key for Code Exchange, RFC 7636) challenges you can build an external approval flow on. createChallenge({ toolId, sessionId, requestedScope, requestInfo, ... }) returns a code verifier and its SHA-256 challenge, and verifyAndConsume(codeVerifier) returns the stored request once, or throws ChallengeValidationError. The plugin itself doesn't call it, send a webhook or serve a callback route.

Docs

Related packages

Contributing

Source: plugins/plugin-approval. Issues and pull requests are welcome; see CONTRIBUTING.md.

License

Apache-2.0. See LICENSE.