Skip to main content
The Approval Plugin provides Claude Code-style permission management for FrontMCP servers, enabling fine-grained tool authorization with PKCE webhook security.

Why Use Approval?

Tool Permissions

Claude Code-style approval system for sensitive tool execution

Multiple Scopes

Session, user, time-limited, and context-specific approvals

PKCE Security

RFC 7636 PKCE webhooks for secure external approval systems

Audit Trail

Full audit log with grantor/revoker tracking

Installation

npm install @frontmcp/plugin-approval

How It Works

1

Tool Configuration

Mark tools requiring approval with approval: { required: true } in metadata
2

Approval Check Hook

Before tool execution, the plugin checks if approval exists
3

Approval Request

If not approved, throws ApprovalRequiredError for client handling
4

Grant/Revoke

Approvals are granted via this.approval methods or external webhooks

Quick Start

Basic Setup

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

@App({
  id: 'my-app',
  name: 'My App',
  plugins: [ApprovalPlugin.init()], // Uses memory store by default
  tools: [
    /* your tools */
  ],
})
class MyApp {}

@FrontMcp({
  info: { name: 'My Server', version: '1.0.0' },
  apps: [MyApp],
})
export default class Server {}

Require Approval on Tools

import { Tool, ToolContext } from '@frontmcp/sdk';
import { z } from 'zod';

@Tool({
  name: 'file_write',
  description: 'Write to file system',
  inputSchema: { path: z.string(), content: z.string() },
  approval: {
    required: true,
    defaultScope: 'session',
    category: 'write',
    riskLevel: 'medium',
    approvalMessage: 'Allow writing to file system for this session?',
  },
})
export default class FileWriteTool extends ToolContext {
  async execute(input: { path: string; content: string }) {
    // Tool only executes if approved
    return await this.writeFile(input.path, input.content);
  }
}

Using the Approval Service

The plugin extends all execution contexts with this.approval:
@Tool({ name: 'dangerous_tool' })
class DangerousTool extends ToolContext {
  async execute(input) {
    // Check if approved
    const isApproved = await this.approval.isApproved('dangerous_tool');

    if (!isApproved) {
      // Request approval from client
      return { needsApproval: true, message: 'Please approve this action' };
    }

    // Proceed with dangerous operation
    return await this.performDangerousAction(input);
  }
}

Approval Scopes

ScopeDescriptionUse Case
sessionValid only for current sessionDefault, most restrictive
userPersists across sessions for userTrusted tools
time_limitedExpires after specified TTLTemporary elevated access
tool_specificTied to specific tool instanceFine-grained control
context_specificTied to context (e.g., specific repo)Context-aware approvals

Plugin Options

Basic Configuration

ApprovalPlugin.init({
  // Storage configuration
  storage: { type: 'auto' }, // 'auto', 'memory', 'redis', 'vercel-kv'

  // Namespace for approval keys
  namespace: 'approval', // default

  // Approval workflow mode
  mode: 'recheck', // or 'webhook'

  // Enable audit logging
  enableAudit: true, // default

  // Maximum delegation depth
  maxDelegationDepth: 3, // default

  // Cleanup interval for expired approvals
  cleanupIntervalSeconds: 60, // default
});

Recheck Mode (Default)

In recheck mode, the plugin polls an external API for approval status:
ApprovalPlugin.init({
  mode: 'recheck',
  recheck: {
    url: 'https://api.example.com/approval/status',
    auth: 'jwt', // 'jwt', 'bearer', 'none', 'custom'
    interval: 5000, // ms between checks
    maxAttempts: 10,
  },
});

Webhook Mode with PKCE

For secure external approval systems using PKCE (RFC 7636):
ApprovalPlugin.init({
  mode: 'webhook',
  webhook: {
    url: 'https://approval.example.com/webhook',
    includeJwt: false, // Security: don't expose JWT by default
    challengeTtl: 300, // 5 minutes
    callbackPath: '/approval/callback',
  },
});

Tool Approval Options

approval.required
boolean
default:"true"
Whether this tool requires approval before execution
approval.defaultScope
ApprovalScope
default:"'session'"
Default scope for approvals: session, user, time_limited, tool_specific, context_specific
approval.allowedScopes
ApprovalScope[]
Restrict which scopes are allowed for this tool
approval.maxTtlMs
number
Maximum TTL for time-limited approvals (milliseconds)
approval.category
string
Category for grouping: read, write, delete, execute, admin
approval.riskLevel
string
Risk level hint: low, medium, high, critical
approval.approvalMessage
string
Message shown to user when prompting for approval
approval.alwaysPrompt
boolean
default:"false"
Prompt every time, even if previously approved (for highly sensitive operations)
approval.skipApproval
boolean
default:"false"
Skip approval entirely (for safe, read-only operations)
approval.preApprovedContexts
ApprovalContext[]
Contexts that are pre-approved (bypass approval check)

API Reference

ApprovalService Methods

isApproved(toolId)
Promise<boolean>
Check if a tool is approved for execution
const approved = await this.approval.isApproved('file_write');
grantSessionApproval(toolId, options?)
Promise<void>
Grant session-scoped approval
await this.approval.grantSessionApproval('file_write', {
  reason: 'User clicked Allow button',
});
grantUserApproval(toolId, options?)
Promise<void>
Grant user-scoped approval (persists across sessions)
await this.approval.grantUserApproval('api_access', {
  reason: 'Admin pre-approved',
});
grantTimeLimitedApproval(toolId, ttlMs, options?)
Promise<void>
Grant time-limited approval
await this.approval.grantTimeLimitedApproval('elevated_access', 3600000, {
  reason: 'Temporary elevated access for 1 hour',
});
revokeApproval(toolId, options?)
Promise<void>
Revoke an existing approval
await this.approval.revokeApproval('file_write', {
  reason: 'User clicked Revoke',
});
getApproval(toolId)
Promise<ApprovalRecord | undefined>
Get the current approval record for a tool
const record = await this.approval.getApproval('file_write');
if (record) {
  console.log('Approved at:', record.grantedAt);
  console.log('Granted by:', record.grantedBy);
}

Approval Audit Trail

Every approval records who granted it and how:
interface ApprovalRecord {
  toolId: string;
  state: 'pending' | 'approved' | 'denied' | 'expired';
  scope: ApprovalScope;
  grantedAt: number;
  grantedBy: {
    source: 'user' | 'policy' | 'admin' | 'system' | 'agent' | 'api' | 'oauth';
    identifier?: string;
    displayName?: string;
    method?: 'interactive' | 'implicit' | 'delegation' | 'batch' | 'api';
    delegatedFrom?: DelegationContext;
  };
  reason?: string;
  expiresAt?: number;
  revokedAt?: number;
  revokedBy?: ApprovalRevoker;
}

Grantor Factory Functions

Create typed grantors for audit trails:
import { userGrantor, adminGrantor, policyGrantor, systemGrantor } from '@frontmcp/plugin-approval';

// User-initiated approval
await this.approval.grantSessionApproval('tool', {
  grantedBy: userGrantor('user-123', 'John Doe', 'interactive'),
});

// Admin approval
await this.approval.grantUserApproval('tool', {
  grantedBy: adminGrantor('admin-456', 'Admin User'),
});

// Policy-based auto-approval
await this.approval.grantSessionApproval('tool', {
  grantedBy: policyGrantor('policy:read-only-safe'),
});

// System auto-approval
await this.approval.grantSessionApproval('tool', {
  grantedBy: systemGrantor('initialization'),
});

PKCE Webhook Flow

For external approval systems, the plugin implements RFC 7636 PKCE:
1. Generate PKCE pair: code_verifier (64 chars) + code_challenge = SHA256(verifier)
2. Store challenge: challenge:{code_challenge} → {toolId, sessionId, scope, expiresAt}
3. Send to webhook: {code_challenge, toolId, requestInfo, callbackUrl} (NO sessionId!)
4. External system calls back: POST /approval/callback {code_verifier, approved}
5. Plugin validates: SHA256(code_verifier) === stored code_challenge
6. Grant approval if valid

Webhook Request

The plugin sends to your webhook URL:
{
  "code_challenge": "sha256-hash-of-verifier",
  "toolId": "file_write",
  "requestInfo": {
    "toolName": "file_write",
    "category": "write",
    "riskLevel": "medium",
    "customMessage": "Allow writing to file system?"
  },
  "callbackUrl": "https://your-server.com/approval/callback"
}

Callback Response

Your approval system responds to the callback URL:
{
  "code_verifier": "original-verifier-string",
  "approved": true,
  "scope": "session",
  "ttlMs": 3600000,
  "grantedBy": {
    "source": "user",
    "identifier": "user-123",
    "displayName": "John Doe"
  }
}
The sessionId is never sent to external webhooks. PKCE ensures only the original requester can complete the approval flow.

Storage Options

Auto-Detect (Default)

ApprovalPlugin.init({
  storage: { type: 'auto' }, // Uses Redis if available, else memory
});

Memory Storage

ApprovalPlugin.init({
  storage: { type: 'memory' },
});
Memory storage resets when the process restarts.

Redis Storage

ApprovalPlugin.init({
  storage: {
    type: 'redis',
    config: {
      host: 'localhost',
      port: 6379,
      password: process.env.REDIS_PASSWORD,
    },
  },
});

Use Existing Storage Instance

import { createStorage } from '@frontmcp/utils';

const storage = await createStorage({ type: 'redis', ... });

ApprovalPlugin.init({
  storageInstance: storage,
});

Best Practices

  • Session: Default, most restrictive - good for sensitive operations
  • User: For tools the user has explicitly trusted
  • Time-limited: For temporary elevated access
  • Context-specific: For repository/project-specific permissions
Mark tools with appropriate risk levels to help users make informed decisions:
approval: {
  required: true,
  riskLevel: 'critical', // For destructive operations
  category: 'delete',
  alwaysPrompt: true, // Always ask for critical operations
}
When integrating with external approval systems, always use webhook mode with PKCE to prevent session hijacking.
Always provide meaningful reason and grantedBy information for compliance and debugging:
await this.approval.grantSessionApproval('tool', {
  grantedBy: userGrantor(userId, userName, 'interactive'),
  reason: 'User approved via UI dialog',
});

Complete Example

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

// Configure approval plugin with webhook mode
const approvalPlugin = ApprovalPlugin.init({
  mode: 'webhook',
  webhook: {
    url: 'https://approval.example.com/webhook',
    challengeTtl: 300,
  },
  storage: {
    type: 'redis',
    config: {
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT || '6379'),
    },
  },
});

// Tool requiring approval
@Tool({
  name: 'delete-account',
  description: 'Permanently delete user account',
  inputSchema: { confirm: z.boolean() },
  approval: {
    required: true,
    riskLevel: 'critical',
    category: 'delete',
    approvalMessage: 'This will permanently delete your account. Are you sure?',
    alwaysPrompt: true,
  },
})
class DeleteAccountTool extends ToolContext {
  async execute(input: { confirm: boolean }) {
    if (!input.confirm) {
      return { success: false, message: 'Deletion not confirmed' };
    }

    // Perform account deletion
    await this.deleteAccount();

    return { success: true, message: 'Account deleted' };
  }
}

// Tool for granting approvals (admin only)
@Tool({
  name: 'grant-tool-access',
  description: 'Grant access to a tool for a user',
  inputSchema: {
    toolId: z.string(),
    scope: z.enum(['session', 'user']),
  },
})
class GrantToolAccessTool extends ToolContext {
  async execute(input: { toolId: string; scope: 'session' | 'user' }) {
    const userId = this.context.authInfo?.extra?.['userId'] as string;

    if (input.scope === 'session') {
      await this.approval.grantSessionApproval(input.toolId, {
        grantedBy: userGrantor(userId, 'Admin', 'interactive'),
        reason: 'Admin granted access',
      });
    } else {
      await this.approval.grantUserApproval(input.toolId, {
        grantedBy: userGrantor(userId, 'Admin', 'interactive'),
        reason: 'Admin granted persistent access',
      });
    }

    return { success: true, message: `Access granted for ${input.toolId}` };
  }
}

@App({
  id: 'secure-app',
  name: 'Secure App',
  plugins: [approvalPlugin],
  tools: [DeleteAccountTool, GrantToolAccessTool],
})
class SecureApp {}

@FrontMcp({
  info: { name: 'Secure Server', version: '1.0.0' },
  apps: [SecureApp],
  http: { port: 3000 },
})
export default class Server {}

Source Code

View the approval plugin source code

Remember Plugin

For session memory storage

Plugin Guide

Learn more about FrontMCP plugins

PKCE RFC 7636

PKCE specification