Feature flags plugin

@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 teaches it step by step.

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 or of @FrontMcp (see Which entries a plugin gates). It isn't part of the @frontmcp/plugins umbrella package.

npm install @frontmcp/plugin-feature-flags
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.

Options

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

adapterAlso requiredFlags come from
"static"flags: Record<string, boolean | FeatureFlagVariant>The values you give. See Static.
"launchdarkly"config: { sdkKey }LaunchDarkly. See LaunchDarkly, Split and Unleash.
"splitio"config: { apiKey }Split. See LaunchDarkly, Split and Unleash.
"unleash"config: { url, appName, apiKey? }Unleash. See LaunchDarkly, Split and Unleash.
"custom"adapterInstance: FeatureFlagAdapterYour adapter. See Your own adapter.

Every adapter also takes:

OptionTypeDefaultDescription
defaultValuebooleanfalseWhat 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.
gateDefaultValuebooleanfalseWhat lists and gates answer for an entry whose featureFlag 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.
cacheTtlMsnumber30000How long a cached result lasts, in milliseconds.
userIdResolver(ctx: FrontMcpContext) => string | undefinedthe caller's idThe userId adapters evaluate flags for. See 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. 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, @Resource, @ResourceTemplate, @Prompt and @Skill (and skill()). @Agent has it too, and it gates the agent's invoke_<agent> tool: see Flagging an agent. Jobs and workflows don't have it.

@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 } })
FormMeaning
"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:

RequestWhat happens
tools/list, resources/list, resources/templates/list, prompts/listThe entry is left out.
SkillsLeft out of the skill:// resources, skill://index.json and skills/list, and skills/load can't find it.
tools/call, including this.callTool()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/getRefused: 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/completeRefused the same way, for a prompt or resource template that's off.

The refusal is a FeatureFlagDisabledError, a public error like an 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.

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:

FieldValue
userIduserIdResolver(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.
sessionIdThe 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.
attributesattributesResolver(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() rejects the same way when it's called. Install the plugin, or remove the field.

Plugin registered onRefuses calls, reads and gets of
@FrontMcpEvery app's entries.
An @AppThat app's entries, and those of every app that has no feature-flag plugin of its own.
An @AgentThe 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 and Flagging an agent show each case.

Adapters

Static

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

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

LaunchDarklySplitUnleash
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 11unleash-client 5 or 6
At startinit(sdkKey), then waitForInitialization()SplitFactory({ core: { authorizationKey } }), then client.ready()new Unleash({ url, appName }), then start()
WhoContext { kind: "user", key: userId ?? sessionId ?? "anonymous", ...attributes }Key userId ?? sessionId ?? "anonymous", with attributes{ userId, sessionId, properties: attributes }
Onvariation(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:

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

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

WhereResult
A list: tools/list and the restThe 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 restThe 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Flagging an agent

Clients reach an 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.


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

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.

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

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