Skip to main content
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:
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:
import { App } from '@frontmcp/sdk';
import { CachePlugin, CodeCallPlugin } from '@frontmcp/plugins';
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:
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:
my-plugin.types.ts
import { z } from 'zod';

// 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>;
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:
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:
import { Plugin, Skill, Tool, ToolContext } from '@frontmcp/sdk';
import { z } from 'zod';

// 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<typeof deployInputSchema> {
  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.
Use plugin skills to bundle workflow knowledge with the tools they use. This creates self-contained, reusable functionality modules.

Available Hooks

ToolHook (tools:call-tool)

Intercept tool execution flow:
StagePhaseDescription
parseInputpreParse and validate request
findToolpreLocate the requested tool
createToolCallContextpreCreate execution context
acquireQuotapreRate limiting
acquireSemaphorepreConcurrency control
validateInputexecuteValidate tool input against schema
executeexecuteRun the tool
validateOutputexecuteValidate tool output
releaseSemaphorefinalizeRelease concurrency
releaseQuotafinalizeRelease rate limit
finalizefinalizeFormat and return response

ListToolsHook (tools:list-tools)

Intercept tool listing flow:
StagePhaseDescription
parseInputpreParse request
findToolsexecuteCollect available tools
resolveConflictsexecuteHandle name conflicts
parseToolspostFormat tool descriptors

Hook Timing

  • .Will(stage) - runs before the stage
  • .Did(stage) - runs after the stage
@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

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 ParameterDescription
TOptionsResolved options type (after defaults). Use internally.
TInputInput options type (for init()). Defaults to TOptions.
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.

Extending Tool Metadata

Plugins can extend the global tool metadata interface:
declare global {
  interface ExtendFrontMcpToolMetadata {
    cache?: { ttl?: number; slideWindow?: boolean } | true;
  }
}
Tools can then use this metadata:
@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:
@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:
@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}`);
  }
}
Server-scoped plugins can only be used in non-standalone apps (standalone: false). Using scope: 'server' in a standalone app will throw an InvalidPluginScopeError.

When to Use Each Scope

Use CaseScopeExample
App-specific cachingappCache responses for one app only
Per-app rate limitingappDifferent limits per app
Global audit loggingserverLog all tool calls across apps
Cross-app orchestrationserverAccess tools from multiple apps
Authentication gatewayserverValidate tokens before any app

Accessing Other Apps (Server Scope)

Server-scoped plugins can access other apps via scope.apps:
@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.getAll();

    // Get a specific app by ID
    const otherApp = ctx.scope.apps.get('other-app-id');
  }
}

Composition

Plugins compose depth-first at the app level. Later plugins can depend on providers exported by earlier ones.
Put organization-wide concerns (auth, audit, tracing) in plugins so all generated and inline components inherit the behavior without boilerplate.