@Plugin

@Plugin declares a plugin: behaviour that belongs around every tool rather than in one, packaged with the providers, tools, resources, prompts and skills it needs. Its class holds hooks, methods that run at points in every call. An app turns it on by listing it in plugins, and a server does the same for every app. Extending with Plugins builds one step by step.

@Plugin(options)
class MyPlugin extends DynamicPlugin<Options> {
  @ToolHook.Did("execute")
  async afterEveryTool(ctx: FlowCtxOf<"tools:call-tool">) { /* ... */ }
}

Reference

@Plugin(options)

Apply @Plugin to a class, usually one that extends DynamicPlugin, and list the class in the plugins array of an @App or of @FrontMcp.

usage.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";
import { UsageLog } from "./usage-log";
import { UsageReport } from "./usage-report.tool";

@Plugin({
  name: "usage",
  description: "Counts calls to every tool",
  providers: [UsageLog],
  exports: [UsageLog],
  tools: [UsageReport],
})
export class UsagePlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async count(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.tool?.metadata.name;
    if (name) this.get(UsageLog).add(name);
  }
}
help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { UsagePlugin } from "./usage.plugin";

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [UsagePlugin] })
export class HelpDeskApp {}

See more examples below.

Options

Required:

OptionTypeDescription
namestringNames the plugin in logs and error messages, like the default message of a context extension.

Optional:

OptionTypeDescription
providersProviderType[]Providers for the plugin's own use: its hooks and its tools, resources and prompts. The app's tools can't get them unless they're in exports.
exportsProviderType[]Which of providers the app's own tools may get too, with this.get(). On a server-level plugin, every app's tools. See sharing a provider.
tools, resources, prompts, skillsas on @AppAdded to the app, and listed to clients like the app's own. A plugin on @FrontMcp adds them once for the server. See adding tools, resources, prompts and skills.
contextExtensionsContextExtension[]Properties added to this in every tool, resource and prompt, like this.usageLog. See contextExtensions.
pluginsPluginType[]Other plugins this one brings along. See bringing other plugins along.
adaptersAdapterType[]Adapters the plugin brings along, which generate tools from another source, such as an OpenAPI description. See the OpenAPI adapter.
descriptionstringFor people reading the code. Clients never see it.
enforcesMetadatastring[]Options this plugin's hooks enforce on tools, resources, prompts, agents and skills, like ["approval"]. A server where an entry sets one of them and no hook of such a plugin covers the entry doesn't start. See enforcing an option of your own.
dynamicSkillsbooleanThe plugin adds skills after the server starts, as Skilled OpenAPI does from the bundle it loads. The server then answers skills/list, skills/search and skills/load, and lists skill://index.json, from startup, with the skills added so far. Without it, a server with no other skills answers skills/list with -32601 until then. New in 1.8.5.
scope"app" | "server""server" runs an app plugin's hooks for every app's entries. It fails in a standalone app. See scope.
idstringAccepted and unused in FrontMCP 1.9.4.

Keys @Plugin doesn't know are kept without an error, so check the spelling of an option that seems to do nothing.

The plugin class

The class body holds the plugin's hooks: methods marked with a hook decorator, like @ToolHook.Did("execute"), that FrontMCP calls at a stage of a request. Inside a hook, this is the plugin instance.

Hook methods on the plugin's providers run too, like hooks on the class itself, as long as the provider has the default GLOBAL scope.

A plugin class doesn't have to extend anything: @Plugin on a plain class works, and its hooks run. Extending DynamicPlugin adds a typed this.get() and a typed init().

DynamicPlugin<Options, Input>

The base class for plugins, exported by @frontmcp/sdk.

  • Options is the type the plugin works with. Use object for a plugin without options: DynamicPlugin needs at least one type argument.
  • Input is what init() accepts, and defaults to Options. Give it optional fields when your constructor fills in defaults. FrontMCP doesn't convert one to the other: the constructor receives what was passed to init().
MemberDescription
static init(options?)Returns a plugin record to put in plugins instead of the class: plugins: [UsagePlugin.init({ ignore: ["usage_report"] })]. Calls the constructor with options right away, when your module is imported. Without an argument, the options are {}.
static init({ inject, useFactory })Builds the options when the server starts: useFactory gets the providers inject returns, from the app, and returns the options, or a promise of them (since 1.9.1). FrontMCP then calls the constructor with them. See options from a provider.
init({ ..., providers })Either form also takes providers: more providers for the plugin, added next to the ones dynamicProviders() returns. They reach the constructor inside options too.
static dynamicProviders(options)Optional. Define it to add providers built from the options, like a client configured with a URL. Called by init(), and with {} for a plugin listed as its class (since 1.9.4). See providers built from options.
static dynamicTools(options)Optional. Define it to add tools that depend on the options, like a set that's only there when an option turns it on, or named under a prefix. Called by init() with the options, from an object or from useFactory, and with {} for a plugin listed as its class. See an option named like a list. New in 1.8.7. (Changed in 1.9: before, the useFactory form didn't call it. Changed in 1.9.4: before, the class form didn't.)
this.get(token)Gets a provider: the plugin's own, an export of a plugin nested in it, or one of the app's. It works once the server has started, in hooks: in the constructor it throws DynamicPlugin.get() is not implemented.

plugins: [UsagePlugin] still works for a plugin that extends DynamicPlugin: FrontMCP builds it as UsagePlugin.init() does, so the constructor, dynamicProviders() and dynamicTools() each get {}. Fill in an option's default from that object, not with a default parameter, which {} doesn't trigger. (Changed in 1.9.4: before, the constructor got no argument, and dynamicProviders() and dynamicTools() weren't called, so a plugin whose tools or providers come from them had none.)

Where you register a plugin

Registered inIts tools, resources, prompts, skillsIts exportsIts hooks on tools/call, resources/read, prompts/get and job attemptsIts hooks on list requests and HttpHook
@App({ plugins })Added to that appThat app's toolsOnly for that app's entries and jobs, including the ones the plugin adds, and for calls to tool names no app has. Every app's, with scope: "server"Every request, whichever app it's about
@FrontMcp({ plugins })Added once, for the serverEvery app's toolsEvery app's entriesEvery request
@Plugin({ plugins })Added to the app the outer plugin is onThe outer plugin onlyAs for the outer pluginEvery request
@Agent({ plugins })Its tools join the agent's, for the agent's model onlyThe agent's toolsOnly for the tools the agent's model calls, and its reads of the agent's resources and prompts (since 1.9.4)Only for the lists the agent's model reads, not clients'

A hook with appliesTo: "uncovered-apps" on an app's plugin also runs for the entries of apps that have no instance of that hook: see enforcing an option of your own. Putting a plugin on an agent shows the last row.

contextExtensions

Each entry adds a property to this in every tool, resource and prompt, so tools write this.usageLog instead of this.get(USAGE_LOG).

FieldTypeDescription
propertystringRequired. The property name, like "usageLog".
tokenTokenRequired. The provider to return. It must be in the plugin's exports, or reading the property fails even in the plugin's own app.
errorMessagestringThe message when the property can't be resolved. Defaults to <plugin name> is not installed or '<property>' is not configured.
  • TypeScript doesn't know about the property until you declare it, with declare module "@frontmcp/sdk" { interface ExecutionContextBase { readonly usageLog: UsageLog } }.
  • FrontMCP installs the property once per process, on the base class of every context, and the first plugin to claim a name keeps it. So the property exists in apps that don't have the plugin as well, and reading it there throws ContextExtensionNotAvailableError (code CONTEXT_EXTENSION_NOT_AVAILABLE) with errorMessage.

scope

scope: "server" registers the hooks of a plugin on one app at server level, for every app:

  • On an app served on the main endpoint (the default), its tools/call, resources/read and prompts/get hooks run for every app's entries, as if the plugin were on @FrontMcp. Registering a plugin on every app shows it.
  • On a standalone: true app, the server doesn't start: Plugin "…" has scope='server' but is used in a standalone app. Server-scoped plugins can only be used in non-standalone apps.

Changed in 1.9.1: before, scope: "server" changed nothing, and the hooks ran only for that app's entries. To run a plugin's hooks for every app, registering it in @FrontMcp({ plugins }) works too. For a hook that gates entries by an option they set, appliesTo: "uncovered-apps" extends it to the apps that don't have the plugin.

Caveats

  • A plugin's providers are private to it, unless they're in exports.
  • Providers from init({ providers }) and from dynamicProviders() are the exception: the tools of the app the plugin is on can get them without exports. Other apps' tools can't (since 1.9; before, every app's could).
  • Hooks on a plugin in @App({ plugins }) that run on list requests (tools/list and the rest) or on HTTP requests see every app: an app's ListToolsHook sees and can filter the other apps' tools too. To filter only the entries its own call hooks judge, check each one with isEntryGatedBy(): see hiding what a plugin refuses.
  • init(options) constructs the plugin when your module is imported, not when the server starts. Use the useFactory form for options that need a provider or the environment at startup.
  • DynamicPlugin needs a type argument. extends DynamicPlugin alone fails with TS2707 (see Troubleshooting).
  • id has no effect in FrontMCP 1.9.4.

Usage

Writing a plugin

This plugin counts calls to every tool of the app it's registered on, and adds a usage_report tool that shows the counts. The app's tools don't mention it.

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "UsageLog" })
export class UsageLog {
  counts: Record<string, number> = {};

  add(tool: string) {
    this.counts[tool] = (this.counts[tool] ?? 0) + 1;
  }
}

@Tool({ name: "usage_report", description: "How many times each tool has been called", inputSchema: {} })
export class UsageReport extends ToolContext {
  async execute() {
    return { counts: { ...this.get(UsageLog).counts } };
  }
}

@Plugin({ name: "usage", description: "Counts calls to every tool", providers: [UsageLog], tools: [UsageReport] })
export class UsagePlugin extends DynamicPlugin<object> {
  // Runs after every tool's execute() returns
  @ToolHook.Did("execute")
  async count(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.tool?.metadata.name;
    if (name) this.get(UsageLog).add(name);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The hook runs for usage_report too: the plugin's tools belong to the app, like the app's own.

Sharing a provider with the app's tools

A plugin's providers are private to it. List one in exports and the app's tools can get it too; the ones you leave out stay private:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "UsageLog" })
export class UsageLog {
  counts: Record<string, number> = {};
}

@Provider({ name: "UsageSettings" })
export class UsageSettings {
  ignore = ["busiest_tool"];
}

@Plugin({
  name: "usage",
  providers: [UsageLog, UsageSettings],
  exports: [UsageLog], // ✅ the app's tools can get UsageLog; UsageSettings stays private
})
export class UsagePlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async count(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.tool?.metadata.name;
    if (!name || this.get(UsageSettings).ignore.includes(name)) return;
    const { counts } = this.get(UsageLog);
    counts[name] = (counts[name] ?? 0) + 1;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

this.get(UsageSettings) in the tool would fail with Provider "UsageSettings" is not available. Registering UsageLog in the app's providers as well wouldn't help: the app would get a second, empty UsageLog, and the tool would read that one.

Adding a property to every tool's this

contextExtensions puts an exported provider on this, as the official Remember plugin does with this.remember. The token must be in exports, and a declare module block tells TypeScript about the property:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

export class UsageLog {
  counts: Record<string, number> = {};
}

export const USAGE_LOG = Symbol.for("frontmcp.dev/reference/plugin/usage-log");
const usageLog = { provide: USAGE_LOG, name: "UsageLog", inject: () => [] as const, useFactory: () => new UsageLog() };

// Tells TypeScript that tools, resources and prompts have this.usageLog
declare module "@frontmcp/sdk" {
  interface ExecutionContextBase {
    readonly usageLog: UsageLog;
  }
}

@Plugin({
  name: "usage",
  providers: [usageLog],
  exports: [usageLog],
  contextExtensions: [
    { property: "usageLog", token: USAGE_LOG, errorMessage: "this.usageLog needs UsagePlugin in this app's plugins." },
  ],
})
export class UsagePlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async count(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.tool?.metadata.name;
    if (!name) return;
    const { counts } = this.get<UsageLog>(USAGE_LOG);
    counts[name] = (counts[name] ?? 0) + 1;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The billing app's tool compiles, because the declare module block applies everywhere, and the property exists at run time, because FrontMCP installs it on every context. Only the plugin's own app can resolve it. The example uses a Symbol.for() key because the Playground rebuilds your classes on every run but keeps one FrontMCP, which remembers the first token for usageLog.

Taking options

A plugin takes options through its constructor. init() passes them in, and goes in plugins where the class went:

Passing options

Example 1 of 4

An options object

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

export type UsageOptions = { ignore?: string[] };

@Provider({ name: "UsageLog" })
export class UsageLog {
  counts: Record<string, number> = {};
}

@Tool({ name: "usage_report", description: "How many times each tool has been called", inputSchema: {} })
export class UsageReport extends ToolContext {
  async execute() {
    return { counts: { ...this.get(UsageLog).counts } };
  }
}

@Plugin({ name: "usage", providers: [UsageLog], tools: [UsageReport] })
export class UsagePlugin extends DynamicPlugin<UsageOptions> {
  constructor(private readonly options: UsageOptions = {}) {
    super();
  }

  @ToolHook.Did("execute")
  async count(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.tool?.metadata.name;
    if (!name || this.options.ignore?.includes(name)) return;
    const { counts } = this.get(UsageLog);
    counts[name] = (counts[name] ?? 0) + 1;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

  • DynamicPlugin<UsageOptions> types init(), so it only accepts UsageOptions, or the inject/useFactory pair.
  • init({ ignore }) calls the constructor at once, when the module is imported. The factory form calls it when the server starts, once the app's providers exist.
  • plugins: [UsagePlugin] still works, and builds the plugin as init() without an argument does: the constructor gets {}. (Changed in 1.9.4: before, it got no argument, so a default parameter applied.)
  • init() without an argument builds the plugin with {}. Before 1.8.6 it threw Cannot read properties of undefined (reading 'providers').
  • One init() record on several apps gives each app its own instance since 1.8.6, as "No options, on two apps" shows. Before, they shared one, so state kept on this was shared too. Providers a plugin adds are a different matter: see Providers built from options.

Providers built from options

static dynamicProviders(options) adds providers that depend on the options, like a client configured with a base URL. init() calls it with the same options it passes to the constructor:

Open
import { DynamicPlugin, Plugin } from "@frontmcp/sdk";

export type LinkOptions = { baseUrl?: string };

export class TicketLinks {
  constructor(private readonly baseUrl: string) {}

  linkTo(id: string) {
    return `${this.baseUrl}/tickets/${id}`;
  }
}

export const TICKET_LINKS = Symbol.for("frontmcp.dev/reference/plugin/ticket-links");

@Plugin({ name: "links" })
export class LinksPlugin extends DynamicPlugin<LinkOptions> {
  static dynamicProviders({ baseUrl = "https://desk.example.com" }: LinkOptions) {
    return [
      { provide: TICKET_LINKS, name: "TicketLinks", inject: () => [] as const, useFactory: () => new TicketLinks(baseUrl) },
    ];
  }

  constructor(readonly options: LinkOptions) {
    super();
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The app's tool gets TicketLinks without exports. The billing app's doesn't, because the plugin is only on the help desk app: providers from dynamicProviders() and from init({ providers }) reach the app that installed the plugin, and no other. Changed in 1.9: before, they were shared with every app.

plugins: [LinksPlugin], without init(), calls dynamicProviders({}), as the last test shows, which is why the default is filled in there. Changed in 1.9.4: before, the class form never called dynamicProviders(), and this.get(TICKET_LINKS) failed.

Adding tools, resources, prompts and skills

Everything a plugin lists joins the app, and clients see it like the app's own. Open the Capabilities tab:

Open
import {
  DynamicPlugin,
  FlowCtxOf,
  Plugin,
  Prompt,
  PromptContext,
  Provider,
  Resource,
  ResourceContext,
  ToolHook,
  skill,
  type GetPromptResult,
} from "@frontmcp/sdk";

@Provider({ name: "UsageLog" })
export class UsageLog {
  counts: Record<string, number> = {};
}

@Resource({ name: "usage", uri: "desk://usage", mimeType: "application/json", description: "Calls per tool so far" })
export class UsageResource extends ResourceContext {
  async execute() {
    return { counts: this.get(UsageLog).counts };
  }
}

@Prompt({ name: "review_usage", description: "Ask for a review of which tools are used", arguments: [] })
export class ReviewUsage extends PromptContext {
  async execute(): Promise<GetPromptResult> {
    const counts = JSON.stringify(this.get(UsageLog).counts);
    return { messages: [{ role: "user", content: { type: "text", text: `Which of these tools look unused? ${counts}` } }] };
  }
}

export const readUsage = skill({
  name: "read-usage",
  description: "How to find out which help desk tools are used",
  instructions: "Read the desk://usage resource. Each key is a tool name and each value a number of calls.",
});

@Plugin({ name: "usage", providers: [UsageLog], resources: [UsageResource], prompts: [ReviewUsage], skills: [readUsage] })
export class UsagePlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async count(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.tool?.metadata.name;
    if (!name) return;
    const { counts } = this.get(UsageLog);
    counts[name] = (counts[name] ?? 0) + 1;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Registering a plugin on every app

A plugin in @FrontMcp({ plugins }) applies to every app: its hooks run for every app's tools, its tools are listed once, and every app's tools can get its exports.

Open
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp } from "./apps";
import { UsagePlugin } from "./usage.plugin";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp, BillingApp],
  plugins: [UsagePlugin],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Move plugins: [UsagePlugin] into HelpDeskApp and refund_invoice stops being counted: an app's plugin hooks tools/call for that app's tools only, unless the plugin sets scope: "server", as the last test does, or the hook sets appliesTo.

Enforcing an option of your own

A plugin can add an option to tools, like approval from the Approval plugin, and enforce it in a hook. Two things keep such an option from doing nothing where the plugin doesn't reach. enforcesMetadata names the option: a server where a tool sets it and no hook of the plugin covers that tool doesn't start. And a hook with appliesTo: "uncovered-apps" also runs for the entries of apps that have no instance of it, so one app's plugin covers the others:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk";

declare global {
  interface ExtendFrontMcpToolMetadata {
    /** The tool changes data, so it's refused during maintenance */
    writes?: boolean;
  }
}

type Options = { until?: string };

@Plugin({ name: "maintenance", enforcesMetadata: ["writes"] })
export class MaintenancePlugin extends DynamicPlugin<Options> {
  constructor(readonly options: Options = {}) {
    super();
  }

  // Also runs for the tools of apps that don't have this plugin
  @ToolHook.Will("execute", { appliesTo: "uncovered-apps" })
  async refuseWrites(ctx: FlowCtxOf<"tools:call-tool">) {
    const tool = ctx.state.tool?.metadata;
    if (tool?.writes && this.options.until) {
      throw new PublicMcpError(`${tool.name} is unavailable during maintenance, until ${this.options.until}.`);
    }
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Remove appliesTo and the server no longer starts: nothing covers refund_invoice. The error is UnenforcedMetadataError, code UNENFORCED_METADATA, and it names every entry and option left uncovered. A tool inside an @Agent is covered only by the agent's own plugins, as the last test shows. approval and featureFlag are checked the same way, for their plugins.

Hiding what a plugin refuses

A plugin that refuses some calls should leave those entries out of the lists too. List hooks see every app's entries, though, even on an app's plugin, while its call hooks only judge its own app. isEntryGatedBy(scope, entry, this), exported by @frontmcp/sdk, says whether this plugin instance's hooks run when scope serves entry ({ tool }, { resource }, { prompt } or { skill }), so a list hook can filter only what its own gate would refuse. Here each app has its own instance, and only the help desk is read-only:

Open
import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, PublicMcpError, ScopeEntry, ToolHook, isEntryGatedBy } from "@frontmcp/sdk";

type Options = { enabled: boolean };

@Plugin({ name: "read-only" })
export class ReadOnlyPlugin extends DynamicPlugin<Options> {
  constructor(readonly options: Options = { enabled: false }) {
    super();
  }

  @ToolHook.Will("execute")
  async refuse(ctx: FlowCtxOf<"tools:call-tool">) {
    const tool = ctx.state.tool?.metadata;
    if (this.options.enabled && !tool?.annotations?.readOnlyHint) throw new PublicMcpError(`${tool?.name} is unavailable: the help desk is read-only.`);
  }

  @ListToolsHook.Did("findTools")
  async hide(ctx: FlowCtxOf<"tools:list-tools">) {
    if (!this.options.enabled) return;
    const scope = this.get(ScopeEntry);
    const tools = ctx.state.tools ?? [];
    // Keep what this instance doesn't judge: another app's tools
    ctx.state.set("tools", tools.filter(({ tool }) => tool.metadata.annotations?.readOnlyHint || !isEntryGatedBy(scope, { tool }, this)));
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Without the isEntryGatedBy() check, the help desk's plugin hides refund_invoice too, although nothing stops it from running by name, as the last test shows. The Feature Flags plugin filters its lists this way since 1.8.4.

Bringing other plugins along

A plugin's plugins are registered with it. Their tools join the app and their hooks run for the app's tools, like the outer plugin's, but their exports reach the outer plugin, not the app:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "Clock" })
export class Clock {
  now() {
    return "2026-09-27T09:00:00Z";
  }
}

@Tool({ name: "server_time", description: "The server's current time", inputSchema: {} })
export class ServerTime extends ToolContext {
  async execute() {
    return { now: this.get(Clock).now() };
  }
}

// Brought along by AuditPlugin
@Plugin({ name: "clock", providers: [Clock], exports: [Clock], tools: [ServerTime] })
export class ClockPlugin extends DynamicPlugin<object> {}

@Provider({ name: "AuditTrail" })
export class AuditTrail {
  lines: string[] = [];
}

@Plugin({ name: "audit", providers: [AuditTrail], exports: [AuditTrail], plugins: [ClockPlugin] })
export class AuditPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    // Clock is ClockPlugin's export, so the outer plugin can get it
    this.get(AuditTrail).lines.push(`${this.get(Clock).now()} ${ctx.state.tool?.metadata.name}`);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

To give the app's tools a nested plugin's provider, register that plugin on the app as well, or on the server.

Putting a plugin on an agent

A plugin in @Agent({ plugins }) applies to the tools the agent's model calls: its hooks run for them, and its tools and exports join the agent's. It doesn't apply to invoke_<agent> itself, nor to the app's tools, even when the agent and the app share a tool:

Open
import { Agent, AgentContext, DynamicPlugin, Plugin, PublicMcpError, ToolHook, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { GetTicket, SetPriority } from "./ticket.tools";

@Plugin({ name: "freeze" })
export class FreezePlugin extends DynamicPlugin<object> {
  @ToolHook.Will("execute", { filter: (ctx) => ctx.state.tool?.metadata.name === "set_priority" })
  async refuse() {
    throw new PublicMcpError("Priorities are frozen until Monday.");
  }
}

@Agent({
  name: "triage",
  description: "Decide a new support ticket's priority. Pass the ticket's id.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket, SetPriority],
  llm: { adapter: model },
  plugins: [FreezePlugin],
})
export class Triage extends AgentContext {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Changed in 1.8.3: before, a plugin on an agent made tool calls fail with Unsupported hook owner kind: "agent". A tool inside an agent that sets an option like approval needs the enforcing plugin here, on the agent: see enforcing an option of your own.

The plugin's ResourceHook, PromptHook and list hooks also run when the agent's model reads the agent's own resources and prompts with read_resource, get_prompt and the list tools: those reads run their flows in the agent (Flows in one request shows one). New in 1.9.4, when agents got those tools.

Publishing a plugin

A plugin other teams can use is an npm package that exports the plugin's class. Keep it in its own folder, with the entry, src/index.ts, exporting everything an app may need: the class to put in plugins, the type of its options, and the provider an app's tools can get through exports. The app imports them from the package, as help-desk.app.ts imports them from ./index here:

Open
// The package's entry: what `import { … } from "help-desk-usage-plugin"` gets
export { UsagePlugin } from "./usage.plugin";
export type { UsagePluginOptions } from "./usage.plugin";
export { UsageLog } from "./usage-log";
export { UsageReport } from "./usage-report.tool";

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The package compiles to JavaScript with type declarations, with the decorator settings every FrontMCP project uses, and lists @frontmcp/sdk as a peer dependency:

package.json
{
  "name": "help-desk-usage-plugin",
  "version": "1.0.0",
  "description": "A FrontMCP plugin that counts calls to every tool and reports them with usage_report",
  "keywords": ["frontmcp", "frontmcp-plugin", "mcp"],
  "license": "MIT",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "@frontmcp/sdk": "^1.9.4"
  },
  "devDependencies": {
    "@frontmcp/sdk": "1.9.4",
    "reflect-metadata": "^0.2.2",
    "typescript": "^5.5.3"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "es2021",
    "module": "commonjs",
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
npm run build                  # dist/index.js, dist/index.d.ts and the rest
npm pack                       # help-desk-usage-plugin-1.0.0.tgz, to try in an app first
npm publish

In the app's project, npm install help-desk-usage-plugin, then import from it as help-desk.app.ts imports from ./index above.

  • peerDependencies: the app's own @frontmcp/sdk is the one the plugin uses, and npm checks the range. An app on an older SDK gets ERESOLVE unable to resolve dependency tree and Could not resolve dependency: peer @frontmcp/sdk@"^1.9.4" from help-desk-usage-plugin@1.0.0, before anything is installed. A plugin that lists the SDK in dependencies instead, at another version, gets a second copy under its own folder. The server still ran with two copies, but the app no longer type-checks: see the error. The range starts at the version you built and tested with.
  • devDependencies: the SDK again, to compile and test the plugin, with reflect-metadata and TypeScript.
  • keywords: frontmcp-plugin lets people find it with npm search keywords:frontmcp-plugin. Nothing in FrontMCP reads it.
  • files: only dist, the compiled code and its .d.ts files.

FrontMCP's own plugins, like @frontmcp/plugin-cache, list the SDK in dependencies at the exact version, because they're released with it. The package and the app were checked with FrontMCP 1.9.4 and a local npm registry (Verdaccio), not the public one: the app installed the package, listed usage_report, counted two get_ticket calls, and type-checked.


Troubleshooting

Several of these errors stop the server from starting. The Playground can't show those: an example that doesn't start has nothing to run.

Provider "…" is not available in one of the app's tools

The provider belongs to a plugin and isn't in its exports. Add it to exports (Sharing a provider with the app's tools). For a plugin nested in another plugin, exports reach the outer plugin only: register the inner plugin on the app too.

ContextExtensionNotAvailableError: … is not installed or '…' is not configured.

A tool read a context extension the plugin couldn't resolve. Either the tool's app doesn't have the plugin (the property exists on every context once any app installs it), or the extension's token isn't in the plugin's exports. The text is the extension's errorMessage, or the default, <plugin name> is not installed or '<property>' is not configured.

@App invalid metadata for "plugins": plugins items must be annotated with @Plugin()

Something in plugins isn't a plugin: a class without @Plugin, or a provider or tool listed in the wrong array. MyPlugin.init({ ... }) results are fine.

Invalid input: expected string, received undefined with "path": ["name"]

@Plugin({ ... }) has no name. It's the one required option.

Plugin "…" has scope='server' but is used in a standalone app

The plugin sets scope: "server" and is registered on a standalone: true app, which is served on its own and has no other apps to reach. Remove scope there. To apply a plugin to every app, list it in @FrontMcp({ plugins }), or set scope: "server" on an app that isn't standalone.

My plugin's hook doesn't run for another app's tools

A plugin in @App({ plugins }) hooks tools/call, resources/read and prompts/get for that app's entries only. Give it scope: "server" (since 1.9.1), register it on @FrontMcp instead (Registering a plugin on every app), or give the hook appliesTo: "uncovered-apps" so it also runs for apps that don't have the plugin (Enforcing an option of your own).

Unenforced metadata: Tool "…" declares '…'

The server didn't start, with an UnenforcedMetadataError: an entry sets an option that only a plugin enforces, like approval, featureFlag or a key in a plugin's enforcesMetadata, and no hook of that plugin reaches the entry. Install the plugin on the server, on the entry's app, or, for a tool inside an @Agent, on the agent. A plugin of your own can also cover other apps with appliesTo: "uncovered-apps". Or remove the option from the entry. See Enforcing an option of your own.

On an edge isolate, createFetchHandler() finds this when it's created, before the server is built, and judges each entry by the plugins that reach it, as the full check does: an agent's tool with the plugin only on its app is refused there too.

Changed in 1.8.3: before, such an option did nothing where its plugin didn't reach, so an approval: true tool on a server without the Approval plugin ran without approval.

DynamicPlugin.get() is not implemented

this.get() was called in the plugin's constructor. FrontMCP connects this.get() to the plugin's providers when the server starts, after the constructor has run. Call it in hooks, or build what you need from options with useFactory or dynamicProviders().

Type 'PluginReturn<…>' is not assignable to type 'PluginType'

TypeScript refuses a published plugin's init() in plugins, with TS2322 and a long list of types from two paths, node_modules/@frontmcp/sdk and node_modules/<plugin>/node_modules/@frontmcp/sdk. The plugin brought its own copy of the SDK, because it lists @frontmcp/sdk in dependencies at a version the app doesn't have. Move it to the plugin's peerDependencies and publish again (Publishing a plugin), or install the same SDK version in the app as the plugin's, so npm keeps one copy.

ERESOLVE unable to resolve dependency tree for a plugin's peer @frontmcp/sdk

The app's @frontmcp/sdk is outside the plugin's peerDependencies range: peer @frontmcp/sdk@"^1.9.4" from help-desk-usage-plugin@1.0.0 with Found: @frontmcp/sdk@1.9.3. Upgrade the app's FrontMCP packages to a version in the range, or use a release of the plugin built for yours.

TypeScript says Generic type 'DynamicPlugin<TOptions, TInput>' requires between 1 and 2 type arguments

DynamicPlugin has no default type argument. Write extends DynamicPlugin<object> for a plugin without options, or DynamicPlugin<MyOptions>. The same mistake also reports TS1238 (Unable to resolve signature of class decorator) on @Plugin.