# Turning Features On and Off

> How to put a FrontMCP tool, resource or prompt behind a feature flag with @frontmcp/plugin-feature-flags, what a caller sees while its flag is off, and how to turn a flag on for some callers first.

Source: https://frontmcp.dev/learn/turning-features-on-and-off

A new tool is rarely ready for everyone on the day it's merged. You want the help desk team to try it first, then one customer, then everybody, and you want a way to switch it off again without a deploy when something goes wrong. A **feature flag** is a named switch that a flag service answers per caller. `@frontmcp/plugin-feature-flags` connects flags to your server: give a tool, resource or prompt `featureFlag: "bulk-export"`, and while that flag is off for the caller, the entry isn't listed and can't be called.

**You will learn**
- Why a switch inside `execute()` is the wrong way to hide a tool
- How to put a tool, resource or prompt behind a flag
- What a caller sees while a flag is off
- How to make a flag default to on, as a kill switch
- How to turn a flag on for some callers, and read it inside a tool

## A tool that isn't ready yet

The help desk is building `bulk_export`, which exports every ticket as CSV. It works, but it's slow on big accounts, so it shouldn't be used yet. A first attempt keeps it switched off with a constant:

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

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title",
  inputSchema: { query: z.string() },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

// 🚩 Not ready: switched off in code
const BULK_EXPORT_READY = false;

@Tool({
  name: "bulk_export",
  description: "Export every support ticket as CSV",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})
export class BulkExport extends ToolContext {
  async execute() {
    if (!BULK_EXPORT_READY) this.fail(new PublicMcpError("Bulk export isn't available yet."));
    return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") };
  }
}
```

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

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

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

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];
```

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

test("🚩 the model is offered bulk_export", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("bulk_export");
});

test("🚩 and every call to it fails", async ({ mcp }) => {
  const result = await mcp.tools.call("bulk_export", {});
  expect(result).toBeError();
  expect(result).toHaveTextContent("Bulk export isn't available yet.");
});
```

Nobody can export, so in that sense it works. But the model still reads `bulk_export` in its tool list, with a description that promises an export, and it only learns the truth after spending a call on it. And the switch is all or nothing: turning it on means a deploy, for every caller at once. There's no way to let the team try it first.

## Putting a tool behind a flag

Install the plugin:

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

Then give the tool a `featureFlag`, and register the plugin with the flags' values. The tool loses its `if`:

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

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title",
  inputSchema: { query: z.string() },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

@Tool({
  name: "bulk_export",
  description: "Export every support ticket as CSV",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  featureFlag: "bulk-export", // ✅ exists only while this flag is on
})
export class BulkExport extends ToolContext {
  async execute() {
    return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") };
  }
}
```

```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],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "bulk-export": false } })],
})
export class HelpDeskApp {}

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

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];
```

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

test("bulk_export isn't listed while its flag is off", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).not.toContainTool("bulk_export");
  expect(tools).toContainTool("search_tickets");
});

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

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

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

test("🚩 without the plugin, the server doesn't start", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport] })
  class WithoutPlugin {}

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

Open the **Capabilities** tab: only `search_tickets` is listed. Now change `false` to `true` in `main.ts`, and `bulk_export` is back, working.

- **`featureFlag: "bulk-export"`** names the flag that decides whether the tool exists for this caller. The key is any string; it's the name the flag has in your flag service.
- **`FeatureFlagPlugin.init({ adapter, ... })`** registers the plugin. The **adapter** is where flag values come from. `"static"` takes them from `flags`, the same for every caller: good for development, for tests, and for a switch you're happy to change with a deploy. The [last section](#turning-a-flag-on-for-some-callers) connects a flag service.
- **A call to a tool whose flag is off** is refused before `execute()` runs, with the message above. That covers clients that call the tool by name, for example from a list they fetched before the flag changed.

A tool with `featureFlag` and no plugin to check it is a mistake FrontMCP won't let through: the server refuses to start with `Unenforced metadata: Tool "bulk_export" declares 'featureFlag' (enforced by FeatureFlagPlugin …)`, as the last test shows. FrontMCP refuses, rather than list and run the tool for everyone.

> **Pitfall: Register the plugin with init()**
`FeatureFlagPlugin.init()` with no options, or with an `adapter` it doesn't know, throws a `FeatureFlagConfigurationError` when the module loads, naming the supported adapters. `plugins: [FeatureFlagPlugin]`, the class without `init()`, has no adapter either, so the server doesn't start, with the same error. Always pass the options, with at least `adapter`. (Before FrontMCP 1.9.4 the class started, and then `tools/list` failed for every caller with `Provider "[ref]" is not available`.)

> **Note**
On an `@App`, as here, the plugin checks that app's entries, and those of any app without a feature-flag plugin of its own. On `@FrontMcp`, it checks every app's. [Which entries a plugin gates](https://frontmcp.dev/reference/plugins/feature-flags#which-entries-a-plugin-gates) has the details.

## On by default: kill switches

A flag the adapter has no value for counts as off. That's the right default for something new: `bulk_export` stays hidden until someone decides otherwise. For a tool people already depend on, you want the opposite: on, unless someone switches it off in an emergency. Give that tool `featureFlag: { key, defaultValue: true }`:

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

@Tool({
  name: "create_ticket",
  description: "Open a support ticket",
  inputSchema: { title: z.string() },
  // ✅ A kill switch: on unless the flag is set to false
  featureFlag: { key: "ticket-intake", defaultValue: true },
})
export class CreateTicket extends ToolContext {
  async execute({ title }: { title: string }) {
    return { id: "T-4", title, status: "open" };
  }
}

@Tool({
  name: "ai_summary",
  description: "Summarize a ticket's thread",
  inputSchema: { id: z.string() },
  featureFlag: "ai-summary", // new: off until turned on
})
export class AiSummary extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, summary: "The customer can't log in since this morning." };
  }
}
```

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [CreateTicket, AiSummary],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: {} })], // neither flag has a value
})
export class HelpDeskApp {}

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

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

/** The tools a server lists with these flag values. */
async function listedWith(flags: Record<string, boolean>, gateDefaultValue = false) {
  @App({ id: "help-desk", name: "Help Desk", tools: [CreateTicket, AiSummary], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags, gateDefaultValue })] })
  class HelpDesk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });
  const { tools } = await server.listTools();
  await server.dispose();
  return tools.map((t: { name: string }) => t.name).sort();
}

test("a flag with no value is off, unless the tool gives a defaultValue", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["create_ticket"]);
});

test("setting the flags turns each tool on or off", async () => {
  expect(await listedWith({ "ai-summary": true })).toEqual(["ai_summary", "create_ticket"]);
  expect(await listedWith({ "ticket-intake": false })).toEqual([]); // the kill switch
});

test("`gateDefaultValue: true` turns on every flag with no value; a flag set to false stays off", async () => {
  expect(await listedWith({}, true)).toEqual(["ai_summary", "create_ticket"]);
  expect(await listedWith({ "ai-summary": false }, true)).toEqual(["create_ticket"]);
});
```

`defaultValue` only fills in when the adapter has no answer for the flag, or fails while a call is checked. A flag set to `false` stays off, whatever the tool's `defaultValue` says, so the kill switch works.

"No answer" means the adapter left the flag out: the static adapter does that for keys you didn't configure. LaunchDarkly, Split and Unleash answer every key, with their own default for one they don't know, which is off. So with a flag service, create the kill switch there, set to on. The tool's `defaultValue: true` still matters: if the service fails while a call is checked, the call goes through. (A list fails outright during an outage; see [When the flag service fails](https://frontmcp.dev/reference/plugins/feature-flags#when-the-flag-service-fails).)

The plugin can flip that default for every tool at once: `FeatureFlagPlugin.init({ adapter, flags, gateDefaultValue: true })` treats every flag without a value as on, for the tools that don't give a `defaultValue` of their own, as the last test shows. That fails open: a new tool is out for everyone the moment it ships, and those tools stay callable while the flag service is down. Keep the default, `false`, and give each kill switch its own `defaultValue: true`.

> **Pitfall: A misspelled flag hides the tool**
Because a flag without a value is off, a typo is silent: `featureFlag: "bulk_export"` on the tool and `"bulk-export": true` in the flags hides the tool for everyone, with no error. Keep flag keys in one place, like `export const BULK_EXPORT = "bulk-export"`, and use the constant on both sides.

## Flagging resources and prompts

`@Resource`, `@ResourceTemplate` and `@Prompt` take `featureFlag` too. The help desk's new insights, a ticket statistics resource and a weekly review prompt, share one flag, `insights`, which is off:

```ts insights.ts active
import { Prompt, PromptContext, Resource, ResourceContext, 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: 2, closed: 1 };
  }
}

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

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

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

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

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

test("neither is listed", async ({ mcp }) => {
  expect(await mcp.resources.list()).not.toContainResource("tickets://stats");
  expect(await mcp.prompts.list()).toHaveLength(0);
});

test("reading the resource is refused with a JSON-RPC error", async ({ mcp }) => {
  const read = await mcp.raw.request({ method: "resources/read", params: { uri: "tickets://stats" } });
  expect(read.error).toMatchObject({ code: -32003, message: 'Resource "ticket_stats" is disabled by feature flag "insights"', data: { code: "FEATURE_FLAG_DISABLED" } });
});

test("so is getting the prompt", async ({ mcp }) => {
  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"');
});
```

While a flag is off for the caller:

| Request | What the caller gets |
| --- | --- |
| `tools/list`, `resources/list`, `resources/templates/list`, `prompts/list` | The entry is left out. |
| `tools/call` | A tool error with the code `FEATURE_FLAG_DISABLED`: `Tool "bulk_export" is disabled by feature flag "bulk-export"`. |
| `resources/read`, `prompts/get` | JSON-RPC error `-32003`, with `data.code` `FEATURE_FLAG_DISABLED`: `Resource "ticket_stats" is disabled by feature flag "insights"`, or the same for a prompt. |

Those messages name the flag, and they reach the caller as written, in production too: a refusal is a public error, so a caller who tries a hidden entry by name learns its flag's key. Keep flag keys you don't mind showing. Skills and agents take `featureFlag` too; the [plugin's reference](https://frontmcp.dev/reference/plugins/feature-flags#the-featureflag-option) covers them.

> **Note**
Changed in 1.8.7: a refusal used to be wrapped as an internal error. Its `_meta.code` was `SERVER_ERROR`, a resource or prompt answered `-32603`, in development the text went on with `Original error:` and a stack trace, and in production the caller read `Internal FrontMCP error. Please contact support with error ID: …` instead of the message.

## Turning a flag on for some callers

A static adapter gives every caller the same answer. A flag service, like LaunchDarkly, Split or Unleash, answers per caller: on for the help desk team, on for one customer's tenant, on for 10% of users. The plugin asks it on every request, about the caller who sent it, and passes it:

- **`userId`**: the caller's id from their token, `this.auth.user.sub`.
- **`attributes`**: whatever your `attributesResolver` returns, like the caller's tenant or plan.

Here a small custom adapter stands in for the flag service: `bulk-export` is on for the `acme` tenant, and `ai-summary` for the support agent `sam`. The tests call the server in-process as two signed-in agents, `nour` from `acme`, and `sam`, who has no tenant; the Playground's own calls are anonymous:

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

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

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

@Tool({ name: "export_formats", description: "List the formats this user can export tickets in", 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: new RolloutAdapter({ "bulk-export": { tenants: ["acme"] }, "ai-summary": { users: ["sam"] } }),
      // The caller's tenant, from a claim in their token
      attributesResolver: (ctx) => ({ tenant: (ctx.authInfo as { user?: { tenant?: string } }).user?.tenant }),
    }),
  ],
})
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

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

```ts rollout-adapter.ts
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 a flag service. */
export class RolloutAdapter implements FeatureFlagAdapter {
  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 };
  }

  // What lists, calls and this.featureFlags.isEnabled() use. Flags without a rule are left out, so a defaultValue applies to them.
  async evaluateFlags(flagKeys: string[], context: FeatureFlagContext): Promise<Map<string, boolean>> {
    const results = new Map<string, boolean>();
    for (const key of flagKeys) {
      if (key in this.rules) results.set(key, await this.isEnabled(key, context));
    }
    return results;
  }
}
```

```ts targeting.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

// Two signed-in support agents, with the claims their tokens would carry
const nour = { authContext: { user: { sub: "nour", tenant: "acme" } } };
const sam = { authContext: { user: { sub: "sam" } } };

test("each agent sees the tools their flags turn on", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const names = async (as: object) => (await server.listTools(as)).tools.map((t: { name: string }) => t.name).sort();
  expect(await names(nour)).toEqual(["bulk_export", "export_formats"]);
  expect(await names(sam)).toEqual(["ai_summary", "export_formats"]);
  await server.dispose();
});

test("a tool is refused to an agent whose flag is off", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  expect((await server.callTool("bulk_export", {}, nour)).structuredContent).toMatchObject({ csv: expect.any(String) });
  await expect(server.callTool("bulk_export", {}, sam)).rejects.toThrow('Tool "bulk_export" is disabled by feature flag "bulk-export"');
  await server.dispose();
});

test("this.featureFlags answers for the caller", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  expect((await server.callTool("export_formats", {}, nour)).structuredContent).toEqual({ formats: ["pdf", "csv"] });
  expect((await server.callTool("export_formats", {}, sam)).structuredContent).toEqual({ formats: ["pdf"] });
  await server.dispose();
});

test("an anonymous caller gets neither", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["export_formats"]);
});
```

Open the **Capabilities** tab: the Playground calls anonymously, so it only sees `export_formats`, and gets `["pdf"]` from it. The tests show the other two callers.

- **`adapter: "custom"`** with `adapterInstance` takes any object with the adapter's five methods: `init()` throws a `FeatureFlagConfigurationError` when it lacks `isEnabled()`, `getVariant()` or `evaluateFlags()`. `evaluateFlags()` is what lists, calls and `this.featureFlags.isEnabled()` use; leave out a key you have no answer for, so the `defaultValue` applies.
- **`attributesResolver`** receives the request's context. `ctx.authInfo.user` holds the claims of the caller's token, like `tenant` here.
- **`this.featureFlags`**, which the plugin adds to every tool, resource and prompt of the apps it's registered for, reads a flag inside `execute()`, for the same caller. Use it when a flag changes what a tool does rather than whether it exists, as `export_formats` does.

An anonymous caller has no `userId`, so a flag service can't tell one from another: a rollout to 10% of users gives all of them the same answer. Target signed-in callers.

With a real flag service, only the adapter changes. For LaunchDarkly, install its SDK (`npm install @launchdarkly/node-server-sdk`) and keep the resolver:

```ts
FeatureFlagPlugin.init({
  adapter: "launchdarkly",
  config: { sdkKey: process.env.LAUNCHDARKLY_SDK_KEY! },
  attributesResolver: (ctx) => ({ tenant: (ctx.authInfo as { user?: { tenant?: string } }).user?.tenant }),
});
```

`"splitio"` and `"unleash"` work the same way, with their own `config`. [LaunchDarkly, Split and Unleash](https://frontmcp.dev/reference/plugins/feature-flags#launchdarkly-split-and-unleash) lists each one's options, and [When the flag service fails](https://frontmcp.dev/reference/plugins/feature-flags#when-the-flag-service-fails) what happens during an outage.

> **Note**
`this.featureFlags.isEnabled("new-editor", true)` gives `true` for a flag the adapter has no answer for, as `defaultValue` does on an entry, and when the adapter fails. Changed in 1.9: it used its default only when the adapter threw, and the static adapter said `false` for a key it didn't know.

## Recap

- A switch inside `execute()` still lists the tool, so the model finds out it's off only by calling it. `featureFlag` on the entry hides it instead.
- `@frontmcp/plugin-feature-flags` adds `featureFlag` to tools, resources, resource templates, prompts, skills and agents. Register it with `FeatureFlagPlugin.init({ adapter, ... })`; an entry with `featureFlag` and no plugin stops the server from starting.
- While a flag is off for the caller, the entry is left out of lists, and calls, reads and gets are refused.
- A flag without a value is off. `featureFlag: { key, defaultValue: true }` makes it on until it's set to `false`, for kill switches.
- A flag service answers per caller, from their `userId` and the `attributes` your `attributesResolver` returns. `this.featureFlags` reads a flag inside a tool, for the same caller.
- Every option, the adapters, and what happens when the flag service fails are in the [feature flags plugin reference](https://frontmcp.dev/reference/plugins/feature-flags).

## Try some challenges

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

### Challenge: Hide the merge tool
`merge_tickets` isn't finished, and the plugin already has a `ticket-merge` flag for it, set to `false`. The tool is still listed and still runs. Put it behind the flag.

```ts tools.ts active
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: "merge_tickets",
  description: "Merge a duplicate ticket into another one",
  inputSchema: { from: z.string(), into: z.string() },
})
export class MergeTickets extends ToolContext {
  async execute({ from, into }: { from: string; into: string }) {
    return { merged: from, into };
  }
}
```

```ts tools.ts solution
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: "merge_tickets",
  description: "Merge a duplicate ticket into another one",
  inputSchema: { from: z.string(), into: z.string() },
  featureFlag: "ticket-merge",
})
export class MergeTickets extends ToolContext {
  async execute({ from, into }: { from: string; into: string }) {
    return { merged: from, into };
  }
}
```

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, MergeTickets],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "ticket-merge": false } })],
})
export class HelpDeskApp {}

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

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

test("`merge_tickets` isn't listed", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("merge_tickets");
});

test("calling it is refused by the `ticket-merge` flag", async ({ mcp }) => {
  const result = await mcp.tools.call("merge_tickets", { from: "T-3", into: "T-1" });
  expect(result).toBeError();
  expect(result).toHaveTextContent('disabled by feature flag "ticket-merge"');
});

test("`get_ticket` is still listed and works", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("get_ticket");
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful();
});
```

**Hint:**
The flag's value is already in `main.ts`. What's missing is the link from the tool to the flag's key.

**Solution:**
`featureFlag: "ticket-merge"` ties the tool to the flag, and the plugin, already registered on the app, does the rest: while the flag is `false`, `merge_tickets` is left out of `tools/list` and refused when called by name. Setting the flag to `true` in `main.ts` brings it back without touching the tool.

### Challenge: Make ticket intake a kill switch
Someone put `create_ticket` behind a `ticket-intake` flag, so it can be switched off if the ticket database struggles. But the flags in `main.ts` don't set it, so the tool vanished for everyone. Make `create_ticket` stay on while the flag has no value, and still go away when the flag is set to `false`.

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

@Tool({
  name: "create_ticket",
  description: "Open a support ticket",
  inputSchema: { title: z.string() },
  featureFlag: "ticket-intake",
})
export class CreateTicket extends ToolContext {
  async execute({ title }: { title: string }) {
    return { id: "T-4", title, status: "open" };
  }
}
```

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

@Tool({
  name: "create_ticket",
  description: "Open a support ticket",
  inputSchema: { title: z.string() },
  featureFlag: { key: "ticket-intake", defaultValue: true },
})
export class CreateTicket extends ToolContext {
  async execute({ title }: { title: string }) {
    return { id: "T-4", title, status: "open" };
  }
}
```

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [CreateTicket],
  plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: {} })],
})
export class HelpDeskApp {}

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

```ts intake.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags";
import { CreateTicket } from "./tools";

async function listedWith(flags: Record<string, boolean>) {
  @App({ id: "help-desk", name: "Help Desk", tools: [CreateTicket], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags })] })
  class HelpDesk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });
  const { tools } = await server.listTools();
  await server.dispose();
  return tools.map((t: { name: string }) => t.name);
}

test("with no value for `ticket-intake`, `create_ticket` is listed and works", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("create_ticket");
  expect(await mcp.tools.call("create_ticket", { title: "Printer on fire" })).toBeSuccessful();
  expect(await listedWith({})).toEqual(["create_ticket"]);
});

test("setting `ticket-intake` to false still switches it off", async () => {
  expect(await listedWith({ "ticket-intake": false })).toEqual([]);
});
```

**Hint:**
`featureFlag` also takes an object, with the key and what to assume when the adapter has no value for it.

**Solution:**
`featureFlag: { key: "ticket-intake", defaultValue: true }` treats a flag without a value as on, so the tool is back for everyone. A value of `false` still wins over `defaultValue`, so the switch still works. Adding `"ticket-intake": true` to `main.ts` would bring the tool back too, but only for that one configuration: the checks also start servers whose flags don't set it. The default belongs on the tool.

### Challenge: Roll out bulk export to one tenant
The rollout adapter turns `bulk-export` on for the `acme` tenant, and `nour`'s token says she's from `acme`. But she doesn't see `bulk_export`: the adapter never learns her tenant. Pass it on, from the `tenant` claim of the caller's token.

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

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

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, BulkExport],
  plugins: [
    FeatureFlagPlugin.init({
      adapter: "custom",
      adapterInstance: new RolloutAdapter({ "bulk-export": { tenants: ["acme"] } }),
    }),
  ],
})
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

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

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

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

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, BulkExport],
  plugins: [
    FeatureFlagPlugin.init({
      adapter: "custom",
      adapterInstance: new RolloutAdapter({ "bulk-export": { tenants: ["acme"] } }),
      attributesResolver: (ctx) => ({ tenant: (ctx.authInfo as { user?: { tenant?: string } }).user?.tenant }),
    }),
  ],
})
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

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

```ts rollout-adapter.ts
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 a flag service. */
export class RolloutAdapter implements FeatureFlagAdapter {
  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>> {
    const results = new Map<string, boolean>();
    for (const key of flagKeys) {
      if (key in this.rules) results.set(key, await this.isEnabled(key, context));
    }
    return results;
  }
}
```

```ts rollout.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

const nour = { authContext: { user: { sub: "nour", tenant: "acme" } } };
const sam = { authContext: { user: { sub: "sam", tenant: "globex" } } };

async function listedFor(as: object) {
  const server = await FrontMcpInstance.createDirect(config);
  const { tools } = await server.listTools(as);
  await server.dispose();
  return tools.map((t: { name: string }) => t.name).sort();
}

test("`nour`, from acme, sees `bulk_export`", async () => {
  expect(await listedFor(nour)).toEqual(["bulk_export", "search_tickets"]);
});

test("`sam`, from another tenant, doesn't", async () => {
  expect(await listedFor(sam)).toEqual(["search_tickets"]);
});

test("neither does an anonymous caller", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("bulk_export");
});
```

**Hint:**
The adapter checks `attributes.tenant`. Which plugin option decides what `attributes` holds?

**Solution:**
`attributesResolver` runs on every request, and what it returns reaches the adapter as `attributes`. Reading `tenant` from `ctx.authInfo.user`, the caller's token claims, gives the adapter `acme` for `nour`, so `bulk-export` is on for her, and something else, or nothing, for everyone else.
