---
title: Plugin Extensions
description: Configure and attach plugins to apps with hooks, providers, and dynamic options
sidebarTitle: Plugins
slug: extensibility/plugins
icon: puzzle-piece
keywords: ['frontmcp', 'mcp', 'model context protocol', 'extensibility', 'extension', 'customization', 'plugins', 'typescript']
---

**Plugins** add cross-cutting behavior and can contribute components. Typical uses: auth/session helpers, PII filtering, tracing, logging, caching, error policy, rate-limits.

## Basic Plugin

A simple plugin uses the `@Plugin` decorator:

```ts
import { Plugin } from '@frontmcp/sdk';

@Plugin({
  name: 'my-plugin',
  description: 'A basic plugin example',
  providers: [MyProvider],     // plugin-scoped providers
  exports: [MyProvider],       // re-export to host app
  tools: [MyTool],             // contribute tools
  skills: [MySkill],           // contribute skills
})
export default class MyPlugin {}
```

## Using Plugins

Attach plugins at app scope:

```ts
import { App } from '@frontmcp/sdk';
import { CachePlugin } from '@frontmcp/plugin-cache';
import { CodeCallPlugin } from '@frontmcp/plugin-codecall';
import MySimplePlugin from './plugins/my-simple.plugin';

@App({
  id: 'my-app',
  name: 'My App',
  plugins: [
    // Option 1: Pass class directly (uses default options)
    MySimplePlugin,

    // Option 2: Use init() with custom options
    CachePlugin.init({
      type: 'redis',
      config: { host: 'localhost', port: 6379 },
    }),

    // Option 3: Use init() with factory for async/injected config
    CodeCallPlugin.init({
      inject: () => [ConfigService],
      useFactory: (config: ConfigService) => ({
        topK: config.get('CODECALL_TOP_K'),
        mode: config.get('CODECALL_MODE'),
      }),
    }),
  ],
})
export default class MyApp {}
```

## Dynamic Plugin with Options

For plugins that need runtime configuration, extend `DynamicPlugin`:

```ts
import { DynamicPlugin, Plugin, ProviderType } from '@frontmcp/sdk';

interface MyPluginOptions {
  mode: 'fast' | 'safe';
  maxRetries?: number;
}

@Plugin({
  name: 'my-plugin',
  description: 'A configurable plugin',
  providers: [],
})
export default class MyPlugin extends DynamicPlugin<MyPluginOptions> {
  options: MyPluginOptions;

  constructor(options: MyPluginOptions = { mode: 'safe' }) {
    super();
    this.options = {
      maxRetries: 3,
      ...options,
    };
  }

  /**
   * Return providers based on options passed to init().
   */
  static override dynamicProviders(options: MyPluginOptions): ProviderType[] {
    return [
      {
        name: 'my-plugin:config',
        provide: MyPluginConfig,
        useValue: new MyPluginConfig(options),
      },
    ];
  }
}
```

### Type-Safe Options with Zod

For complex options with defaults, use Zod schemas with two types:

```ts title="my-plugin.types.ts"
import { z } from '@frontmcp/sdk';

// Inner schema (without outer .default())
const myPluginOptionsObjectSchema = z.object({
  mode: z.enum(['fast', 'safe']).default('safe'),
  maxRetries: z.number().positive().default(3),
  timeout: z.number().positive().optional(),
});

// Full schema for parsing
export const myPluginOptionsSchema = myPluginOptionsObjectSchema.default({});

/** Resolved type - all defaults applied. Use internally. */
export type MyPluginOptions = z.infer<typeof myPluginOptionsSchema>;

/** Input type - fields with defaults are optional. Use for init(). */
export type MyPluginOptionsInput = z.input<typeof myPluginOptionsObjectSchema>;
```

```ts title="my-plugin.plugin.ts"
import { DynamicPlugin, Plugin, ProviderType } from '@frontmcp/sdk';
import {
  MyPluginOptions,
  MyPluginOptionsInput,
  myPluginOptionsSchema
} from './my-plugin.types';

@Plugin({
  name: 'my-plugin',
  description: 'Plugin with Zod-validated options',
  providers: [],
})
export default class MyPlugin extends DynamicPlugin<MyPluginOptions, MyPluginOptionsInput> {
  options: MyPluginOptions;

  constructor(options: MyPluginOptionsInput = {}) {
    super();
    this.options = myPluginOptionsSchema.parse(options);
  }

  static override dynamicProviders(options: MyPluginOptionsInput): ProviderType[] {
    const parsed = myPluginOptionsSchema.parse(options);
    return [
      {
        name: 'my-plugin:config',
        provide: MyPluginConfig,
        useValue: new MyPluginConfig(parsed),
      },
    ];
  }
}
```

## Adding Hooks

Plugins can intercept flow stages using hooks. Use `this.get(Token)` to access providers:

```ts
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from '@frontmcp/sdk';

// Define options interface
interface CachePluginOptions {
  type: 'memory' | 'redis';
  defaultTTL?: number; // TTL in seconds
}

// Injection token for the cache store
const CacheStoreToken = Symbol('CacheStore');

@Plugin({
  name: 'cache',
  description: 'Cache plugin for tool results',
  providers: [
    {
      name: 'cache:store',
      provide: CacheStoreToken,
      useValue: new MemoryCacheProvider(),
    },
  ],
})
export default class CachePlugin extends DynamicPlugin<CachePluginOptions> {
  options: CachePluginOptions;

  constructor(options: CachePluginOptions = { type: 'memory', defaultTTL: 3600 }) {
    super();
    this.options = options;
  }

  // Hook BEFORE 'execute' stage
  @ToolHook.Will('execute', { priority: 1000 })
  async checkCache(flowCtx: FlowCtxOf<'tools:call-tool'>) {
    const { tool, toolContext } = flowCtx.state;
    if (!tool || !toolContext) return;

    const { cache } = toolContext.metadata;
    if (!cache) return;

    // Access provider via this.get()
    const cacheStore = this.get(CacheStoreToken);
    const key = this.buildKey(tool.fullName, toolContext.input);
    const cached = await cacheStore.getValue(key);

    if (cached !== undefined) {
      // Set output and bypass execution
      flowCtx.state.rawOutput = cached;
      toolContext.respond(cached);
    }
  }

  // Hook AFTER 'execute' stage
  @ToolHook.Did('execute', { priority: 1000 })
  async storeCache(flowCtx: FlowCtxOf<'tools:call-tool'>) {
    const { tool, toolContext } = flowCtx.state;
    if (!tool || !toolContext) return;

    const { cache } = toolContext.metadata;
    if (!cache) return;

    const cacheStore = this.get(CacheStoreToken);
    const key = this.buildKey(tool.fullName, toolContext.input);
    const ttl = cache === true ? this.options.defaultTTL : cache.ttl;

    await cacheStore.setValue(key, toolContext.output, ttl);
  }

  private buildKey(toolName: string, input: unknown): string {
    return `${toolName}:${JSON.stringify(input)}`;
  }
}
```

## Contributing Skills

Plugins can contribute skills that teach AI how to perform workflows using the plugin's tools:

```ts
import { Plugin, Skill, Tool, ToolContext } from '@frontmcp/sdk';
import { z } from '@frontmcp/sdk';

// Plugin tool
const deployInputSchema = z.object({
  environment: z.enum(['staging', 'production']),
  version: z.string(),
});

@Tool({
  name: 'deploy_application',
  description: 'Deploy application to specified environment',
  inputSchema: deployInputSchema,
})
class DeployTool extends ToolContext {
  async execute(input) {
    return { success: true, environment: input.environment };
  }
}

// Plugin skill that uses the tool
@Skill({
  name: 'deploy-workflow',
  description: 'Complete deployment workflow',
  instructions: `
    ## Deployment Workflow
    1. Use deploy_application to deploy
    2. Verify deployment success
  `,
  tools: [
    { tool: DeployTool, purpose: 'Deploy the application', required: true },
  ],
  tags: ['devops', 'deployment'],
})
class DeployWorkflowSkill {}

@Plugin({
  name: 'devops-plugin',
  description: 'DevOps tools and deployment workflows',
  tools: [DeployTool],
  skills: [DeployWorkflowSkill],
})
export class DevOpsPlugin {}
```

Plugin skills are **automatically adopted** into the app's skill registry when the plugin is attached. They appear in `searchSkills` results alongside app-level skills and can be loaded with `loadSkill`.

<Tip>
Use plugin skills to bundle workflow knowledge with the tools they use. This creates self-contained, reusable functionality modules.
</Tip>

## Available Hooks

### ToolHook (tools:call-tool)

Intercept tool execution flow:

| Stage                   | Phase    | Description                        |
| ----------------------- | -------- | ---------------------------------- |
| `parseInput`            | pre      | Parse and validate request         |
| `findTool`              | pre      | Locate the requested tool          |
| `createToolCallContext` | pre      | Create execution context           |
| `acquireQuota`          | pre      | Rate limiting                      |
| `acquireSemaphore`      | pre      | Concurrency control                |
| `validateInput`         | execute  | Validate tool input against schema |
| `execute`               | execute  | Run the tool                       |
| `validateOutput`        | execute  | Validate tool output               |
| `releaseSemaphore`      | finalize | Release concurrency                |
| `releaseQuota`          | finalize | Release rate limit                 |
| `finalize`              | finalize | Format and return response         |

### ListToolsHook (tools:list-tools)

Intercept tool listing flow:

| Stage              | Phase   | Description             |
| ------------------ | ------- | ----------------------- |
| `parseInput`       | pre     | Parse request           |
| `findTools`        | execute | Collect available tools |
| `resolveConflicts` | execute | Handle name conflicts   |
| `parseTools`       | post    | Format tool descriptors |

### Hook Timing

- `.Will(stage)` - runs **before** the stage
- `.Did(stage)` - runs **after** the stage

```ts
@ToolHook.Will('execute', { priority: 1000 })  // Before tool runs
async beforeExecute(flowCtx: FlowCtxOf<'tools:call-tool'>) { }

@ToolHook.Did('execute', { priority: 1000 })   // After tool runs
async afterExecute(flowCtx: FlowCtxOf<'tools:call-tool'>) { }
```

## DynamicPlugin API

```ts
class DynamicPlugin<TOptions, TInput = TOptions> {
  // Access a provider registered in the plugin or app
  get<T>(token: Reference<T>): T;

  // Static method to create configured plugin instance
  // Pass to the plugins array in @App()
  static init(options: TInput);

  // Override to provide dynamic providers based on options
  static dynamicProviders?(options: TInput): ProviderType[];
}
```

| Type Parameter | Description                                                |
| -------------- | ---------------------------------------------------------- |
| `TOptions`     | Resolved options type (after defaults). Use internally.    |
| `TInput`       | Input options type (for `init()`). Defaults to `TOptions`. |

<Info>
  The `init()` method accepts your plugin's input options type and returns a provider
  configuration that the framework uses internally. You don't need to import or reference
  the return type—just pass the result directly to the `plugins` array.
</Info>

## Extending Tool Metadata

Plugins can extend the global tool metadata interface:

```ts
declare global {
  interface ExtendFrontMcpToolMetadata {
    cache?: { ttl?: number; slideWindow?: boolean } | true;
  }
}
```

Tools can then use this metadata:

```ts
@Tool({
  name: 'my-tool',
  metadata: {
    cache: { ttl: 3600 },
  },
})
```

## Plugin Scope

By default, plugins operate at the **app scope** - their hooks only fire for requests to that specific app.
For cross-app functionality, you can use **server scope**.

### App Scope (Default)

Hooks fire only for requests to the app where the plugin is registered:

```ts
@Plugin({
  name: 'my-plugin',
  scope: 'app', // Default - can be omitted
})
export default class MyPlugin {
  @ToolHook.Will('execute')
  async beforeTool(ctx: FlowCtxOf<'tools:call-tool'>) {
    // Only fires for tools in this app
  }
}
```

### Server Scope

Hooks fire at the gateway level for **all apps** in the server:

```ts
@Plugin({
  name: 'global-logger',
  scope: 'server', // Hooks fire for all apps
})
export default class GlobalLoggerPlugin {
  @ToolHook.Did('execute')
  async logToolCall(ctx: FlowCtxOf<'tools:call-tool'>) {
    // Fires for every tool call across all apps
    console.log(`Tool called: ${ctx.state.tool?.fullName}`);
  }
}
```

<Warning>
  Server-scoped plugins can only be used in non-standalone apps (`standalone: false`).
  Using `scope: 'server'` in a standalone app will throw an `InvalidPluginScopeError`.
</Warning>

### When to Use Each Scope

| Use Case                | Scope    | Example                          |
| ----------------------- | -------- | -------------------------------- |
| App-specific caching    | `app`    | Cache responses for one app only |
| Per-app rate limiting   | `app`    | Different limits per app         |
| Global audit logging    | `server` | Log all tool calls across apps   |
| Cross-app orchestration | `server` | Access tools from multiple apps  |
| Authentication gateway  | `server` | Validate tokens before any app   |

### Accessing Other Apps (Server Scope)

Server-scoped plugins can access other apps via `scope.apps`:

```ts
@Plugin({
  name: 'cross-app-plugin',
  scope: 'server',
})
export default class CrossAppPlugin {
  @ToolHook.Will('execute')
  async checkOtherApps(ctx: FlowCtxOf<'tools:call-tool'>) {
    // Access all registered apps
    const apps = ctx.scope.apps.getApps();

    // Find a specific app by ID
    const otherApp = apps.find((a) => a.id === 'other-app-id');
  }
}
```

### Composition

Plugins compose **depth-first** at the app level. Later plugins can depend on providers exported by earlier ones.

<Tip>
  Put organization-wide concerns (auth, audit, tracing) in plugins so all generated and inline components inherit the
  behavior without boilerplate.
</Tip>
