# @Plugin

> Package providers, tools, resources, prompts, skills and hooks into a plugin that an app or a whole server turns on with one line.

Source: https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/learn/extending-with-plugins) builds one step by step.

```ts
@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`.

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

```ts 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.](#usage)

#### Options

Required:

| Option | Type | Description |
| --- | --- | --- |
| `name` | `string` | Names the plugin in logs and error messages, like the default message of a [context extension](#contextextensions). |

Optional:

| Option | Type | Description |
| --- | --- | --- |
| `providers` | `ProviderType[]` | [Providers](https://frontmcp.dev/reference/sdk/provider) 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`. |
| `exports` | `ProviderType[]` | 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](#sharing-a-provider-with-the-apps-tools). |
| `tools`, `resources`, `prompts`, `skills` | as on [`@App`](https://frontmcp.dev/reference/sdk/app) | Added 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](#adding-tools-resources-prompts-and-skills). |
| `contextExtensions` | `ContextExtension[]` | Properties added to `this` in every tool, resource and prompt, like `this.usageLog`. See [`contextExtensions`](#contextextensions). |
| `plugins` | `PluginType[]` | Other plugins this one brings along. See [bringing other plugins along](#bringing-other-plugins-along). |
| `adapters` | `AdapterType[]` | Adapters the plugin brings along, which generate tools from another source, such as an OpenAPI description. See the [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi). |
| `description` | `string` | For people reading the code. Clients never see it. |
| `enforcesMetadata` | `string[]` | 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](#enforcing-an-option-of-your-own). |
| `dynamicSkills` | `boolean` | The plugin adds skills after the server starts, as [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/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`](#scope). |
| `id` | `string` | Accepted 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](https://frontmcp.dev/reference/sdk/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()`.

| Member | Description |
| --- | --- |
| `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](#taking-options). |
| `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](#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](#taking-options). 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 in | Its tools, resources, prompts, skills | Its `exports` | Its hooks on `tools/call`, `resources/read`, `prompts/get` and [job attempts](https://frontmcp.dev/reference/sdk/hooks#hooking-a-jobs-attempts) | Its hooks on list requests and `HttpHook` |
| --- | --- | --- | --- | --- |
| `@App({ plugins })` | Added to that app | That app's tools | Only 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"`](#scope) | Every request, whichever app it's about |
| `@FrontMcp({ plugins })` | Added once, for the server | Every app's tools | Every app's entries | Every request |
| `@Plugin({ plugins })` | Added to the app the outer plugin is on | The outer plugin only | As for the outer plugin | Every request |
| `@Agent({ plugins })` | Its tools join the agent's, for the agent's model only | The agent's tools | Only 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"`](https://frontmcp.dev/reference/sdk/hooks#options) 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](#enforcing-an-option-of-your-own). [Putting a plugin on an agent](#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)`.

| Field | Type | Description |
| --- | --- | --- |
| `property` | `string` | **Required.** The property name, like `"usageLog"`. |
| `token` | `Token` | **Required.** The provider to return. It must be in the plugin's `exports`, or reading the property fails even in the plugin's own app. |
| `errorMessage` | `string` | The 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](#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"`](#enforcing-an-option-of-your-own) 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](#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](#typescript-says-generic-type-dynamicplugintoptions-tinput-requires-between-1-and-2-type-arguments)).
- `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.

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

```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() } })
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 help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetTicket } from "./tools";
import { UsagePlugin } from "./usage.plugin";

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

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

test("the plugin's tool is listed with the app's", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("usage_report");
  expect(tools).toContainTool("get_ticket");
});

test("every call is counted, including calls to the plugin's own tool", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  await mcp.tools.call("get_ticket", { id: "T-2" });
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("usage_report", {});
  expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({
    get_ticket: 2,
    close_ticket: 1,
    usage_report: 1,
  });
});
```

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:

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

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

@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, title: "Cannot log in", status: "open" };
  }
}

@Tool({ name: "busiest_tool", description: "Which tool has been called most", inputSchema: {} })
export class BusiestTool extends ToolContext {
  async execute() {
    const counts = Object.entries(this.get(UsageLog).counts).sort((a, b) => b[1] - a[1]);
    return {
      busiest: counts[0]?.[0] ?? null,
      canSeeSettings: this.tryGet(UsageSettings) !== undefined,
    };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { BusiestTool, GetTicket } from "./tools";
import { UsagePlugin } from "./usage.plugin";

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

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

test("the app's tool reads the plugin's exported UsageLog", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect((await mcp.tools.call("busiest_tool", {})).json().busiest).toBe("get_ticket");
});

test("a provider that isn't exported stays private", async ({ mcp }) => {
  expect((await mcp.tools.call("busiest_tool", {})).json().canSeeSettings).toBe(false);
});
```

`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](https://frontmcp.dev/reference/plugins/remember) does with `this.remember`. The token must be in `exports`, and a `declare module` block tells TypeScript about the property:

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

```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() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

@Tool({ name: "busiest_tool", description: "Which tool has been called most", inputSchema: {} })
export class BusiestTool extends ToolContext {
  async execute() {
    const counts = Object.entries(this.usageLog.counts).sort((a, b) => b[1] - a[1]);
    return { busiest: counts[0]?.[0] ?? null };
  }
}

@Tool({ name: "invoice_usage", description: "Tool usage, seen from the billing app", inputSchema: {} })
export class InvoiceUsage extends ToolContext {
  async execute() {
    return { counts: this.usageLog.counts };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { BusiestTool, GetTicket, InvoiceUsage } from "./tools";
import { UsagePlugin } from "./usage.plugin";

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

// No UsagePlugin here
@App({ id: "billing", name: "Billing", tools: [InvoiceUsage] })
export class BillingApp {}

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

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

test("tools read the log through this.usageLog", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect((await mcp.tools.call("busiest_tool", {})).json().busiest).toBe("get_ticket");
});

test("in an app without the plugin, this.usageLog fails with errorMessage", async ({ mcp }) => {
  const result = await mcp.tools.call("invoice_usage", {});
  expect(result).toBeError();
  expect(result).toHaveTextContent("this.usageLog needs UsagePlugin in this app's plugins.");
});
```

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:

<Examples title="Passing options">

#### Example: An options object
```ts usage.plugin.ts active
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;
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { UsagePlugin } from "./usage.plugin";

@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, title: "Cannot log in", status: "open" };
  }
}

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

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

test("tools in `ignore` aren't counted", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  await mcp.tools.call("usage_report", {});
  expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({ get_ticket: 1 });
});
```

#### Example: Options from a provider
`useFactory` builds the options when the server starts, from the providers `inject` returns. Here the ignore list comes from the app's `DeskSettings`:

```ts help-desk.app.ts active
import { App, Provider, Tool, ToolContext, z } from "@frontmcp/sdk";
import { UsagePlugin, type UsageOptions } from "./usage.plugin";

@Provider({ name: "DeskSettings" })
export class DeskSettings {
  internalTools = ["usage_report"];
}

@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, title: "Cannot log in", status: "open" };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket],
  providers: [DeskSettings],
  plugins: [
    UsagePlugin.init({
      inject: () => [DeskSettings] as const,
      useFactory: (settings: DeskSettings): UsageOptions => ({ ignore: settings.internalTools }),
    }),
  ],
})
export class HelpDeskApp {}
```

```ts usage.plugin.ts
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;
  }
}
```

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

test("the ignore list comes from DeskSettings", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  await mcp.tools.call("usage_report", {});
  expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({ get_ticket: 1 });
});
```

#### Example: No options, on two apps
`init()` needs no argument: the constructor gets `{}`. When one record is on several apps, the first uses the instance `init()` built, and each of the others builds one of its own. Here `CountCalls` keeps a count on `this`:

```ts count-calls.plugin.ts active
import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";

export const instances: CountCalls[] = [];

@Plugin({ name: "count-calls" })
export class CountCalls extends DynamicPlugin<object> {
  calls = 0;

  constructor() {
    super();
    instances.push(this);
  }

  @ToolHook.Did("execute")
  async count() {
    this.calls += 1;
  }
}
```

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CountCalls } from "./count-calls.plugin";

@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: "open" };
  }
}

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

const countCalls = CountCalls.init();

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

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

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp } from "./apps";

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

```ts no-options.test.ts
import { test, expect } from "@frontmcp/testing";
import { instances } from "./count-calls.plugin";

test("each app counts on an instance of its own", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  await mcp.tools.call("get_ticket", { id: "T-2" });
  await mcp.tools.call("refund_invoice", { id: "INV-7" });
  expect(instances.map((instance) => instance.calls).sort()).toEqual([1, 2]);
});
```

#### Example: An option named like a list
An option may share its name with one of `@Plugin`'s lists: `tools`, `resources`, `prompts`, `skills`, `adapters`, `plugins` or `exports`. When it isn't an array, it stays an option, and the plugin gets it in its constructor. Options named like `@Plugin`'s other fields, `name`, `id`, `description` and `scope`, are always the plugin's: `init({ scope: "server" })` doesn't change where the plugin's hooks run. (Changed in 1.9.2: before, those four renamed the plugin or changed its `scope`.) Here `tools` is an object, `{ enabled, prefix }`, and `static dynamicTools(options)` turns it into the tools the plugin adds:

```ts notes.plugin.ts active
import { DynamicPlugin, Plugin, Tool, ToolContext, z } from "@frontmcp/sdk";

export type NotesOptions = { tools?: { enabled?: boolean; prefix?: string } };

const notes: string[] = [];

function noteTools(prefix: string) {
  @Tool({ name: `${prefix}add_note`, description: "Keep a note for later", inputSchema: { text: z.string() } })
  class AddNote extends ToolContext {
    async execute({ text }: { text: string }) {
      notes.push(text);
      return { count: notes.length };
    }
  }

  @Tool({ name: `${prefix}list_notes`, description: "List the notes kept so far", inputSchema: {} })
  class ListNotes extends ToolContext {
    async execute() {
      return { notes: [...notes] };
    }
  }

  return [AddNote, ListNotes];
}

@Plugin({ name: "notes", description: "Keeps notes for later" })
export class NotesPlugin extends DynamicPlugin<NotesOptions> {
  constructor(readonly options: NotesOptions = {}) {
    super();
  }

  static dynamicTools(options: NotesOptions) {
    return options.tools?.enabled ? noteTools(options.tools.prefix ?? "") : [];
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { NotesPlugin } from "./notes.plugin";

@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, title: "Cannot log in", status: "open" };
  }
}

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

```ts notes.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { NotesPlugin } from "./notes.plugin";

test("the plugin's tools are listed next to the app's, under the prefix", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((tool) => tool.name).sort()).toEqual(["get_ticket", "memo_add_note", "memo_list_notes"]);
});

test("they run like any tool", async ({ mcp }) => {
  await mcp.tools.call("memo_add_note", { text: "Called the customer back." });
  expect((await mcp.tools.call("memo_list_notes", {})).json().notes).toContain("Called the customer back.");
});

test("`enabled: false` adds none, and `useFactory`'s options reach `dynamicTools()` too", async () => {
  const serverWith = async (plugin: unknown) => {
    @App({ id: "desk", name: "Desk", plugins: [plugin as never] })
    class Desk {}
    return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  };
  const disabled = await serverWith(NotesPlugin.init({ tools: { enabled: false } }));
  const factory = await serverWith(NotesPlugin.init({ inject: () => [] as const, useFactory: () => ({ tools: { enabled: true } }) }));
  try {
    expect((await disabled.listTools()).tools).toEqual([]);
    expect((await factory.listTools()).tools.map((tool) => tool.name)).toEqual(["add_note", "list_notes"]);
  } finally {
    await disabled.dispose();
    await factory.dispose();
  }
});
```

New in 1.8.7: before, an option object named `tools` was read as the plugin's own list of tools, and the server didn't start; `dynamicTools()` is how the Remember plugin registers its memory tools under `tools.prefix`.

- **`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).

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

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

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { LinksPlugin, TICKET_LINKS, type TicketLinks } from "./links.plugin";

@Tool({ name: "ticket_link", description: "Get a link to a support ticket", inputSchema: { id: z.string() } })
export class TicketLink extends ToolContext {
  async execute({ id }: { id: string }) {
    return { url: this.get<TicketLinks>(TICKET_LINKS).linkTo(id) };
  }
}

@Tool({ name: "invoice_link", description: "Get a link to an invoice", inputSchema: { id: z.string() } })
export class InvoiceLink extends ToolContext {
  async execute({ id }: { id: string }) {
    return { hasTicketLinks: this.tryGet<TicketLinks>(TICKET_LINKS) !== undefined };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [TicketLink],
  plugins: [LinksPlugin.init({ baseUrl: "https://support.acme.com" })],
})
export class HelpDeskApp {}

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

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

```ts links.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { LinksPlugin } from "./links.plugin";
import { TicketLink } from "./main";

test("the provider is built from the options, and the app's tools can get it", async ({ mcp }) => {
  expect((await mcp.tools.call("ticket_link", { id: "T-1" })).json()).toEqual({ url: "https://support.acme.com/tickets/T-1" });
});

test("other apps' tools can't", async ({ mcp }) => {
  expect((await mcp.tools.call("invoice_link", { id: "INV-7" })).json().hasTicketLinks).toBe(false);
});

test("listed as its class, the plugin gets `{}`, so the default base URL applies", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [TicketLink], plugins: [LinksPlugin] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  try {
    expect((await server.callTool("ticket_link", { id: "T-1" })).structuredContent).toEqual({ url: "https://desk.example.com/tickets/T-1" });
  } finally {
    await server.dispose();
  }
});
```

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:

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

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { UsagePlugin } from "./usage.plugin";

@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, title: "Cannot log in", status: "open" };
  }
}

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

```ts contents.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, Plugin, connect } from "@frontmcp/sdk";

test("the plugin's resource, prompt and skill are listed", async ({ mcp }) => {
  expect(await mcp.resources.list()).toContainResource("desk://usage");
  expect(await mcp.resources.list()).toContainResource("skill://read-usage/SKILL.md");
  expect(await mcp.prompts.list()).toContainPrompt("review_usage");
});

test("the resource reads the plugin's provider", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect((await mcp.resources.read("desk://usage")).json()).toEqual({ counts: { get_ticket: 1 } });
});

test("`dynamicSkills`: the skills requests work before the plugin has added a skill", async () => {
  @Plugin({ name: "bundle-skills", dynamicSkills: true })
  class BundleSkills {}
  @Plugin({ name: "no-skills" })
  class NoSkills {}
  @App({ id: "billing", name: "Billing", plugins: [BundleSkills] })
  class Billing {}
  @App({ id: "billing", name: "Billing", plugins: [NoSkills] })
  class BillingWithout {}

  const client = await connect({ info: { name: "billing", version: "1.0.0" }, apps: [Billing] });
  expect(await client.listSkills()).toMatchObject({ skills: [], total: 0 });
  await client.close();

  const without = await connect({ info: { name: "billing", version: "1.0.0" }, apps: [BillingWithout] });
  await expect(without.listSkills()).rejects.toThrow("Method not found");
  await without.close();
});
```

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

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

```ts apps.ts
import { App, 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: "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" };
  }
}

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

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

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

@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<object> {
  @ToolHook.Did("execute", { filter: (ctx) => ctx.state.tool?.metadata.name !== "usage_report" })
  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;
  }
}
```

```ts server.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Plugin } from "@frontmcp/sdk";
import { BillingApp, CloseTicket } from "./apps";
import { UsageLog, UsagePlugin, UsageReport } from "./usage.plugin";

test("calls to both apps are counted", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  await mcp.tools.call("refund_invoice", { id: "INV-7" });
  expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({ close_ticket: 1, refund_invoice: 1 });
});

test("the plugin's tool is listed once", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names.filter((n: string) => n === "usage_report")).toHaveLength(1);
});

test("on one app, with `scope: \"server\"`, it counts every app's calls too", async () => {
  @Plugin({ name: "usage", providers: [UsageLog], tools: [UsageReport], scope: "server" })
  class ServerUsage extends UsagePlugin {}
  @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], plugins: [ServerUsage] })
  class HelpDesk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk, BillingApp] });
  try {
    await server.callTool("close_ticket", { id: "T-1" });
    await server.callTool("refund_invoice", { id: "INV-7" });
    expect((await server.callTool("usage_report", {})).structuredContent).toEqual({ counts: { close_ticket: 1, refund_invoice: 1 } });
  } finally {
    await server.dispose();
  }
});
```

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"`](#scope), as the last test does, or the hook sets [`appliesTo`](#enforcing-an-option-of-your-own).

### Enforcing an option of your own

A plugin can add an option to tools, like `approval` from the [Approval plugin](https://frontmcp.dev/reference/plugins/approval), 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:

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

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

@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, title: "Cannot log in", status: "open" };
  }
}

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, CloseTicket],
  plugins: [MaintenancePlugin.init({ until: "10:00 UTC" })],
})
export class HelpDeskApp {}

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

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp } from "./apps";

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

```ts maintenance.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance } from "@frontmcp/sdk";
import { BillingApp, CloseTicket } from "./apps";
import { MaintenancePlugin } from "./maintenance.plugin";

test("tools that write are refused, in the plugin's app", async ({ mcp }) => {
  expect((await mcp.tools.call("close_ticket", { id: "T-1" })).text()).toBe("close_ticket is unavailable during maintenance, until 10:00 UTC.");
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful();
});

test("`appliesTo: \"uncovered-apps\"` reaches the billing app too", async ({ mcp }) => {
  expect(await mcp.tools.call("refund_invoice", { id: "INV-7" })).toBeError();
});

test("a server where no plugin enforces `writes` doesn't start", async () => {
  await expect(FrontMcpInstance.createForGraph({ info: { name: "billing", version: "1.0.0" }, apps: [BillingApp] })).rejects.toThrow(
    `Unenforced metadata: Tool "refund_invoice" declares 'writes' (enforced by plugin "maintenance")`,
  );
});

test("a tool inside an agent needs the plugin on the agent", async () => {
  const llm = { adapter: { completion: async () => ({ content: "Closed.", finishReason: "stop" as const }) } };

  @Agent({ name: "closer", tools: [CloseTicket], llm })
  class Closer extends AgentContext {}

  @App({ id: "desk", name: "Desk", agents: [Closer], plugins: [MaintenancePlugin] })
  class Desk {}

  await expect(FrontMcpInstance.createForGraph({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] })).rejects.toThrow(
    `Tool "closer:close_ticket" declares 'writes'`,
  );
});
```

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:

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

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ReadOnlyPlugin } from "./read-only.plugin";

@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, 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" };
  }
}

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

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

@App({ id: "billing", name: "Billing", tools: [RefundInvoice], plugins: [ReadOnlyPlugin.init({ enabled: false })] })
export class BillingApp {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp } from "./apps";

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

```ts read-only.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, DynamicPlugin, FlowCtxOf, FrontMcpInstance, ListToolsHook, Plugin } from "@frontmcp/sdk";
import { CloseTicket, GetTicket, RefundInvoice } from "./apps";

test("the list hides what the calls refuse, and nothing else", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((tool) => tool.name)).toEqual(["get_ticket", "refund_invoice"]);
  expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeError();
  expect((await mcp.tools.call("refund_invoice", { id: "INV-7" })).json()).toEqual({ id: "INV-7", status: "refunded" });
});

test("🚩 without the check, one app's plugin hides another app's tools", async () => {
  @Plugin({ name: "hide-writes" })
  class HideWrites extends DynamicPlugin<object> {
    @ListToolsHook.Did("findTools")
    async hide(ctx: FlowCtxOf<"tools:list-tools">) {
      ctx.state.set("tools", (ctx.state.tools ?? []).filter(({ tool }) => tool.metadata.annotations?.readOnlyHint));
    }
  }
  @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [HideWrites] })
  class HelpDesk {}
  @App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
  class Billing {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk, Billing] });
  expect((await server.listTools()).tools.map((tool) => tool.name)).toEqual(["get_ticket"]);
  expect((await server.callTool("refund_invoice", { id: "INV-7" })).structuredContent).toEqual({ id: "INV-7", status: "refunded" });
  await server.dispose();
});
```

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](https://frontmcp.dev/reference/plugins/feature-flags) 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:

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

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

@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, title: "Cannot log in", canSeeClock: this.tryGet(Clock) !== undefined };
  }
}

@Tool({ name: "audit_trail", description: "Show the audit trail", inputSchema: {} })
export class ShowAuditTrail extends ToolContext {
  async execute() {
    return { lines: [...this.get(AuditTrail).lines] };
  }
}
```

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

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

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

test("the outer plugin uses the nested plugin's export", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect((await mcp.tools.call("audit_trail", {})).json().lines).toEqual(["2026-09-27T09:00:00Z get_ticket"]);
});

test("the nested plugin's tool joins the app", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("server_time");
  expect((await mcp.tools.call("server_time", {})).json()).toEqual({ now: "2026-09-27T09:00:00Z" });
});

test("the app's tools don't see the nested plugin's export", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json().canSeeClock).toBe(false);
});
```

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:

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

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

@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in since this morning" };
  }
}

@Tool({
  name: "set_priority",
  description: "Set a ticket's priority.",
  inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) },
})
export class SetPriority extends ToolContext {
  async execute({ id, priority }: { id: string; priority: string }) {
    return { id, priority };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket, SetPriority } from "./ticket.tools";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach: it sets the priority,
// then answers with what set_priority said. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const toolResults: (string | null)[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages[prompt.messages.length - 1];
    if (last.role === "user") {
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "set_priority", arguments: { id: "T-1", priority: "high" } }] };
    }
    toolResults.push(last.content);
    return { content: `set_priority answered: ${last.content}`, finishReason: "stop" };
  },
};
```

```ts freeze.test.ts
import { test, expect } from "@frontmcp/testing";
import { toolResults } from "./model.example";

test("the agent's model gets the plugin's refusal", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(toolResults).toEqual(['{"error":"Priorities are frozen until Monday."}']);
});

test("the client's own call isn't hooked", async ({ mcp }) => {
  expect((await mcp.tools.call("set_priority", { id: "T-1", priority: "high" })).json()).toEqual({ id: "T-1", priority: "high" });
});
```

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](#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](https://frontmcp.dev/reference/server/flows#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:

```ts index.ts active
// 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";
```

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

export interface UsagePluginOptions {
  /** Tools not to count */
  ignore?: string[];
}

@Plugin({ name: "usage", description: "Counts calls to every tool", providers: [UsageLog], exports: [UsageLog], tools: [UsageReport] })
export class UsagePlugin extends DynamicPlugin<UsagePluginOptions> {
  constructor(private readonly options: UsagePluginOptions = {}) {
    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)) this.get(UsageLog).add(name);
  }
}
```

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

@Provider({ name: "UsageLog" })
export class UsageLog {
  private counts = new Map<string, number>();
  add(tool: string) {
    this.counts.set(tool, (this.counts.get(tool) ?? 0) + 1);
  }
  report() {
    return Object.fromEntries(this.counts);
  }
}
```

```ts usage-report.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";
import { UsageLog } from "./usage-log";

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

```ts help-desk.app.ts
// In the app's project, this import is from "help-desk-usage-plugin"
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { UsageLog, UsagePlugin } from "./index";

@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, title: "Cannot log in", status: "open" };
  }
}

@Tool({ name: "busiest_tool", description: "The tool called most often so far", inputSchema: {} })
export class BusiestTool extends ToolContext {
  async execute() {
    const counts = Object.entries(this.get(UsageLog).report()).sort((a, b) => b[1] - a[1]);
    return { name: counts[0]?.[0] ?? null };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, BusiestTool],
  plugins: [UsagePlugin.init({ ignore: ["usage_report", "busiest_tool"] })],
})
export class HelpDeskApp {}
```

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

test("the app turns the plugin on from the package's entry", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("usage_report");
  await mcp.tools.call("get_ticket", { id: "T-1" });
  await mcp.tools.call("get_ticket", { id: "T-2" });
  await mcp.tools.call("usage_report", {});
  expect((await mcp.tools.call("usage_report", {})).json()).toEqual({ counts: { get_ticket: 2 } });
});

test("the app's own tools get the provider the package exports", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect((await mcp.tools.call("busiest_tool", {})).json()).toEqual({ name: "get_ticket" });
});
```

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

```json 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"
  }
}
```

```json 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"]
}
```

```bash
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](#type-pluginreturn-is-not-assignable-to-type-plugintype). 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](#sharing-a-provider-with-the-apps-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](#contextextensions) 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](#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](#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](#enforcing-an-option-of-your-own).

On an edge isolate, [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler#assertstaticstartupconfigconfig) 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](#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`.
