# Extending with Plugins

> What a FrontMCP plugin is, how to write one that adds providers, tools and hooks to an app, how to configure and register it, and what the official plugins do.

Source: https://frontmcp.dev/learn/extending-with-plugins

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:

```ts tools.ts active
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] };
  }
}
```

```ts audit-log.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { AssignTicket, CloseTicket, ReopenTicket, ShowAuditLog } from "./tools";
import { AuditLog } from "./audit-log";

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

```ts audit.test.ts
import { test, expect } from "@frontmcp/testing";

test("assign_ticket never reaches the log", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("assign_ticket", { id: "T-1", agent: "nour" });
  const tools = (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool);
  expect(tools).toEqual(["close_ticket"]);
});
```

`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:

```ts audit.plugin.ts active
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>);
  }
}
```

```ts audit-log.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    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 }) {
    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 }) {
    return { id, agent };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { AssignTicket, CloseTicket, ReopenTicket } from "./tools";
import { AuditPlugin } from "./audit.plugin";

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

```ts audit.test.ts
import { test, expect } from "@frontmcp/testing";

test("the plugin adds audit_log to the app's tools", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("audit_log");
});

test("every call is recorded, with its input", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("assign_ticket", { id: "T-1", agent: "nour" });
  expect((await mcp.tools.call("audit_log", {})).json().entries).toEqual([
    { tool: "close_ticket", input: { id: "T-1" } },
    { tool: "assign_ticket", input: { id: "T-1", agent: "nour" } },
  ]);
});
```

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:

| Option | What it adds |
| --- | --- |
| `name` | **Required.** Names the plugin in logs and errors. |
| `providers` | Providers for the plugin's own use: its hooks and its tools. |
| `exports` | Which of those providers the app's own tools may use too. [Below.](#sharing-a-plugins-providers-with-the-app) |
| `tools`, `resources`, `prompts` | Capabilities added to the app, listed to clients like the app's own. |
| `contextExtensions` | Properties added to every tool's `this`, like `this.auditLog`. [Below.](#sharing-a-plugins-providers-with-the-app) |
| `plugins` | Other 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](https://frontmcp.dev/learn/hooking-into-calls) 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](#configuring-a-plugin). `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 dive: Hooks on tools and providers**
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:

```ts help-desk.app.ts active
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 {}
```

```ts hooks.test.ts
import { test, expect } from "@frontmcp/testing";

test("the provider's hook runs for every tool in the app", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: "T-7" })).toHaveTextContent("legal hold");
  expect(await mcp.tools.call("get_ticket", { id: "T-7" })).toHaveTextContent("legal hold");
});

test("the tool's own hook runs only for that tool", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: "T-2" })).toHaveTextContent("already closed");
  expect(await mcp.tools.call("get_ticket", { id: "T-2" })).toBeSuccessful();
});
```

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`**:

```ts audit.plugin.ts active
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>);
  }
}
```

```ts tools.ts
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 }) {
    return { id, status: "closed" };
  }
}

@Tool({
  name: "ticket_history",
  description: "List the tool calls that touched one ticket, oldest first",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class TicketHistory extends ToolContext {
  async execute({ id }: { id: string }) {
    const entries = this.get(AuditLog).entries.filter((e) => e.input.id === id);
    return { id, history: entries.map((e) => e.tool) };
  }
}
```

```ts audit-log.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, TicketHistory } from "./tools";
import { AuditPlugin } from "./audit.plugin";

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

```ts history.test.ts
import { test, expect } from "@frontmcp/testing";

test("the app's tool reads the plugin's log", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("close_ticket", { id: "T-2" });
  expect((await mcp.tools.call("ticket_history", { id: "T-1" })).json()).toEqual({ id: "T-1", history: ["close_ticket"] });
});
```

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 dive: Adding this.auditLog to every tool**
`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:

```ts audit.plugin.ts active
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>);
  }
}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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

@Tool({
  name: "ticket_history",
  description: "List the tool calls that touched one ticket, oldest first",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class TicketHistory extends ToolContext {
  async execute({ id }: { id: string }) {
    const entries = this.auditLog.entries.filter((e) => e.input.id === id);
    return { id, history: entries.map((e) => e.tool) };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, TicketHistory } from "./tools";
import { AuditPlugin } from "./audit.plugin";

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

```ts history.test.ts
import { test, expect } from "@frontmcp/testing";

test("tools read the log through this.auditLog", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  expect((await mcp.tools.call("ticket_history", { id: "T-1" })).json()).toEqual({ id: "T-1", history: ["close_ticket"] });
});
```

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`:

```ts audit.plugin.ts active
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>);
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetTicket } from "./tools";
import { AuditPlugin } from "./audit.plugin";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, CloseTicket],
  plugins: [AuditPlugin.init({ skipReadOnly: true })],
})
export class HelpDeskApp {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

```ts audit-log.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

```ts options.test.ts
import { test, expect } from "@frontmcp/testing";

test("reads are left out, changes are recorded", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("audit_log", {});
  expect((await mcp.tools.call("audit_log", {})).json().entries).toEqual([{ tool: "close_ticket", input: { id: "T-1" } }]);
});
```

- **`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:

```ts main.ts active
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 {}
```

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { AuditLog } from "./audit.plugin";

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

@Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } })
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const earlier = this.get(AuditLog).entries.filter((e) => e.input.id === id).length;
    return { id, status: "refunded", earlierCalls: earlier };
  }
}

@App({ id: "tickets", name: "Tickets", tools: [CloseTicket] })
export class TicketsApp {}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
export class BillingApp {}
```

```ts audit.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

@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", providers: [AuditLog], exports: [AuditLog], tools: [ShowAuditLog] })
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>);
  }
}
```

```ts server.test.ts
import { test, expect } from "@frontmcp/testing";

test("calls to both apps are recorded", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("refund_invoice", { id: "INV-7" });
  const tools = (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool);
  expect(tools).toEqual(["close_ticket", "refund_invoice"]);
});

test("every app's tools can use the exported AuditLog", async ({ mcp }) => {
  expect(await mcp.tools.call("refund_invoice", { id: "INV-7" })).toBeSuccessful();
});
```

`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:

```ts main.ts active
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 {}
```

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { AuditLog, AuditPlugin } from "./audit.plugin";

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

@Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } })
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "refunded", canSeeAuditLog: this.tryGet(AuditLog) !== undefined };
  }
}

@App({ id: "tickets", name: "Tickets", tools: [CloseTicket], plugins: [AuditPlugin] })
export class TicketsApp {}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
export class BillingApp {}
```

```ts audit.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

@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", providers: [AuditLog], exports: [AuditLog], tools: [ShowAuditLog] })
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>);
  }
}
```

```ts scope.test.ts
import { test, expect } from "@frontmcp/testing";

test("the billing app can't use the tickets app's AuditLog", async ({ mcp }) => {
  expect((await mcp.tools.call("refund_invoice", { id: "INV-7" })).json().canSeeAuditLog).toBe(false);
});

test("the tickets app's hook records only tickets calls", async ({ mcp }) => {
  await mcp.tools.call("refund_invoice", { id: "INV-7" });
  await mcp.tools.call("close_ticket", { id: "T-1" });
  const tools = (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool);
  expect(tools).toEqual(["close_ticket"]);
});
```

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({ ... })]`:

| Package | What it does | Lesson |
| --- | --- | --- |
| [`@frontmcp/plugin-cache`](https://frontmcp.dev/reference/plugins/cache) | Caches tool results, keyed by tool, input and caller, in memory or Redis. Tools opt in with `cache` in `@Tool`. | [Caching Results](https://frontmcp.dev/learn/caching-results) |
| [`@frontmcp/plugin-remember`](https://frontmcp.dev/reference/plugins/remember) | Encrypted memory that tools read and write through `this.remember`, per session, user or tool. | [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls) |
| [`@frontmcp/plugin-approval`](https://frontmcp.dev/reference/plugins/approval) | Requires approval before sensitive tools run. | [Asking for Approval](https://frontmcp.dev/learn/asking-for-approval) |
| [`@frontmcp/plugin-feature-flags`](https://frontmcp.dev/reference/plugins/feature-flags) | Hides and blocks tools, resources and prompts behind feature flags from Split.io, LaunchDarkly, Unleash or your own source. | [Turning Features On and Off](https://frontmcp.dev/learn/turning-features-on-and-off) |
| [`@frontmcp/plugin-codecall`](https://frontmcp.dev/reference/plugins/codecall) | Puts 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](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) |
| [`@frontmcp/plugin-skilled-openapi`](https://frontmcp.dev/reference/plugins/skilled-openapi) | Serves a REST API described by OpenAPI as a small set of skills instead of one tool per endpoint. | [An API too big to list](https://frontmcp.dev/learn/wrapping-an-openapi-service#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](https://frontmcp.dev/learn/built-in-plugins-and-adapters) chapter teaches them one lesson at a time, and [Plugins](https://frontmcp.dev/reference/plugins) covers each one, with examples that run on this site. For example, with the Cache plugin:

```ts 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](https://frontmcp.dev/reference/plugins) 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](https://frontmcp.dev/reference/sdk/plugin).

## Try some challenges

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

### Challenge: 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.

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { AssignTicket, CloseTicket } from "./tools";

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

```ts help-desk.app.ts solution
import { App } from "@frontmcp/sdk";
import { AssignTicket, CloseTicket } from "./tools";
import { AuditPlugin } from "./audit.plugin";

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

```ts audit.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

@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", providers: [AuditLog], tools: [ShowAuditLog] })
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>);
  }
}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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

@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 }) {
    return { id, agent };
  }
}
```

```ts audit.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`audit_log` is listed", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("audit_log");
});

test("the app's own tools are still listed", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("close_ticket");
  expect(tools).toContainTool("assign_ticket");
});

test("calls are recorded in order", async ({ mcp }) => {
  await mcp.tools.call("assign_ticket", { id: "T-1", agent: "nour" });
  await mcp.tools.call("close_ticket", { id: "T-1" });
  const log = await mcp.tools.call("audit_log", {});
  expect(log).toBeSuccessful();
  expect(log.json().entries.map((e: { tool: string }) => e.tool)).toEqual(["assign_ticket", "close_ticket"]);
});
```

**Hint:**
Plugins go in their own list on `@App`, next to `tools`.

**Solution:**
`plugins: [AuditPlugin]` turns on everything the plugin declares: the `audit_log` tool joins the app, the `AuditLog` provider is created, and the hook starts recording. Nothing in the tools had to change.

### Challenge: Share the audit log with the app
`ticket_history` belongs to the app and reads the plugin's `AuditLog`, but every call fails with `Provider "AuditLog" is not available`. Let the app's tools use the audit log, without moving it out of the plugin.

```ts audit.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

@Plugin({ name: "audit", providers: [AuditLog] })
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>);
  }
}
```

```ts audit.plugin.ts solution
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

@Plugin({ name: "audit", providers: [AuditLog], exports: [AuditLog] })
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>);
  }
}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { AuditLog } from "./audit.plugin";

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

@Tool({
  name: "ticket_history",
  description: "List the tool calls that touched one ticket, oldest first",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class TicketHistory extends ToolContext {
  async execute({ id }: { id: string }) {
    const entries = this.get(AuditLog).entries.filter((e) => e.input.id === id);
    return { id, history: entries.map((e) => e.tool) };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, TicketHistory } from "./tools";
import { AuditPlugin } from "./audit.plugin";

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

```ts history.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`ticket_history` works", async ({ mcp }) => {
  expect(await mcp.tools.call("ticket_history", { id: "T-9" })).toBeSuccessful();
});

test("it sees what the plugin recorded", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("close_ticket", { id: "T-2" });
  const result = await mcp.tools.call("ticket_history", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ id: "T-1", history: ["close_ticket"] });
});
```

**Hint:**
Registering `AuditLog` in the app as well would give the app a second, empty log. Look for the plugin option that shares a provider.

**Solution:**
`exports: [AuditLog]` makes the plugin's own instance visible to the app's tools, so `ticket_history` reads the same log the hook writes to. Adding `AuditLog` to the app's `providers` instead would have made the error go away, but the app would get its own empty copy.

### Challenge: Make the plugin skip reads
Every `get_ticket` call ends up in the audit log. Give the plugin a `skipReadOnly` option that leaves out tools marked `readOnlyHint`, and turn it on for the app.

```ts audit.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

@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<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>);
  }
}
```

```ts audit.plugin.ts solution
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "AuditLog" })
export class AuditLog {
  entries: { tool: string; input: Record<string, unknown> }[] = [];

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

@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] };
  }
}

export type AuditOptions = { skipReadOnly?: boolean };

@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>);
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetTicket } from "./tools";
import { AuditPlugin } from "./audit.plugin";

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

```ts help-desk.app.ts solution
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetTicket } from "./tools";
import { AuditPlugin } from "./audit.plugin";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, CloseTicket],
  plugins: [AuditPlugin.init({ skipReadOnly: true })],
})
export class HelpDeskApp {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

```ts options.test.ts hidden
import { test, expect } from "@frontmcp/testing";

const logged = async (mcp: any) =>
  (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool);

test("`get_ticket` isn't recorded", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(await logged(mcp)).not.toContain("get_ticket");
});

test("`close_ticket` still is", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(await logged(mcp)).toEqual(["close_ticket"]);
});
```

**Hint:**
Take the options in the constructor, with a default so `plugins: [AuditPlugin]` still works, and change `DynamicPlugin<object>` to your options type. Then register it with `AuditPlugin.init({ ... })`.

**Solution:**
`init({ skipReadOnly: true })` creates the plugin with those options, and the hook returns early for tools whose `annotations` say they're read-only. The log also stops recording `audit_log` itself, since it's marked read-only too.
