Extending with Plugins

Intermediate

Some behaviour belongs to no single tool. Every call should land in an audit log, a server under maintenance should refuse changes, slow lookups should be cached. You could write that code into every tool, and every new tool would have to remember it. A plugin (@Plugin) packages it once: the providers it needs, the tools it adds, and hooks that run around every call. An app turns it on with one line.

You will learn

  • What a plugin can add to an app
  • How to write a plugin with a provider, a tool and a hook
  • How a plugin shares its providers with the app's tools
  • How to make a plugin configurable
  • Where to register a plugin, and what the official plugins do

Code every tool has to remember

The help desk keeps an audit log of every change, and an audit_log tool that shows it. Each tool that changes a ticket records itself:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { AuditLog } from "./audit-log";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.get(AuditLog).record("close_ticket", { id });
    return { id, status: "closed" };
  }
}

@Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket", inputSchema: { id: z.string() } })
export class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.get(AuditLog).record("reopen_ticket", { id });
    return { id, status: "open" };
  }
}

@Tool({
  name: "assign_ticket",
  description: "Assign a support ticket to an agent",
  inputSchema: { id: z.string(), agent: z.string() },
})
export class AssignTicket extends ToolContext {
  async execute({ id, agent }: { id: string; agent: string }) {
    // 🚩 Forgot to record
    return { id, agent };
  }
}

@Tool({ name: "audit_log", description: "Show every change made to tickets", inputSchema: {} })
export class ShowAuditLog extends ToolContext {
  async execute() {
    return { entries: [...this.get(AuditLog).entries] };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

assign_ticket forgot, so the Tests tab shows a log with a hole in it. Nothing warned about it, and nothing will warn about the next tool either. The tool name is typed by hand in each tool, so a copied tool can record the wrong name too. The audit log isn't part of any tool's job. It's something that should happen around all of them.

Writing a plugin

Here the audit log is a plugin. The tools don't mention it at all:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";
import { AuditLog } from "./audit-log";

@Tool({ name: "audit_log", description: "Show every tool call made so far", inputSchema: {} })
export class ShowAuditLog extends ToolContext {
  async execute() {
    return { entries: [...this.get(AuditLog).entries] };
  }
}

@Plugin({
  name: "audit",
  description: "Records every tool call in an audit log",
  providers: [AuditLog],
  tools: [ShowAuditLog],
})
export class AuditPlugin extends DynamicPlugin<object> {
  // Runs after every tool's execute() succeeds
  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    const { tool, toolContext } = ctx.state;
    if (!tool || !toolContext) return;
    // input is typed unknown here, because the hook runs for every tool
    this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record<string, unknown>);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Open the Capabilities tab: the app lists four tools, and one of them came from the plugin. The Tests tab shows every call in the log, with the name and input FrontMCP had, not ones typed by hand.

A plugin is a class with @Plugin, and its metadata says what it adds:

OptionWhat it adds
nameRequired. Names the plugin in logs and errors.
providersProviders for the plugin's own use: its hooks and its tools.
exportsWhich of those providers the app's own tools may use too. Below.
tools, resources, promptsCapabilities added to the app, listed to clients like the app's own.
contextExtensionsProperties added to every tool's this, like this.auditLog. Below.
pluginsOther plugins this one brings along.

The class body holds hooks: methods marked with a decorator like @ToolHook.Did("execute"), which FrontMCP calls at a point in every tool call. Did("execute") runs after a tool's execute() returns. Its argument, ctx, describes the call in progress: ctx.state.tool is the tool being called, and ctx.state.toolContext is the running tool, with the validated input. The next lesson covers every point you can hook into, and what a hook can change.

Extending DynamicPlugin gives the class a typed this.get(), which reaches the plugin's providers. Its type parameter is for options, which come later. object means none.

Finally, the app lists the plugin in plugins. That's the one line: remove it, and the tool, the provider and the hook are all gone.

Deep diveHooks on tools and providersShow detailsHide details

Hook methods also run on two other kinds of class. On a @Tool class, a hook runs only for that tool's calls. On a provider listed in an app's providers, it runs for every tool in that app. Here the app's LegalHold provider refuses any call about ticket T-7, and close_ticket refuses to close T-2 twice:

Open
import { App, FlowCtxOf, Provider, PublicMcpError, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk";

type CallCtx = FlowCtxOf<"tools:call-tool">;
const idOf = (ctx: CallCtx) => (ctx.state.toolContext?.input as { id?: string } | undefined)?.id;

@Provider({ name: "LegalHold" })
export class LegalHold {
  // On an app's provider: runs for every tool in the app
  @ToolHook.Will("execute")
  async refuseHeldTickets(ctx: CallCtx) {
    if (idOf(ctx) === "T-7") throw new PublicMcpError("Ticket T-7 is on legal hold.");
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  // On a tool class: runs only for close_ticket
  @ToolHook.Will("execute")
  async refuseClosedTickets(ctx: CallCtx) {
    if (idOf(ctx) === "T-2") throw new PublicMcpError("Ticket T-2 is already closed.");
  }

  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: id === "T-2" ? "closed" : "open" };
  }
}

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Both are fine for code that belongs to one tool or one app. A plugin is still the way to share hooks between apps and servers, bring along the providers and tools they need, and take options.

Sharing a plugin's providers with the app

A plugin's providers are private to it by default. When the app's own ticket_history tool asks for the plugin's AuditLog, it only finds it because the plugin lists it in exports:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";
import { AuditLog } from "./audit-log";

@Plugin({
  name: "audit",
  description: "Records every tool call in an audit log",
  providers: [AuditLog],
  exports: [AuditLog], // ✅ the app's tools can this.get(AuditLog) too
})
export class AuditPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    const { tool, toolContext } = ctx.state;
    if (!tool || !toolContext) return;
    this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record<string, unknown>);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Take exports out and ticket_history fails with Provider "AuditLog" is not available, even though the plugin's hook keeps recording. Exporting is a choice: export what the app is meant to use, and keep the plugin's internals private. The second challenge below starts without it.

Deep diveAdding this.auditLog to every toolShow detailsHide details

contextExtensions goes one step further: it adds a property to every tool's this, so tools write this.auditLog instead of this.get(AuditLog). It's how the official plugins add helpers like this.remember. Each extension names the property and the key of an exported provider:

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

export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

  record(tool: string, input: Record<string, unknown>) {
    this.entries.push({ tool, input });
  }
}

export const AUDIT_LOG = Symbol.for("help-desk.audit-log");

const auditLogProvider = { provide: AUDIT_LOG, name: "AuditLog", inject: () => [] as const, useFactory: () => new AuditLog() };

// Tells TypeScript that tools have this.auditLog
declare module "@frontmcp/sdk" {
  interface ExecutionContextBase {
    readonly auditLog: AuditLog;
  }
}

@Plugin({
  name: "audit",
  providers: [auditLogProvider],
  exports: [auditLogProvider],
  contextExtensions: [{ property: "auditLog", token: AUDIT_LOG }],
})
export class AuditPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    const { tool, toolContext } = ctx.state;
    if (!tool || !toolContext) return;
    this.get<AuditLog>(AUDIT_LOG).record(tool.metadata.name, toolContext.input as Record<string, unknown>);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

FrontMCP installs an extension once per process, on the class every tool context extends, and the first plugin to claim a property name keeps it. That's why this example uses a Symbol.for() key rather than the AuditLog class: the Playground builds your classes afresh on every run but keeps one FrontMCP, and a key made with Symbol.for() is the same on every run. In a project, where your files load once, a class works as the key too.

Configuring a plugin

Logging every read fills the audit log with noise. A plugin takes options through its constructor, and init() passes them in. Here skipReadOnly leaves out tools marked readOnlyHint:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";
import { AuditLog } from "./audit-log";

export type AuditOptions = { skipReadOnly?: boolean };

@Tool({ name: "audit_log", description: "Show every change made so far", inputSchema: {}, annotations: { readOnlyHint: true } })
export class ShowAuditLog extends ToolContext {
  async execute() {
    return { entries: [...this.get(AuditLog).entries] };
  }
}

@Plugin({ name: "audit", providers: [AuditLog], tools: [ShowAuditLog] })
export class AuditPlugin extends DynamicPlugin<AuditOptions> {
  constructor(private readonly options: AuditOptions = {}) {
    super();
  }

  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    const { tool, toolContext } = ctx.state;
    if (!tool || !toolContext) return;
    if (this.options.skipReadOnly && tool.metadata.annotations?.readOnlyHint) return;
    this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record<string, unknown>);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

  • DynamicPlugin<AuditOptions> declares the options type, so init() only accepts AuditOptions.
  • AuditPlugin.init({ skipReadOnly: true }) builds the plugin with those options. It goes in plugins where the class went.
  • plugins: [AuditPlugin] still works, and calls the constructor with no options, so give every option a default.

The plugin now reads annotations, which the tools already declare for clients. A plugin can also read keys of its own from @Tool({ ... }): unknown keys are kept in tool.metadata. The official Cache plugin works that way, with cache: true on each tool it should cache.

Registering a plugin on the server

A plugin in an app's plugins belongs to that app: its tools join the app, its exported providers are visible to the app's tools only, and its hooks run for the app's tools only. To give every app the audit log, register the plugin once on the server instead:

Open
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp, TicketsApp } from "./apps";
import { AuditPlugin } from "./audit.plugin";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
  plugins: [AuditPlugin],
})
export default class HelpDeskServer {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

audit_log is listed once, the billing app's refund_invoice can this.get(AuditLog), and the hook records calls to both apps. Server-wide concerns like auditing, tracing and policy belong here.

Compare the same plugin registered on the tickets app only. Its tool is listed, but the billing app can't reach its AuditLog, and its hook doesn't see billing calls:

Open
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp, TicketsApp } from "./apps";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Register a plugin on the app whose tools it's about, and on the server only when every app needs it.

Official plugins

The FrontMCP team publishes plugins for common needs, each as its own npm package. They're installed and registered like the plugin above, with plugins: [SomePlugin.init({ ... })]:

PackageWhat it doesLesson
@frontmcp/plugin-cacheCaches tool results, keyed by tool, input and caller, in memory or Redis. Tools opt in with cache in @Tool.Caching Results
@frontmcp/plugin-rememberEncrypted memory that tools read and write through this.remember, per session, user or tool.Remembering Across Calls
@frontmcp/plugin-approvalRequires approval before sensitive tools run.Asking for Approval
@frontmcp/plugin-feature-flagsHides and blocks tools, resources and prompts behind feature flags from Split.io, LaunchDarkly, Unleash or your own source.Turning Features On and Off
@frontmcp/plugin-codecallPuts a large set of tools behind a few meta-tools, so the model searches for tools and runs short scripts that call them.Letting the Model Write Code with CodeCall
@frontmcp/plugin-skilled-openapiServes a REST API described by OpenAPI as a small set of skills instead of one tool per endpoint.An API too big to list

@frontmcp/plugins bundles four of them, Cache, CodeCall, Remember and a dashboard; install the others on their own. The Built-in Plugins and Adapters chapter teaches them one lesson at a time, and Plugins covers each one, with examples that run on this site. For example, with the Cache plugin:

src/tickets.app.ts
import { App } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetTicket, SearchTickets } from "./tools";

@App({
  id: "tickets",
  name: "Tickets",
  tools: [GetTicket, SearchTickets],
  plugins: [CachePlugin.init({ type: "memory" })],
})
export class TicketsApp {}

Each plugin's page lists its options, and which tools it applies to. Before writing a plugin of your own, check whether one of these already does the job.

Recap

  • A plugin (@Plugin) packages behaviour that belongs around every tool rather than in one: providers, tools, resources, prompts, hooks and context extensions.
  • Hooks are methods on the plugin class, like @ToolHook.Did("execute"). The same hooks also run on a tool class, for that tool, and on an app's provider, for that app.
  • A plugin's providers are private to it. List them in exports for the app's tools to use them, or add a this. property with contextExtensions.
  • Extend DynamicPlugin<Options> for a typed this.get() and options, and register a configured plugin with MyPlugin.init({ ... }).
  • Register a plugin on an app for that app's tools, or on @FrontMcp for every app's.
  • Every @Plugin option, and what each place you register a plugin affects, is in the @Plugin reference.

Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press Check.

Challenge 1 of 3

Turn the plugin on

The audit plugin is written, but nothing is recorded and clients can't see audit_log. Register the plugin on the help desk app.

Open
import { App } from "@frontmcp/sdk";
import { AssignTicket, CloseTicket } from "./tools";

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.