> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Approval Plugin

> Tool authorization workflow with PKCE webhook security for secure AI agent interactions.

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

## Why Use Approval?

<CardGroup cols={2}>
  <Card title="Tool Permissions" icon="shield-check">
    Claude Code-style approval system for sensitive tool execution
  </Card>

  <Card title="Multiple Scopes" icon="folder-tree">
    Session, user, time-limited, and context-specific approvals
  </Card>

  <Card title="PKCE Security" icon="lock">
    RFC 7636 PKCE webhooks for secure external approval systems
  </Card>

  <Card title="Audit Trail" icon="clipboard-list">
    Full audit log with grantor/revoker tracking
  </Card>
</CardGroup>

## Installation

```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
npm install @frontmcp/plugin-approval
```

## How It Works

<Steps>
  <Step title="Tool Configuration">
    Mark tools requiring approval with `approval: { required: true }` in metadata
  </Step>

  <Step title="Approval Check Hook">
    Before tool execution, the plugin checks if approval exists. `ApprovalPlugin.init()` registers this
    check itself; you do not list `ApprovalCheckPlugin` separately
  </Step>

  <Step title="Approval Request">
    If not approved, throws `ApprovalRequiredError`. The client receives an error result whose text is exactly the
    tool's `approvalMessage` (or the default prompt) and whose `_meta.code` is `APPROVAL_REQUIRED`, in production and
    outside it alike
  </Step>

  <Step title="Grant/Revoke">
    Approvals are granted via `this.approval` methods or external webhooks
  </Step>
</Steps>

***

## Quick Start

### Basic Setup

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
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 {}
```

<Warning>
  Upgrade to 1.8.1 or later. In 1.8.0 and earlier, `ApprovalPlugin.init()` did not register the check hook, so
  tools marked `approval` ran without any approval. Listing `ApprovalCheckPlugin` next to it is no longer needed;
  if you already do, the check still runs once per call.
</Warning>

### Require Approval on Tools

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { ApprovalScope } from '@frontmcp/plugin-approval';
import { Tool, ToolContext } from '@frontmcp/sdk';
import { z } from '@frontmcp/sdk';

@Tool({
  name: 'file_write',
  description: 'Write to file system',
  inputSchema: { path: z.string(), content: z.string() },
  approval: {
    required: true,
    defaultScope: ApprovalScope.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`:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@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);
  }
}
```

***

## How a Call Is Decided

For a tool with `approval` set, the check runs before the tool executes and decides in this order:

1. `skipApproval: true` (or `approval` not required): the tool runs.
2. A recorded **denial** for the caller, in session or user scope: the call is refused with state `denied`.
   A denial outranks everything below, including pre-approved contexts and a session approval.
3. The call runs in one of the tool's `preApprovedContexts`: the tool runs.
4. `alwaysPrompt: true`: the call is refused with state `pending`.
5. A valid (unexpired) approval for the caller: the tool runs.
6. Otherwise the call is refused with state `pending`, or `expired` when the approval has lapsed.

A refused call throws `ApprovalRequiredError`, which the client receives as an error result:

```json theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
{
  "isError": true,
  "content": [{ "type": "text", "text": "Tool \"my-app:file_write\" requires approval to execute. Allow?" }],
  "_meta": { "code": "APPROVAL_REQUIRED", "errorId": "err_…", "timestamp": "…" }
}
```

The text is the tool's `approvalMessage` when it sets one, and `Tool "<full name>" execution denied.` for a recorded
denial. The approval errors are public MCP errors (`ApprovalError` extends `PublicMcpError`), so the message is never
replaced by an internal-error notice in production and never carries a stack trace.

<Note>
  Releases up to 1.8.5 wrapped a refusal as an internal server error: outside production its text carried a stack
  trace, and in production the client saw `Internal FrontMCP error` instead of the approval message.
</Note>

### Whose Approval It Is

Approvals are looked up by the tool's full name, `<owner id>:<tool name>`, so pass that name to the `this.approval`
grant and check methods. The owner is the app that declares the tool, or the adapter or plugin that provides it: a
tool declared on app `my-app` is `my-app:file_write`, and one the app's `github-api` adapter provides is
`github-api:create_issue`.

### Which Tools a Plugin Gates

Installed on an app, `ApprovalPlugin` gates that app's tools, including those the app's adapters and plugins
provide, against its own store. It never judges the tools of another app that has its own `ApprovalPlugin`, so two
apps can each install it with separate stores. It does gate, against its store, the `approval` tools of apps with no
approval plugin of their own, so a tool that asks for approval never runs ungated because the plugin sits on a
different app. Installed on the server (`@FrontMcp({ plugins })`), it gates every tool. A tool both gate must pass
each store's check, and a denial in either one refuses the call.

<Note>
  Releases up to 1.8.2 let the `approval` tools of an app without the plugin run for anyone when the plugin was
  installed on another app.
</Note>

`@Agent({ approval })` gates the agent's `invoke_<agent>` tool the same way. A server where a tool or agent declares
`approval` and no `ApprovalPlugin` reaches it refuses to start with `UnenforcedMetadataError`, naming the entries. This
includes a server with no `ApprovalPlugin` at all, a `splitByApp` app whose own scope has none, and a tool declared
inside an `@Agent`, which only a plugin installed on that agent gates.

<Note>
  Up to 1.8.2 such a server started, and those tools ran without approval. `@Agent({ approval })` was never gated.
</Note>

`this.approval` resolves the `ApprovalService` of the `ApprovalPlugin` nearest to the tool: the one its own app
installed, otherwise the one on the server. With two apps that each install `ApprovalPlugin` with its own store, a
grant or check made through `this.approval` in one app's tool uses that app's store.

<Note>
  Releases up to 1.8.1 resolved `this.approval` to the store of the app registered last, whichever app the tool
  belonged to ([#600](https://github.com/agentfront/frontmcp/issues/600)).
</Note>

Session approvals belong to the caller's session, and only to a session the server verified
(`FrontMcpContext.verifiedSessionId`). A stateless request has no such session: it carries the shared stateless
session id, or no `mcp-session-id` at all and runs under a fresh per-request id. Such a request is keyed by the
authenticated principal instead: `authInfo.extra.userId`, then `authInfo.extra.sub`, then `authInfo.clientId`
(the token's `sub`), so an approval granted in one stateless request is found by the next request of the same
principal and by no other principal. A stateless call with no authenticated principal cannot hold a session
approval, and a tool that requires approval stays refused for it.

<Note>
  Releases up to 1.8.1 keyed a stateless request that sent no `mcp-session-id` by its per-request id, so a session
  approval granted in one request was never found by the next and the tool stayed refused
  ([#597](https://github.com/agentfront/frontmcp/issues/597)).
</Note>

***

## Approval Scopes

| Scope | Description | Use Case |
| - | - | - |
| `session` | Valid only for current session | Default, most restrictive |
| `user` | Persists across sessions for user | Trusted tools |
| `time_limited` | Expires after specified TTL | Temporary elevated access |
| `tool_specific` | Tied to specific tool instance | Fine-grained control |
| `context_specific` | Tied to context (e.g., specific repo) | Context-aware approvals |

A context-specific approval opens the gate only for calls whose session carries that context
(`authInfo.extra.approvalContext`, set by the server while authenticating), never from a context the caller names.

***

## Plugin Options

### Basic Configuration

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
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:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
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):

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
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

<ParamField path="approval.required" type="boolean" default="true">
  Whether this tool requires approval before execution
</ParamField>

<ParamField path="approval.defaultScope" type="ApprovalScope" default="'session'">
  Default scope for approvals: `session`, `user`, `time_limited`, `tool_specific`, `context_specific`
</ParamField>

<ParamField path="approval.allowedScopes" type="ApprovalScope[]">
  Restrict which scopes are allowed for this tool. Granting another scope through `this.approval` throws
  `ApprovalScopeNotAllowedError`, and an approval of another scope, however it was stored, does not open the gate.
</ParamField>

<ParamField path="approval.maxTtlMs" type="number">
  Maximum lifetime of an approval of this tool (milliseconds). A longer `grantTimeLimitedApproval()` throws
  `ApprovalOperationError`, grants without a TTL get `maxTtlMs`, and no approval counts for longer than `maxTtlMs`
  after it was granted.
</ParamField>

<ParamField path="approval.category" type="string">
  Category for grouping: `read`, `write`, `delete`, `execute`, `admin`
</ParamField>

<ParamField path="approval.riskLevel" type="string">
  Risk level hint: `low`, `medium`, `high`, `critical`
</ParamField>

<ParamField path="approval.approvalMessage" type="string">
  Message shown to user when prompting for approval
</ParamField>

<ParamField path="approval.alwaysPrompt" type="boolean" default="false">
  Prompt every time, even if previously approved (for highly sensitive operations)
</ParamField>

<ParamField path="approval.skipApproval" type="boolean" default="false">
  Skip approval entirely (for safe, read-only operations)
</ParamField>

<ParamField path="approval.preApprovedContexts" type="ApprovalContext[]">
  Contexts that are pre-approved (bypass approval check).

  The context a call runs in is read **only** from the session, at
  `authInfo.extra.approvalContext`, which your authentication layer sets. A `context`
  field in the tool's own arguments is ignored: the caller of a gated tool must not be
  able to name the context that lets it skip the gate. A recorded denial for the caller
  still wins over a pre-approved context.
</ParamField>

***

## API Reference

### ApprovalService Methods

<Note>
  Every grant method records who granted the approval in `grantedBy`. When you pass none, it is the signed-in caller
  whose tool made the grant, i.e. `{ source: 'user', identifier: '<user id>', method: 'implicit' }`: a tool that grants
  through `this.approval` asked no one, so the grant is not recorded as an `'interactive'` answer. A tool that did ask passes
  `grantedBy: userGrantor(<user id>)`, whose method is `'interactive'`. Without a signed-in user (no principal, or an anonymous `anon:` subject) it is `{ source: 'user' }`
  with no identifier. Pass `grantedBy` to record something else, e.g. `policyGrantor('policy:read-only-safe')` for an
  automatic grant. `revokeApproval()` records `revokedBy` by the same rule, and `getRevocations(toolId)` reads it back. Releases up to
  1.8.5 recorded every such grant and revocation as `'policy'`; 1.8.6 recorded every default grant as `'interactive'` and
  kept no `revokedBy`.
</Note>

<ParamField path="isApproved(toolId)" type="Promise<boolean>">
  Check if a tool is approved for execution

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  const approved = await this.approval.isApproved('file_write');
  ```
</ParamField>

<ParamField path="grantSessionApproval(toolId, options?)" type="Promise<void>">
  Grant session-scoped approval

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  await this.approval.grantSessionApproval('file_write', {
    reason: 'User clicked Allow button',
  });
  ```
</ParamField>

<ParamField path="grantUserApproval(toolId, options?)" type="Promise<void>">
  Grant user-scoped approval (persists across sessions)

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  await this.approval.grantUserApproval('api_access', {
    reason: 'Admin pre-approved',
  });
  ```
</ParamField>

<ParamField path="grantTimeLimitedApproval(toolId, ttlMs, options?)" type="Promise<void>">
  Grant time-limited approval to the caller. `ttlMs` must be a positive number.

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  await this.approval.grantTimeLimitedApproval('elevated_access', 3600000, {
    reason: 'Temporary elevated access for 1 hour',
  });
  ```
</ParamField>

<ParamField path="revokeApproval(toolId, options?)" type="Promise<boolean>">
  Revoke the caller's approvals of the tool: session, user, time-limited and context approvals. Returns whether
  anything was revoked. Recorded denials are kept.

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  await this.approval.revokeApproval('file_write', {
    reason: 'User clicked Revoke',
  });
  ```
</ParamField>

<ParamField path="getRevocations(toolId)" type="Promise<ApprovalRecord[]>">
  The caller's recent revocations of the tool (kept for 24 hours), newest last. Each is the approval that was revoked,
  with `revokedBy`, `revokedAt` and `revocationReason`. Empty when the store keeps no revocations.
</ParamField>

<ParamField path="getApproval(toolId)" type="Promise<ApprovalRecord | undefined>">
  Get the current approval record for a tool

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  const record = await this.approval.getApproval('file_write');
  if (record) {
    console.log('Approved at:', record.grantedAt);
    console.log('Granted by:', record.grantedBy);
  }
  ```
</ParamField>

***

## Approval Audit Trail

Every approval records who granted it and how:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
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. `userGrantor(userId, displayName?, options?)` takes the method and origin in
its third argument (`{ method?, origin? }`, method defaulting to `'interactive'`):

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { userGrantor, adminGrantor, policyGrantor, systemGrantor } from '@frontmcp/plugin-approval';

// User-initiated approval
await this.approval.grantSessionApproval('tool', {
  grantedBy: userGrantor('user-123', 'John Doe', { method: '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:

```json theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
{
  "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:

```json theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
{
  "code_verifier": "original-verifier-string",
  "approved": true,
  "scope": "session",
  "ttlMs": 3600000,
  "grantedBy": {
    "source": "user",
    "identifier": "user-123",
    "displayName": "John Doe"
  }
}
```

<Warning>
  The `sessionId` is **never** sent to external webhooks. PKCE ensures only the original requester can complete the approval flow.
</Warning>

***

## Storage Options

The `storage` option uses the `StorageConfig` type from `@frontmcp/utils` and supports `memory`, `redis`, `vercel-kv`, `upstash`, and `auto`.

### Auto-Detect (Default)

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
ApprovalPlugin.init({
  storage: { type: 'auto' }, // Detects from env vars (Redis, Vercel KV, Upstash, or memory)
});
```

### Memory Storage

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
ApprovalPlugin.init({
  storage: { type: 'memory' },
});
```

<Warning>Memory storage resets when the process restarts.</Warning>

### Redis Storage

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
ApprovalPlugin.init({
  storage: {
    type: 'redis',
    redis: {
      config: {
        host: 'localhost',
        port: 6379,
        password: process.env.REDIS_PASSWORD,
      },
      // Or, alternatively: url: 'redis://user:pass@host:6379/0'
      // Or, alternatively: client: existingIoRedisClient
    },
  },
});
```

### Use Existing Storage Instance

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { createStorage } from '@frontmcp/utils';

const storage = await createStorage({ type: 'redis', redis: { url: 'redis://localhost' } });

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

***

## Best Practices

<AccordionGroup>
  <Accordion title="1. Use Appropriate Scopes">
    * **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
  </Accordion>

  <Accordion title="2. Configure Risk Levels">
    Mark tools with appropriate risk levels to help users make informed decisions:

    ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    approval: {
      required: true,
      riskLevel: 'critical', // For destructive operations
      category: 'delete',
      alwaysPrompt: true, // Always ask for critical operations
    }
    ```
  </Accordion>

  <Accordion title="3. Use PKCE for External Approvals">
    When integrating with external approval systems, always use webhook mode with PKCE to prevent session hijacking.
  </Accordion>

  <Accordion title="4. Track Audit Trails">
    Always provide meaningful `reason` and `grantedBy` information for compliance and debugging:

    ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    await this.approval.grantSessionApproval('tool', {
      grantedBy: userGrantor(userId, userName, { method: 'interactive' }),
      reason: 'User approved via UI dialog',
    });
    ```
  </Accordion>
</AccordionGroup>

***

## Complete Example

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { FrontMcp, App, Tool, ToolContext } from '@frontmcp/sdk';
import { ApprovalPlugin, userGrantor } from '@frontmcp/plugin-approval';
import { z } from '@frontmcp/sdk';

// Configure approval plugin with webhook mode
const approvalPlugin = ApprovalPlugin.init({
  mode: 'webhook',
  webhook: {
    url: 'https://approval.example.com/webhook',
    challengeTtl: 300,
  },
  storage: {
    type: 'redis',
    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', { method: 'interactive' }),
        reason: 'Admin granted access',
      });
    } else {
      await this.approval.grantUserApproval(input.toolId, {
        grantedBy: userGrantor(userId, 'Admin', { method: '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 {}
```

***

## Links & Resources

<CardGroup cols={2}>
  <Card title="Source Code" icon="github" href="https://github.com/agentfront/frontmcp/tree/main/plugins/plugin-approval">
    View the approval plugin source code
  </Card>

  <Card title="Remember Plugin" icon="brain" href="/frontmcp/plugins/remember-plugin">
    For session memory storage
  </Card>

  <Card title="Plugin Guide" icon="puzzle-piece" href="/frontmcp/extensibility/plugins">
    Learn more about FrontMCP plugins
  </Card>

  <Card title="PKCE RFC 7636" icon="book" href="https://oauth.net/2/pkce/">
    PKCE specification
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.