Turning Features On and Off

IntermediateMCP 2026-07-28

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:

Open
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") };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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:

Open
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") };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

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

Open
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." };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

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.

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:

Open
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." } }] };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

While a flag is off for the caller:

RequestWhat the caller gets
tools/list, resources/list, resources/templates/list, prompts/listThe entry is left out.
tools/callA tool error with the code FEATURE_FLAG_DISABLED: Tool "bulk_export" is disabled by feature flag "bulk-export".
resources/read, prompts/getJSON-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 covers them.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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 lists each one's options, and When the flag service fails what happens during an outage.

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.

Try some challenges

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

Challenge 1 of 3

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.

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.