# Feature flags plugin

> @frontmcp/plugin-feature-flags hides tools, resources, prompts and skills while their flag is off, and refuses them when called. Every option, the static, LaunchDarkly, Split and Unleash adapters, per-user targeting, this.featureFlags, and what happens when the flag service fails.

Source: https://frontmcp.dev/reference/plugins/feature-flags

`@frontmcp/plugin-feature-flags` turns tools, resources, resource templates, prompts, skills and agents on and off with feature flags. Give an entry `featureFlag: "bulk-export"`, and while that flag is off for the caller, the entry is left out of every list and refused when called, so you can ship a capability to some users before everyone. Flags come from an adapter: fixed values, LaunchDarkly, Split, Unleash or your own. Each caller's flags are evaluated for them, from their token. Inside your code, `this.featureFlags` reads a flag. [Turning Features On and Off](https://frontmcp.dev/learn/turning-features-on-and-off) teaches it step by step.

```ts
FeatureFlagPlugin.init({ adapter, flags? | config? | adapterInstance?, defaultValue?, gateDefaultValue?, cacheStrategy?, cacheTtlMs?, userIdResolver?, attributesResolver? })

@Tool({ ..., featureFlag: "key" | { key, defaultValue? } })
```

---

## Reference

### `FeatureFlagPlugin.init(options)`

Install the package, then register the plugin in the `plugins` of an [`@App`](https://frontmcp.dev/reference/sdk/app) or of [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) (see [Which entries a plugin gates](#which-entries-a-plugin-gates)). It isn't part of the `@frontmcp/plugins` umbrella package.

```bash
npm install @frontmcp/plugin-feature-flags
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { BulkExport, SearchTickets } from "./tools";

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport] })
class HelpDesk {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  // On @FrontMcp, flags gate every app's entries
  plugins: [FeatureFlagPlugin.init({ adapter: "launchdarkly", config: { sdkKey: process.env.LAUNCHDARKLY_SDK_KEY! } })],
})
export default class Server {}
```

[See more examples below.](#usage)

#### Options

`adapter` picks where flags come from, and decides which of `flags`, `config` and `adapterInstance` is required:

| `adapter` | Also required | Flags come from |
| --- | --- | --- |
| `"static"` | `flags: Record<string, boolean \| FeatureFlagVariant>` | The values you give. See [Static](#static). |
| `"launchdarkly"` | `config: { sdkKey }` | LaunchDarkly. See [LaunchDarkly, Split and Unleash](#launchdarkly-split-and-unleash). |
| `"splitio"` | `config: { apiKey }` | Split. See [LaunchDarkly, Split and Unleash](#launchdarkly-split-and-unleash). |
| `"unleash"` | `config: { url, appName, apiKey? }` | Unleash. See [LaunchDarkly, Split and Unleash](#launchdarkly-split-and-unleash). |
| `"custom"` | `adapterInstance: FeatureFlagAdapter` | Your adapter. See [Your own adapter](#your-own-adapter). |

Every adapter also takes:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `boolean` | `false` | What `this.featureFlags.isEnabled()` returns, when the call gave no default, for a key the adapter has no answer for or when the adapter throws. Lists and gates don't use it: see [When the flag service fails](#when-the-flag-service-fails). |
| `gateDefaultValue` | `boolean` | `false` | What lists and gates answer for an entry whose [`featureFlag`](#the-featureflag-option) has no `defaultValue` of its own, when the adapter has no answer for the flag, or throws while a call is checked. `true` makes every such entry available then, so set it only if failing open is what you want. Anything but a boolean makes `init()` throw a `FeatureFlagConfigurationError`. New in 1.9.4. |
| `cacheStrategy` | `"session" \| "request" \| "none"` | `"none"` | Caches `this.featureFlags.isEnabled()` results for `cacheTtlMs`. The cache belongs to `this.featureFlags`, which is new on every request, so `"session"` and `"request"` both last one request under protocol 2026-07-28. Lists and gates never cache. |
| `cacheTtlMs` | `number` | `30000` | How long a cached result lasts, in milliseconds. |
| `userIdResolver` | `(ctx: FrontMcpContext) => string \| undefined` | the caller's id | The `userId` adapters evaluate flags for. See [Who a flag is evaluated for](#who-a-flag-is-evaluated-for). |
| `attributesResolver` | `(ctx: FrontMcpContext) => Record<string, unknown>` | `{}` | Targeting attributes for adapters, like a tenant or a plan. |

The plugin needs an `adapter`. `FeatureFlagPlugin.init()` with no argument, with no `adapter` or with one that isn't in the table throws a `FeatureFlagConfigurationError` as soon as `init()` runs, which is when the module that calls it loads. The message names what it got and the supported adapters: [see below](#featureflagplugininit-requires-an-adapter-option). `plugins: [FeatureFlagPlugin]`, the class without `init()`, has no adapter either, and the server doesn't start, with the same error. Changed in 1.9.4: the class started, and then every list failed with `Provider "[ref]" is not available`. The error class is exported from the package. The plugin works in CommonJS and ES module projects, its LaunchDarkly, Split and Unleash adapters included. Changed in 1.9: in an ES module project, those three adapters failed to load their SDK.

### The `featureFlag` option

The plugin adds `featureFlag` to [`@Tool`](https://frontmcp.dev/reference/sdk/tool), [`@Resource`](https://frontmcp.dev/reference/sdk/resource), [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template), [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) and [`@Skill`](https://frontmcp.dev/reference/sdk/skill) (and `skill()`). [`@Agent`](https://frontmcp.dev/reference/sdk/agent) has it too, and it gates the agent's `invoke_<agent>` tool: see [Flagging an agent](#flagging-an-agent). Jobs and workflows don't have it.

```ts
@Tool({ name: "bulk_export", description: "Export tickets as CSV", inputSchema: {}, featureFlag: "bulk-export" })
@Tool({ name: "sla_report", description: "SLA report", inputSchema: {}, featureFlag: { key: "sla-report", defaultValue: true } })
```

| Form | Meaning |
| --- | --- |
| `"key"` | Gated by the flag `key`. When the adapter has no answer for `key`, the entry is off, unless the plugin's `gateDefaultValue` is `true`. |
| `{ key, defaultValue }` | The same, except that `defaultValue` is used when the adapter has no answer for `key`, or throws while a call is checked, instead of the plugin's `gateDefaultValue`. A flag the adapter reports as off stays off, whatever `defaultValue` says. |

"No answer" means the adapter left the key out of `evaluateFlags()`'s result. The static adapter does that for keys you didn't configure; LaunchDarkly, Split and Unleash always answer, with their own default for a flag they don't know.

While a flag is off for the caller:

| Request | What happens |
| --- | --- |
| `tools/list`, `resources/list`, `resources/templates/list`, `prompts/list` | The entry is left out. |
| Skills | Left out of the `skill://` resources, `skill://index.json` and `skills/list`, and `skills/load` can't find it. |
| `tools/call`, including [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) | Refused: a tool error with `_meta.code: "FEATURE_FLAG_DISABLED"` and the text `Tool "bulk_export" is disabled by feature flag "bulk-export"`. |
| `resources/read`, `prompts/get` | Refused: JSON-RPC error `-32003`, with `data.code: "FEATURE_FLAG_DISABLED"` and the message `Resource "…" is disabled by feature flag "…"` or `Prompt "…" …`. A skill's `skill://` resource is refused the same way. |
| `completion/complete` | Refused the same way, for a prompt or resource template that's off. |

The refusal is a `FeatureFlagDisabledError`, a public error like an [approval](https://frontmcp.dev/reference/plugins/approval) refusal: it has the code `FEATURE_FLAG_DISABLED` and the status `403`, and its message reaches the client as written, in production too, for tools, resources and prompts alike. A session-based client (before MCP 2026-07-28) reads a resource's or a prompt's message with `MCP error -32003: ` in front.

> **Note**
Changed in 1.8.7: a refusal was wrapped as an internal error. Its `_meta.code` was `SERVER_ERROR`, in development its text went on with `Original error:` and a stack trace, resources and prompts answered `-32603`, and in production clients got `Internal FrontMCP error. Please contact support with error ID: …` instead of the message.

Under MCP 2026-07-28, list results carry a `cacheScope`. With the plugin in the server they're `"private"`, even for a caller without credentials, because the plugin can make them differ from one caller to the next, so a shared cache doesn't hand one caller's list to another.

#### Who a flag is evaluated for

Lists and gates evaluate flags on every request, for the caller. Each adapter receives a `FeatureFlagContext`:

| Field | Value |
| --- | --- |
| `userId` | `userIdResolver(ctx)`, or else the caller's id: `authInfo.extra.sub`, then `authInfo.extra.userId`, then `authInfo.clientId`. With FrontMCP's auth modes that's `this.auth.user.sub`: a token's subject, or `static:…` for a static key. A caller without credentials has none: the `anon:` id FrontMCP gives them is new on every request, so it isn't passed on. |
| `sessionId` | The session the server verified for the request, never an `mcp-session-id` header the client sent. Under protocol 2026-07-28 there's none, so it's `undefined`. |
| `attributes` | `attributesResolver(ctx)`, or `{}`. `ctx.authInfo.user` holds the caller's token claims. |

A list evaluates all its flags in one `evaluateFlags()` call; a gate evaluates one flag. Under 2026-07-28, a caller without credentials reaches the adapter with neither a `userId` nor a `sessionId`, so the adapter can't tell such callers apart (LaunchDarkly and Split evaluate them all under the key `anonymous`), and a percentage rollout gives them all the same answer: target signed-in users.

#### Which entries a plugin gates

A `featureFlag` works only where a feature-flag plugin reaches the entry, so FrontMCP checks this when the server starts. If an entry declares `featureFlag` and no plugin reaches it, the server doesn't start, with `UnenforcedMetadataError` naming each entry: `Unenforced metadata: Tool "refund_invoice" declares 'featureFlag' (enforced by FeatureFlagPlugin from @frontmcp/plugin-feature-flags). …`. [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) rejects the same way when it's called. Install the plugin, or remove the field.

| Plugin registered on | Refuses calls, reads and gets of |
| --- | --- |
| `@FrontMcp` | Every app's entries. |
| An `@App` | That app's entries, and those of every app that has no feature-flag plugin of its own. |
| An `@Agent` | The tools declared inside that agent. An app's or the server's plugin doesn't reach them. |

Lists follow the same rules: a plugin leaves out of `tools/list` and the other lists only the entries whose calls it checks, so a list and a call agree. [Gating every app's entries](#gating-every-apps-entries) and [Flagging an agent](#flagging-an-agent) show each case.

> **Note**
Changed in 1.8.4: before, every feature-flag plugin filtered every app's lists, while a call was checked by one plugin only. With a plugin on each of two apps, a tool could be hidden from `tools/list` and still run when called by name.

### Adapters

#### Static

Flag values you give, the same for every caller. For development, tests, and kill switches you change by redeploying.

```ts
FeatureFlagPlugin.init({
  adapter: "static",
  flags: {
    "ai-summary": true,
    "bulk-export": false,
    ranking: { name: "semantic", value: { boost: 1.5 }, enabled: true },
  },
});
```

A flag is a boolean or a `FeatureFlagVariant`, `{ name, value, enabled }`, whose `enabled` says whether it's on. `getVariant()` returns the variant, `{ name: "on" | "off", value: <the boolean>, enabled }` for a boolean, and `{ name: "off", value: undefined, enabled: false }` for a key you didn't configure. `isEnabled()` says `false` for a key you didn't configure, and `evaluateFlags()` leaves it out, so a `defaultValue` applies to it.

#### LaunchDarkly, Split and Unleash

These adapters load the vendor's SDK, an optional peer dependency you install yourself, and connect when the server starts. If it isn't installed, the server fails to start (and [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) rejects when it's called) with `LaunchDarkly SDK not found. Install it: npm install @launchdarkly/node-server-sdk`, or the same for the others. They need the network and those SDKs, so this page's Playgrounds can't run them; the details below are from the adapters' source.

| | LaunchDarkly | Split | Unleash |
| --- | --- | --- | --- |
| `config` | `{ sdkKey }` | `{ apiKey }` | `{ url, appName, apiKey? }`; `apiKey` is sent as the `Authorization` header |
| Package | `@launchdarkly/node-server-sdk` 9 or 10 | `@splitsoftware/splitio` 10 or 11 | `unleash-client` 5 or 6 |
| At start | `init(sdkKey)`, then `waitForInitialization()` | `SplitFactory({ core: { authorizationKey } })`, then `client.ready()` | `new Unleash({ url, appName })`, then `start()` |
| Who | Context `{ kind: "user", key: userId ?? sessionId ?? "anonymous", ...attributes }` | Key `userId ?? sessionId ?? "anonymous"`, with `attributes` | `{ userId, sessionId, properties: attributes }` |
| On | `variation(key, context, false)` | The treatment is `"on"` | `isEnabled(key, context)` |
| `getVariant()` | `{ name: String(value), value, enabled: Boolean(value) }` from `variationDetail()` | `{ name: treatment, value: treatment, enabled: treatment === "on" }` | `{ name, value: payload.value ?? name, enabled }` |

They answer every key, so a ref's `defaultValue`, and the plugin's `gateDefaultValue`, only matter when they throw. They load their SDK the same way in CommonJS and ES module projects. Changed in 1.9: in an ES module project (`"type": "module"`), they failed with the same `SDK not found` message even when the SDK was installed.

#### Your own adapter

`adapter: "custom"` with `adapterInstance`, any object with these methods:

```ts
interface FeatureFlagAdapter {
  initialize(): Promise<void>;
  isEnabled(flagKey: string, context: FeatureFlagContext): Promise<boolean>;
  getVariant(flagKey: string, context: FeatureFlagContext): Promise<FeatureFlagVariant>;
  evaluateFlags(flagKeys: string[], context: FeatureFlagContext): Promise<Map<string, boolean>>;
  destroy(): Promise<void>;
}
```

- `evaluateFlags()` is what lists, gates and `this.featureFlags.isEnabled()` call. Leave out a key you know nothing about, so the caller's `defaultValue` applies; answer `false` for a flag that's off.
- `getVariant()` is what `this.featureFlags.getVariant()` calls. Nothing in the plugin calls `isEnabled()`, but `init()` requires it.
- `init()` checks `adapterInstance`: without one, or with one that lacks `isEnabled()`, `getVariant()` or `evaluateFlags()`, it throws a `FeatureFlagConfigurationError` that names what's missing (with `useFactory`, when the server starts). Changed in 1.9: the server started, and then answered every request with a `500`.
- `initialize()` runs when the server starts, before any list or gate asks the adapter, and `destroy()` when the server is disposed. An `initialize()` that throws stops the server from starting, with its error. An adapter object given to two servers is initialized and destroyed with each. Changed in 1.9.2: FrontMCP called neither, so an adapter had to be ready before it was passed in.

[Targeting users](#turning-a-flag-on-for-some-users) has one. The four built-in adapters are exported as classes too: `StaticFeatureFlagAdapter`, `LaunchDarklyFeatureFlagAdapter`, `SplitioFeatureFlagAdapter` and `UnleashFeatureFlagAdapter`.

### `this.featureFlags`

The plugin adds `this.featureFlags`, a `FeatureFlagAccessor` for the caller, to every tool, resource and prompt. It evaluates flags for the same `FeatureFlagContext` as the gates. Under protocol 2026-07-28 a new one is built for each request, so it answers for whoever is calling, with a token or without.

| Method | Returns | Description |
| --- | --- | --- |
| `isEnabled(flagKey, defaultValue?)` | `Promise<boolean>` | The flag's value from the adapter's `evaluateFlags()`. For a key it has no answer for, or if it throws: `defaultValue`, else the plugin's `defaultValue`, else `false`. Cached with `cacheStrategy`; a failure isn't. |
| `getVariant(flagKey)` | `Promise<FeatureFlagVariant>` | The adapter's `getVariant()`. Errors aren't caught. |
| `evaluateFlags(flagKeys)` | `Promise<Map<string, boolean>>` | The adapter's `evaluateFlags()`: with the static adapter, keys you didn't configure are missing. Errors aren't caught. |
| `resolveRef(ref)` | `Promise<boolean>` | `isEnabled(key, ref.defaultValue)` for a `featureFlag` value. |

`this.get(FeatureFlagAccessorToken)` returns the same accessor, and `this.tryGet(FeatureFlagAccessorToken)` returns it or `undefined` where the plugin isn't. `FeatureFlagAdapterToken` gets the adapter, and `FeatureFlagConfigToken` the options. They're in the apps the plugin is registered for: every app on `@FrontMcp`, that app on `@App`. In another app's tools, `this.featureFlags` throws `FeatureFlagPlugin is not installed. Add FeatureFlagPlugin.init() to your plugins array.`, although the plugin's gate covers that app's entries. Changed in 1.9: tools in every app could get them. The package's `getFeatureFlags(this)` and `tryGetFeatureFlags(this)` do the same, but in a `strict` TypeScript project passing `this` fails with `TS2345` (`Types of property 'get' are incompatible`): use the tokens.

#### Caveats

- A ref's `defaultValue` and `this.featureFlags` agree about keys the adapter doesn't know: with the static adapter, `featureFlag: { key: "new", defaultValue: true }` shows the entry, and `this.featureFlags.resolveRef({ key: "new", defaultValue: true })` says `true`. The plugin's own `defaultValue` is different: `this.featureFlags` uses it, lists and gates don't. They use `gateDefaultValue`, which `this.featureFlags` doesn't. Changed in 1.9: `isEnabled()` asked the adapter's `isEnabled()`, which the static adapter answers `false` for a key it doesn't know, so it used a default only when the adapter threw.

### When the flag service fails

What happens when the adapter throws:

| Where | Result |
| --- | --- |
| A list: `tools/list` and the rest | The whole request fails: JSON-RPC `-32603` with the adapter's error message (`Internal FrontMCP error…` in production). Neither `defaultValue` helps, and nor does `gateDefaultValue`. |
| A gate: `tools/call` and the rest | The ref's `defaultValue`, else the plugin's `gateDefaultValue`, else `false`. The plugin's `defaultValue` isn't used. |
| `this.featureFlags.isEnabled()` | The call's `defaultValue`, else the plugin's, else `false`. |
| `getVariant()`, `evaluateFlags()` | The error is thrown to your code. |

Catch errors inside a custom adapter, and answer from a last known value, if a flag service outage mustn't take your lists down.

---

## Usage

### Hiding a tool behind a flag

With the static adapter, `ai-summary` is on and `bulk-export` off. `sla_report`'s flag isn't configured, so its `defaultValue` decides, and `merge_tickets` shows that `defaultValue` can't turn on a flag that's off. Open the **Capabilities** tab to see which tools are listed:

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

@Tool({ name: "search_tickets", description: "Search support tickets", inputSchema: {} })
export class SearchTickets extends ToolContext {
  async execute() {
    return { tickets: ["T-1"] };
  }
}

@Tool({ name: "ai_summary", description: "Summarize a ticket thread", inputSchema: {}, featureFlag: "ai-summary" })
export class AiSummary extends ToolContext {
  async execute() {
    return { summary: "The customer can't log in." };
  }
}

@Tool({ name: "bulk_export", description: "Export every ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" })
export class BulkExport extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}

@Tool({ name: "sla_report", description: "Report SLA breaches", inputSchema: {}, featureFlag: { key: "sla-report", defaultValue: true } })
export class SlaReport extends ToolContext {
  async execute() {
    return { breaches: 0 };
  }
}

@Tool({ name: "merge_tickets", description: "Merge duplicate tickets", inputSchema: {}, featureFlag: { key: "bulk-export", defaultValue: true } })
export class MergeTickets extends ToolContext {
  async execute() {
    return { merged: 2 };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { AiSummary, BulkExport, MergeTickets, SearchTickets, SlaReport } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, AiSummary, BulkExport, SlaReport, MergeTickets],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "ai-summary": true, "bulk-export": false } })],
})
class HelpDesk {}

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

```ts flags.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { FeatureFlagConfigurationError, FeatureFlagDisabledError, FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { AiSummary, BulkExport } from "./tools";

const info = { name: "help-desk", version: "1.0.0" };

test("tools whose flag is off aren't listed", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name).sort();
  expect(names).toEqual(["ai_summary", "search_tickets", "sla_report"]);
});

test("calling one anyway is refused", async ({ mcp }) => {
  const result = await mcp.tools.call("bulk_export", {});
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("FEATURE_FLAG_DISABLED");
  expect(result).toHaveTextContent('Tool "bulk_export" is disabled by feature flag "bulk-export"');
});

test("the refusal's text is its message alone, as in production", async ({ mcp }) => {
  const result = await mcp.tools.call("bulk_export", {});
  expect(result.text()).toBe('Tool "bulk_export" is disabled by feature flag "bulk-export"');
});

test("the refusal is a public error with the status 403", () => {
  const error = new FeatureFlagDisabledError("Tool", "bulk_export", "bulk-export");
  expect(error).toMatchObject({ code: "FEATURE_FLAG_DISABLED", statusCode: 403, message: 'Tool "bulk_export" is disabled by feature flag "bulk-export"' });
});

test("the list isn't marked for shared caches", async ({ mcp }) => {
  const list = await mcp.raw.request({ method: "tools/list", params: {} });
  expect(list.result.cacheScope).toBe("private");
});

test("defaultValue decides a flag the adapter doesn't know", async ({ mcp }) => {
  expect(await mcp.tools.call("sla_report", {})).toBeSuccessful();
});

test("defaultValue doesn't turn on a flag that's off", async ({ mcp }) => {
  expect(await mcp.tools.call("merge_tickets", {})).toBeError();
});

test("`gateDefaultValue: true` turns on the flags the adapter doesn't know, and only those", async () => {
  @App({
    id: "help-desk",
    name: "Help Desk",
    tools: [AiSummary, BulkExport],
    plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "bulk-export": false }, gateDefaultValue: true })],
  })
  class FailOpen {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [FailOpen] });
  expect((await server.listTools()).tools.map((t: { name: string }) => t.name)).toEqual(["ai_summary"]);
  expect((await server.callTool("ai_summary", {})).structuredContent).toEqual({ summary: "The customer can't log in." });
  await expect(server.callTool("bulk_export", {})).rejects.toThrow('Tool "bulk_export" is disabled by feature flag "bulk-export"');
  await server.dispose();
});

test("`gateDefaultValue` must be a boolean", () => {
  expect(() => FeatureFlagPlugin.init({ adapter: "static", flags: {}, gateDefaultValue: "yes" } as never)).toThrow(
    'FeatureFlagPlugin.init() option `gateDefaultValue` must be a boolean, got "yes".',
  );
});

test("init() without an adapter throws a FeatureFlagConfigurationError", () => {
  expect(() => FeatureFlagPlugin.init()).toThrow(FeatureFlagConfigurationError);
  expect(() => FeatureFlagPlugin.init()).toThrow(
    'FeatureFlagPlugin.init() requires an `adapter` option, got undefined. Supported adapters: "static", "splitio", "launchdarkly", "unleash", "custom".',
  );
});

test("so does an adapter it doesn't know", () => {
  expect(() => FeatureFlagPlugin.init({ adapter: "redis" } as never)).toThrow(
    'FeatureFlagPlugin.init() requires an `adapter` option, got "redis". Supported adapters:',
  );
});

test("so does adapter: \"custom\" without an adapterInstance, or with one that lacks a method", () => {
  expect(() => FeatureFlagPlugin.init({ adapter: "custom" } as never)).toThrow(
    "FeatureFlagPlugin.init({ adapter: 'custom' }) requires an `adapterInstance` option: an object implementing FeatureFlagAdapter (isEnabled(), getVariant(), evaluateFlags()); got undefined.",
  );
  const partial = { isEnabled: async () => true };
  expect(() => FeatureFlagPlugin.init({ adapter: "custom", adapterInstance: partial } as never)).toThrow("missing getVariant(), evaluateFlags().");
});

test("the class without init() has no adapter, so the server doesn't start", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [BulkExport], plugins: [FeatureFlagPlugin] })
  class WithoutInit {}

  const startup = FrontMcpInstance.createDirect({ info, apps: [WithoutInit] });
  await expect(startup).rejects.toThrow("FeatureFlagPlugin.init() requires an `adapter` option, got undefined.");
});
```

A flag that's off hides the tool from the model and refuses it for clients that call it by name anyway, for example from a list they cached before the flag changed. A flag the adapter doesn't know is off too, for an entry without a `defaultValue`, unless the plugin sets `gateDefaultValue: true`, as one of the tests does: then such flags are on, and a flag the adapter reports as off stays off.

### Flagging resources, prompts and skills

Resources, resource templates, prompts and skills take `featureFlag` too. Here everything but `search_tickets` depends on `insights`, which is off:

```ts insights.ts active
import { Prompt, PromptContext, Resource, ResourceContext, ResourceTemplate, skill, type GetPromptResult } from "@frontmcp/sdk";

@Resource({ name: "ticket_stats", uri: "tickets://stats", mimeType: "application/json", featureFlag: "insights" })
export class TicketStats extends ResourceContext {
  async execute() {
    return { open: 12, closed: 40 };
  }
}

@ResourceTemplate({ name: "ticket_history", uriTemplate: "tickets://{id}/history", mimeType: "application/json", featureFlag: "insights" })
export class TicketHistory extends ResourceContext {
  async execute(uri: string, { id }: { id: string }) {
    return { id, events: ["opened", "assigned"] };
  }
}

@Prompt({ name: "weekly_review", description: "Review the week's tickets", arguments: [], featureFlag: "insights" })
export class WeeklyReview extends PromptContext {
  async execute(): Promise<GetPromptResult> {
    return { messages: [{ role: "user", content: { type: "text", text: "Review this week's tickets." } }] };
  }
}

export const readInsights = skill({
  name: "read-insights",
  description: "How to read the help desk's ticket statistics",
  instructions: "Read tickets://stats, then compare open and closed counts.",
  featureFlag: "insights",
});
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { readInsights, TicketHistory, TicketStats, WeeklyReview } from "./insights";

@Tool({ name: "search_tickets", description: "Search support tickets", inputSchema: {} })
class SearchTickets extends ToolContext {
  async execute() {
    return { tickets: ["T-1"] };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets],
  resources: [TicketStats, TicketHistory],
  prompts: [WeeklyReview],
  skills: [readInsights],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { insights: false } })],
})
class HelpDesk {}

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

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

test("they're left out of every list", async ({ mcp }) => {
  expect(await mcp.resources.list()).not.toContainResource("tickets://stats");
  expect((await mcp.resources.listTemplates()).map((t: { uriTemplate: string }) => t.uriTemplate)).not.toContain("tickets://{id}/history");
  expect(await mcp.prompts.list()).toHaveLength(0);
  expect(await mcp.resources.list()).not.toContainResource("skill://read-insights/SKILL.md");
});

test("reading or getting one is refused", async ({ mcp }) => {
  const stats = await mcp.raw.request({ method: "resources/read", params: { uri: "tickets://stats" } });
  expect(stats.error).toMatchObject({
    code: -32003,
    message: 'Resource "ticket_stats" is disabled by feature flag "insights"',
    data: { code: "FEATURE_FLAG_DISABLED" },
  });
  const history = await mcp.raw.request({ method: "resources/read", params: { uri: "tickets://T-1/history" } });
  expect(history.error?.message).toBe('Resource "ticket_history" is disabled by feature flag "insights"');
  const prompt = await mcp.raw.request({ method: "prompts/get", params: { name: "weekly_review", arguments: {} } });
  expect(prompt.error?.message).toBe('Prompt "weekly_review" is disabled by feature flag "insights"');
});

test("the skill can't be read, found or loaded", async ({ mcp }) => {
  const read = await mcp.raw.request({ method: "resources/read", params: { uri: "skill://read-insights/SKILL.md" } });
  expect(read.error?.message).toBe('Resource "read-insights" is disabled by feature flag "insights"');
  expect((await mcp.resources.read("skill://index.json")).text()).not.toContain("read-insights");
  expect((await mcp.raw.request({ method: "skills/list", params: {} })).result.skills).toEqual([]);
  const load = await mcp.raw.request({ method: "skills/load", params: { skillIds: ["read-insights"] } });
  expect(load.result.summary.combinedWarnings).toEqual(['Skill "read-insights" not found']);
});

test("completion is refused too", async ({ mcp }) => {
  const completion = await mcp.raw.request({
    method: "completion/complete",
    params: { ref: { type: "ref/resource", uri: "tickets://{id}/history" }, argument: { name: "id", value: "T" } },
  });
  expect(completion.error?.message).toBe('Resource "ticket_history" is disabled by feature flag "insights"');
});
```

### Reading a flag in a tool

`this.featureFlags` reads flags inside `execute()`, for behaviour that changes with a flag rather than a whole tool that comes and goes. Here the ranking algorithm is a variant, with a value:

```ts search-tickets.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search support tickets", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const ranking = await this.featureFlags.getVariant("ranking");
    const summaries = await this.featureFlags.isEnabled("ai-summary");
    return {
      query,
      ranking: ranking.enabled ? ranking.name : "keyword",
      boost: ranking.value,
      withSummaries: summaries,
    };
  }
}
```

```ts flag-report.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";
import { FeatureFlagAccessorToken } from "@frontmcp/plugin-feature-flags";

@Tool({ name: "flag_report", description: "Show how the flags are set", inputSchema: {} })
export class FlagReport extends ToolContext {
  async execute() {
    const flags = this.tryGet(FeatureFlagAccessorToken); // undefined in a server without the plugin
    if (!flags) return { installed: false };
    return {
      installed: true,
      evaluated: Object.fromEntries(await flags.evaluateFlags(["ai-summary", "bulk-export", "not-configured"])),
      unknown: await flags.isEnabled("not-configured"),
      unknownWithDefault: await flags.isEnabled("not-configured", true),
      refWithDefault: await flags.resolveRef({ key: "not-configured", defaultValue: true }),
    };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { FlagReport } from "./flag-report.tool";
import { SearchTickets } from "./search-tickets.tool";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, FlagReport],
  plugins: [
    FeatureFlagPlugin.init({
      adapter: "static",
      flags: {
        "ai-summary": true,
        "bulk-export": false,
        ranking: { name: "semantic", value: { boost: 1.5 }, enabled: true },
      },
    }),
  ],
})
class HelpDesk {}

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

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

test("getVariant() returns the variant's name and value", async ({ mcp }) => {
  expect((await mcp.tools.call("search_tickets", { query: "login" })).json()).toEqual({
    query: "login",
    ranking: "semantic",
    boost: { boost: 1.5 },
    withSummaries: true,
  });
});

test("evaluateFlags() leaves out keys the static adapter doesn't know", async ({ mcp }) => {
  expect((await mcp.tools.call("flag_report", {})).json().evaluated).toEqual({ "ai-summary": true, "bulk-export": false });
});

test("isEnabled() and resolveRef() use defaultValue for a key the adapter doesn't know", async ({ mcp }) => {
  expect((await mcp.tools.call("flag_report", {})).json()).toMatchObject({
    unknown: false,
    unknownWithDefault: true,
    refWithDefault: true,
  });
});
```

The static adapter leaves a key it doesn't know out of `evaluateFlags()`, so `isEnabled()` and `resolveRef()` use the default they're given, else the plugin's `defaultValue`, else `false`: the same answer a tool with `featureFlag: { key: "not-configured", defaultValue: true }` gets from the gate.

### Turning a flag on for some users

A flag service decides per caller. Here a small adapter stands in for one: `bulk-export` is on for the `acme` tenant, and `ai-summary` for the user `sam`. `attributesResolver` passes the tenant from the caller's token. The tests call as two signed-in users, with tokens signed ahead of time; the Playground calls without one, so both flagged tools are hidden from it.

```ts rollout-adapter.ts active
import type { FeatureFlagAdapter, FeatureFlagContext, FeatureFlagVariant } from "@frontmcp/plugin-feature-flags";

type Rule = { users?: string[]; tenants?: string[] };

/** Flags that are on for some users and tenants. Stands in for LaunchDarkly, Split or Unleash. */
export class RolloutAdapter implements FeatureFlagAdapter {
  lastContext?: FeatureFlagContext; // who the last list or gate asked about

  constructor(private readonly rules: Record<string, Rule>) {}

  async initialize() {}
  async destroy() {}

  async isEnabled(flagKey: string, { userId, attributes }: FeatureFlagContext): Promise<boolean> {
    const rule = this.rules[flagKey];
    if (!rule) return false;
    return (!!userId && !!rule.users?.includes(userId)) || !!rule.tenants?.includes(String(attributes?.tenant));
  }

  async getVariant(flagKey: string, context: FeatureFlagContext): Promise<FeatureFlagVariant> {
    const enabled = await this.isEnabled(flagKey, context);
    return { name: enabled ? "on" : "off", value: enabled, enabled };
  }

  async evaluateFlags(flagKeys: string[], context: FeatureFlagContext): Promise<Map<string, boolean>> {
    this.lastContext = context;
    const results = new Map<string, boolean>();
    for (const key of flagKeys) {
      if (key in this.rules) results.set(key, await this.isEnabled(key, context)); // unknown keys: no answer
    }
    return results;
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { RolloutAdapter } from "./rollout-adapter";

export const rollout = new RolloutAdapter({ "bulk-export": { tenants: ["acme"] }, "ai-summary": { users: ["sam"] } });

@Tool({ name: "bulk_export", description: "Export every ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" })
class BulkExport extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}

@Tool({ name: "ai_summary", description: "Summarize a ticket thread", inputSchema: {}, featureFlag: "ai-summary" })
class AiSummary extends ToolContext {
  async execute() {
    return { summary: "The customer can't log in." };
  }
}

@Tool({ name: "export_formats", description: "Which export formats the user can use", inputSchema: {} })
class ExportFormats extends ToolContext {
  async execute() {
    const bulk = await this.featureFlags.isEnabled("bulk-export"); // for this caller
    return { formats: bulk ? ["pdf", "csv"] : ["pdf"] };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [BulkExport, AiSummary, ExportFormats],
  plugins: [
    FeatureFlagPlugin.init({
      adapter: "custom",
      adapterInstance: rollout,
      attributesResolver: (ctx) => ({ tenant: (ctx.authInfo as { user?: { tenant?: string } }).user?.tenant }),
    }),
  ],
})
class HelpDesk {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts request-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

let server: ReturnType<typeof FrontMcpInstance.createFetchHandler> | undefined;

/** Sends one request as a client with this token would, and returns its result. */
export async function requestAs(token: string | undefined, method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
  server ??= FrontMcpInstance.createFetchHandler(config);
  const response = await (await server)(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": method,
        ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
        ...headers,
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return (await response.json()).result;
}

export const toolNames = async (token?: string) => (await requestAs(token, "tools/list")).tools.map((t: { name: string }) => t.name).sort();
export const callAs = (token: string | undefined, name: string) => requestAs(token, "tools/call", { name, arguments: {} });
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: no tenant
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts targeting.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { rollout } from "./main";
import { RolloutAdapter } from "./rollout-adapter";
import { callAs, requestAs, toolNames } from "./request-as";
import { NOUR, SAM } from "./tokens";

test("each user sees the tools their flags turn on", async () => {
  expect(await toolNames(NOUR)).toEqual(["bulk_export", "export_formats"]);
  expect(await toolNames(SAM)).toEqual(["ai_summary", "export_formats"]);
  expect(await toolNames(undefined)).toEqual(["export_formats"]);
});

test("a tool is refused to a user whose flag is off", async () => {
  expect((await callAs(NOUR, "bulk_export")).isError).toBeUndefined();
  expect(await callAs(SAM, "bulk_export")).toMatchObject({ isError: true });
});

test("this.featureFlags answers for each caller", async () => {
  expect((await callAs(NOUR, "export_formats")).structuredContent).toEqual({ formats: ["pdf", "csv"] });
  expect((await callAs(SAM, "export_formats")).structuredContent).toEqual({ formats: ["pdf"] });
  expect((await callAs(NOUR, "export_formats")).structuredContent).toEqual({ formats: ["pdf", "csv"] });
});

test("the adapter never gets a session id the client picked", async () => {
  await requestAs(SAM, "tools/list", {}, { "mcp-session-id": "picked-by-the-client" });
  expect(rollout.lastContext).toMatchObject({ userId: "sam" });
  expect(rollout.lastContext?.sessionId).toBeUndefined(); // no session under 2026-07-28
});

test("a caller without a token reaches the adapter with no id", async () => {
  await requestAs(undefined, "tools/list");
  expect(rollout.lastContext?.userId).toBeUndefined();
  expect(rollout.lastContext?.sessionId).toBeUndefined();
});

class RecordingAdapter extends RolloutAdapter {
  calls: string[] = [];

  constructor(private readonly failToStart = false) {
    super({ "bulk-export": { users: ["sam"] } });
  }

  async initialize() {
    this.calls.push("initialize");
    if (this.failToStart) throw new Error("The flag service can't be reached");
  }

  async destroy() {
    this.calls.push("destroy");
  }
}

function serverWith(adapter: RecordingAdapter) {
  @Tool({ name: "bulk_export", description: "Export every ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" })
  class BulkExport extends ToolContext {
    async execute() {
      return { csv: "id,title" };
    }
  }

  @App({
    id: "help-desk",
    name: "Help Desk",
    tools: [BulkExport],
    plugins: [FeatureFlagPlugin.init({ adapter: "custom", adapterInstance: adapter })],
  })
  class HelpDesk {}

  return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });
}

test("a custom adapter is initialized when the server starts, and destroyed when it's disposed", async () => {
  const adapter = new RecordingAdapter();
  const server = await serverWith(adapter);
  expect(adapter.calls).toEqual(["initialize"]);
  await server.listTools();
  await server.dispose();
  expect(adapter.calls).toEqual(["initialize", "destroy"]);
});

test("an initialize() that throws stops the server from starting", async () => {
  await expect(serverWith(new RecordingAdapter(true))).rejects.toThrow("The flag service can't be reached");
});
```

- **`attributesResolver`** receives the request's `FrontMcpContext`. `ctx.authInfo.user` holds the token's claims, like `tenant` here.
- **`evaluateFlags()`** leaves out flags it has no rule for, so a ref's `defaultValue`, or the plugin's `gateDefaultValue`, can apply to them.
- **`export_formats`** reads the flag with `this.featureFlags`, which passes the adapter the same `userId` and attributes as the gates.
- **`sessionId`** is the one the server verified. A session id the client sends in `mcp-session-id` never reaches the adapter, so a caller can't pick the bucket a rollout puts them in. Under 2026-07-28 there's none, and a caller without a token has no `userId` either.

For a real flag service, replace the custom adapter with `adapter: "launchdarkly"` (or `"splitio"`, `"unleash"`) and its `config`, and keep `attributesResolver`: the adapters pass `userId` and the attributes on to the service.

### Surviving a flag service outage

This adapter throws while `outage.down` is set, the way a remote flag service fails when it can't be reached. `create_ticket` has a `defaultValue` of `true`, so it keeps working; `bulk_export` doesn't:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { FeatureFlagPlugin, StaticFeatureFlagAdapter, type FeatureFlagContext } from "@frontmcp/plugin-feature-flags";

export const outage = { down: false };

/** The static adapter, except that it throws during an outage */
export class FlakyAdapter extends StaticFeatureFlagAdapter {
  async isEnabled(flagKey: string, context: FeatureFlagContext) {
    if (outage.down) throw new Error("Flag service unreachable");
    return super.isEnabled(flagKey, context);
  }
  async evaluateFlags(flagKeys: string[], context: FeatureFlagContext) {
    if (outage.down) throw new Error("Flag service unreachable");
    return super.evaluateFlags(flagKeys, context);
  }
}

@Tool({ name: "create_ticket", description: "Open a support ticket", inputSchema: {}, featureFlag: { key: "ticket-editor", defaultValue: true } })
class CreateTicket extends ToolContext {
  async execute() {
    return { id: "T-2", richEditor: await this.featureFlags.isEnabled("rich-editor") };
  }
}

@Tool({ name: "bulk_export", description: "Export every ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" })
export class BulkExport extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [CreateTicket, BulkExport],
  plugins: [
    FeatureFlagPlugin.init({
      adapter: "custom",
      adapterInstance: new FlakyAdapter({ "ticket-editor": true, "bulk-export": true, "rich-editor": false }),
      defaultValue: true, // for this.featureFlags.isEnabled() only
    }),
  ],
})
class HelpDesk {}

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

```ts outage.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { BulkExport, FlakyAdapter, outage } from "./main";

test("while the service is up, the flags decide", async ({ mcp }) => {
  outage.down = false;
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name).sort()).toEqual(["bulk_export", "create_ticket"]);
  expect((await mcp.tools.call("create_ticket", {})).json()).toEqual({ id: "T-2", richEditor: false });
});

test("during an outage, tools/list fails", async ({ mcp }) => {
  outage.down = true;
  await expect(mcp.tools.list()).rejects.toThrow("Flag service unreachable");
  outage.down = false;
});

test("during an outage, calls fall back to the ref's defaultValue", async ({ mcp }) => {
  outage.down = true;
  expect(await mcp.tools.call("bulk_export", {})).toBeError();
  expect(await mcp.tools.call("create_ticket", {})).toBeSuccessful();
  outage.down = false;
});

test("during an outage, isEnabled() falls back to the plugin's defaultValue", async ({ mcp }) => {
  outage.down = true;
  expect((await mcp.tools.call("create_ticket", {})).json()).toEqual({ id: "T-2", richEditor: true });
  outage.down = false;
});

test("with `gateDefaultValue: true`, calls without a ref default go through too; the list still fails", async () => {
  @App({
    id: "help-desk",
    name: "Help Desk",
    tools: [BulkExport],
    plugins: [FeatureFlagPlugin.init({ adapter: "custom", adapterInstance: new FlakyAdapter({ "bulk-export": true }), gateDefaultValue: true })],
  })
  class FailOpen {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [FailOpen] });
  outage.down = true;
  expect((await server.callTool("bulk_export", {})).structuredContent).toEqual({ csv: "id,title\nT-1,Cannot log in" });
  await expect(server.listTools()).rejects.toThrow("Flag service unreachable");
  outage.down = false;
  await server.dispose();
});
```

The list fails outright, so the client gets no tools at all, flagged or not. If that's worse than a stale answer, catch the service's errors inside the adapter and answer from the last values it returned. The plugin's `gateDefaultValue: true` lets calls to every entry without a `defaultValue` through during an outage, as the last test shows, but doesn't save the list.

### Gating every app's entries

Registered on `@FrontMcp`, the plugin lists and refuses every app's entries by their flags. Registered on one app, it does the same for its own app and for every app without a feature-flag plugin of its own: the tests build the same server with the plugin on `help-desk`, and `billing`'s `refund_invoice` is hidden and refused too. Without any plugin, the server doesn't start. The last test gives `billing` a plugin of its own that turns `instant-refunds` on: from then on `billing`'s entries follow `billing`'s plugin, in `tools/list` and in calls alike, and `help-desk`'s plugin judges only `help-desk`'s.

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { BillingApp, HelpDeskApp } from "./apps";

export const flags = FeatureFlagPlugin.init({ adapter: "static", flags: { "bulk-export": false, "instant-refunds": false } });

@FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp], plugins: [flags] })
export default class Server {}
```

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

@Tool({ name: "bulk_export", description: "Export every ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" })
export class BulkExport extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice at once", inputSchema: { id: z.string() }, featureFlag: "instant-refunds" })
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, refunded: true };
  }
}

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

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

```ts every-app.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { BillingApp, BulkExport, HelpDeskApp, RefundInvoice } from "./apps";
import { flags } from "./main";

const info = { name: "support", version: "1.0.0" };

/** Starts a server with these apps, and sends requests to it as a 2026-07-28 client would. */
async function start(apps: object[]) {
  const handler = await FrontMcpInstance.createFetchHandler({ info, apps: apps as never });
  return async (method: string, params: Record<string, unknown>) => {
    const response = await handler(
      new Request("https://support.example.com/", {
        method: "POST",
        headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(params.name ? { "mcp-name": String(params.name) } : {}) },
        body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
      }),
    );
    return (await response.json()).result;
  };
}

@App({ id: "help-desk", name: "Help Desk", tools: [BulkExport], plugins: [flags] })
class HelpDeskWithFlags {}

test("on @FrontMcp, every app's flagged tools are hidden and refused", async ({ mcp }) => {
  expect(await mcp.tools.list()).toHaveLength(0);
  expect(await mcp.tools.call("bulk_export", {})).toBeError();
  expect(await mcp.tools.call("refund_invoice", { id: "INV-7" })).toBeError();
});

test("on one app, it also gates apps without a plugin of their own", async () => {
  const send = await start([HelpDeskWithFlags, BillingApp]);
  expect((await send("tools/list", {})).tools).toEqual([]);
  const refund = await send("tools/call", { name: "refund_invoice", arguments: { id: "INV-7" } });
  expect(refund.isError).toBe(true);
  expect(refund.content[0].text).toContain('Tool "refund_invoice" is disabled by feature flag "instant-refunds"');
});

test("without the plugin, the server doesn't start", async () => {
  const startup = FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp, BillingApp] });
  await expect(startup).rejects.toThrow(`Tool "bulk_export" declares 'featureFlag'`);
  await expect(start([HelpDeskApp, BillingApp])).rejects.toThrow(`Tool "refund_invoice" declares 'featureFlag'`);
});

test("with a plugin on each app, each app's entries follow their own, in lists and calls", async () => {
  @App({
    id: "billing",
    name: "Billing",
    tools: [RefundInvoice],
    plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "instant-refunds": true } })],
  })
  class BillingWithFlags {}

  const send = await start([HelpDeskWithFlags, BillingWithFlags]);
  expect((await send("tools/list", {})).tools.map((t: { name: string }) => t.name)).toEqual(["refund_invoice"]); // billing's plugin says on
  const refund = await send("tools/call", { name: "refund_invoice", arguments: { id: "INV-7" } });
  expect(refund.structuredContent).toEqual({ id: "INV-7", refunded: true });
  expect((await send("tools/call", { name: "bulk_export", arguments: {} })).isError).toBe(true); // help-desk's says off
});
```

### Flagging an agent

Clients reach an [agent](https://frontmcp.dev/reference/sdk/agent) through its `invoke_<agent>` tool, and `featureFlag` on `@Agent` hides and refuses that tool like any other. The tools declared inside an agent are another matter: the agent runs them itself, where only the plugins in the agent's own `plugins` apply, so a plugin on the app or the server doesn't reach them. Here `research` is behind a flag that's off, and `answer`'s own plugin keeps its `search_web` tool off. The model is a stand-in that calls the tool the question names:

```ts agents.ts active
import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { model } from "./model.example";

@Tool({ name: "search_kb", description: "Search the help center", inputSchema: { query: z.string() } })
export class SearchKb extends ToolContext {
  async execute({ query }: { query: string }) {
    return { hits: [`Help center: ${query}`] };
  }
}

@Tool({ name: "search_web", description: "Search the web", inputSchema: { query: z.string() }, featureFlag: "web-search" })
export class SearchWeb extends ToolContext {
  async execute({ query }: { query: string }) {
    return { hits: [`Web: ${query}`] };
  }
}

// A flag on the agent gates invoke_research
@Agent({
  name: "research",
  description: "Research a customer question in depth",
  inputSchema: { query: z.string() },
  llm: { adapter: model },
  featureFlag: "deep-research",
})
export class Research extends AgentContext {}

// A plugin on the agent gates the tools inside it
@Agent({
  name: "answer",
  description: "Answer a customer question",
  inputSchema: { query: z.string() },
  llm: { adapter: model },
  tools: [SearchKb, SearchWeb],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "web-search": false } })],
})
export class Answer extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { Answer, Research } from "./agents";

@App({
  id: "help-desk",
  name: "Help Desk",
  agents: [Research, Answer],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "deep-research": false } })],
})
export class HelpDeskApp {}

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It calls the tool
// the question names, then answers with what the tool returned. A real server
// doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages[prompt.messages.length - 1];
    if (last.role === "tool") {
      const result = JSON.parse(last.content ?? "{}");
      return { content: result.error ? `I couldn't search: ${result.error}` : `Found: ${result.hits.join(", ")}`, finishReason: "stop" };
    }
    const tool = /search_\w+/.exec(last.content ?? "")?.[0] ?? "search_kb";
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: tool, arguments: { query: "refund policy" } }] };
  },
};
```

```ts agents.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { SearchWeb } from "./agents";
import { model } from "./model.example";

test("an agent whose flag is off is hidden and refused", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["invoke_answer"]);
  const result = await mcp.tools.call("invoke_research", { query: "refunds" });
  expect(result).toBeError();
  expect(result).toHaveTextContent('Tool "invoke_research" is disabled by feature flag "deep-research"');
});

test("the agent's plugin gates the tools inside it", async ({ mcp }) => {
  expect((await mcp.tools.call("invoke_answer", { query: "search_kb: refunds" })).json()).toEqual({
    response: "Found: Help center: refund policy",
  });
  expect((await mcp.tools.call("invoke_answer", { query: "search_web: refunds" })).json()).toEqual({
    response: 'I couldn\'t search: Tool "search_web" is disabled by feature flag "web-search"',
  });
});

test("a plugin on the app doesn't reach an agent's tools", async () => {
  @Agent({ name: "answer", inputSchema: {}, llm: { adapter: model }, tools: [SearchWeb] })
  class AnswerWithoutPlugin extends AgentContext {}

  @App({ id: "help-desk", name: "Help Desk", agents: [AnswerWithoutPlugin], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: {} })] })
  class HelpDesk {}

  const startup = FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });
  await expect(startup).rejects.toThrow(`Tool "answer:search_web" declares 'featureFlag'`);
});
```

---

## Troubleshooting

### ``FeatureFlagPlugin.init() requires an `adapter` option``

A `FeatureFlagConfigurationError`, thrown when `init()` runs: it got no argument, no `adapter`, or one that isn't `"static"`, `"splitio"`, `"launchdarkly"`, `"unleash"` or `"custom"`. The message ends with what it got, `undefined` or the value you passed, and lists the supported adapters. Pass `adapter`, with what it needs: `flags` for `"static"`, `config` for the others, `adapterInstance` for `"custom"`. When the server fails to start with `got undefined`, look for `plugins: [FeatureFlagPlugin]`, the class without `init()`.

### ``FeatureFlagPlugin.init({ adapter: 'custom' }) requires an `adapterInstance` option``

The same error, for `adapter: "custom"` without an `adapterInstance`, or with one that lacks `isEnabled()`, `getVariant()` or `evaluateFlags()`: the message ends with `got undefined` or names the methods missing. Pass an object with all three ([Your own adapter](#your-own-adapter)). With `useFactory`, the server fails to start with it. Before 1.9, the server started and every request answered `500 Internal Server Error`.

### `Provider "[ref]" is not available: not found in local or parent registries`

`tools/list` (or another list) fails with code `PROVIDER_NOT_AVAILABLE` once any entry has a `featureFlag`: the plugin was registered as a class, `plugins: [FeatureFlagPlugin]`, on FrontMCP 1.9.3 or earlier. Use `FeatureFlagPlugin.init({ adapter: ... })`. Since 1.9.4 the class stops the server from starting with [the `adapter` error](#featureflagplugininit-requires-an-adapter-option).

### `Tool "…" is disabled by feature flag "…"`

The flag is off for this caller, or the adapter has no answer for it and neither the ref's `defaultValue` nor the plugin's `gateDefaultValue` is `true`. With a targeted adapter, check what the adapter receives: callers without a token have no `userId`, and `attributes` is `{}` unless you set `attributesResolver`.

### `Unenforced metadata: Tool "…" declares 'featureFlag'`

The server doesn't start (`UnenforcedMetadataError`) because an entry has `featureFlag` and no feature-flag plugin reaches it: the plugin isn't installed, or the entry is a tool declared inside an `@Agent` whose own `plugins` don't have it (the message then names it `"<agent>:<tool>"`). Install the plugin on the server, or on the agent, or remove the field. See [Which entries a plugin gates](#which-entries-a-plugin-gates).

### `tools/list` fails with the flag service's error

The adapter threw while the list was filtered, and lists don't fall back to any default ([When the flag service fails](#when-the-flag-service-fails)).

### `LaunchDarkly SDK not found. Install it: npm install @launchdarkly/node-server-sdk`

Install the SDK. If it's installed and your project is an ES module (`"type": "module"` in `package.json`), the plugin is older than 1.9.0, which couldn't load it there: update every `@frontmcp/*` package to 1.9.0 or later. See [LaunchDarkly, Split and Unleash](#launchdarkly-split-and-unleash). The same goes for `Split.io SDK not found` and `Unleash SDK not found`.

### `Argument of type 'this' is not assignable to parameter of type '{ get: (token: unknown) => unknown; }'`

`getFeatureFlags(this)` or `tryGetFeatureFlags(this)` in a `strict` TypeScript project. Use `this.get(FeatureFlagAccessorToken)` or `this.tryGet(FeatureFlagAccessorToken)`, or just `this.featureFlags`.
