Why Use Feature Flags?
Declarative Gating
Annotate capabilities with
featureFlag metadata — no conditional logic in your tool codeMultiple Providers
Split.io, LaunchDarkly, Unleash, static flags, or your own custom adapter
Per-User Targeting
Evaluate flags per user or session via auth context for targeted rollouts
Execution Gate
Blocks direct
tools/call, resources/read, prompts/get, completion/complete and skill loads that bypass the list filter — no sneaking past disabled flagsInstallation
How It Works
1
Hook Registration
The plugin registers hooks on the list flows for tools, resources, resource templates and prompts, and on the
skills:filter flow that every skill surface runs2
Metadata Collection
When capabilities are listed, the plugin collects
featureFlag refs from each entry’s metadata3
Batch Evaluation
Flags are batch-evaluated via the configured adapter in a single call
4
Capability Filtering
Capabilities with disabled flags are filtered out before reaching the client. Execution gates also block direct
tools/call, resources/read, prompts/get and completion/complete for disabled flags, and a disabled skill is not found when loaded or read, so a flag withholds the capability rather than merely hiding it from the menu.Quick Start
Basic Setup
Annotating a Tool
Programmatic Flag Checks
The plugin extends all execution contexts withthis.featureFlags:
Adapters
Static
Best for: Development, testing, and demos with fixed flag values.Split.io
Requires the
@splitsoftware/splitio peer dependency:LaunchDarkly
Requires the
@launchdarkly/node-server-sdk peer dependency:Unleash
Requires the
unleash-client peer dependency:Custom
Provide your own adapter implementing theFeatureFlagAdapter interface:
FeatureFlagAdapter interface:
Annotating Capabilities
AddfeatureFlag to any tool, resource, resource template, prompt, or skill metadata. Capabilities with disabled flags are hidden from list responses and refused on direct access. The refusal is a public FeatureFlagDisabledError (code FEATURE_FLAG_DISABLED, HTTP 403) whose message names the capability and the flag, in every environment. A client that cached an earlier listing, or holds a URI from a previous session, gets an error rather than the capability.
When the plugin is installed on an app (
@App({ plugins: [FeatureFlagPlugin.init(...)] })), the gates cover every capability the app provides, including those its adapters (for example an OpenAPI adapter) and its plugins contribute. Its tool, resource, prompt and completion gates also cover the flagged capabilities of apps with no feature-flag plugin of their own, but not those of an app that installs its own; install the plugin on @FrontMcp({ plugins }) to gate every app with one adapter. Listings follow the same plugin as the gates: each capability and skill is hidden or listed by the copy of the plugin that judges it when it is called or read, so with a plugin on each of two apps, each app’s flags decide for that app’s entries only. Up to 1.8.3 every copy filtered every app’s listings, so one app’s flags could hide another app’s capability that its own plugin still served. Releases up to 1.8.2 hid another app’s flagged-off tool from tools/list but still ran it when called by name. Resources and prompts the server serves outside every app, such as the SEP-2640 skill:// resources, are gated by every installed copy of the plugin.
@Agent({ featureFlag }) hides and refuses the agent’s invoke_<agent> tool like a tool’s flag. A server where a capability or agent declares featureFlag and no FeatureFlagPlugin reaches it refuses to start with UnenforcedMetadataError, naming the entries. This includes a server with no FeatureFlagPlugin at all, a splitByApp app whose own scope has none, and a tool declared inside an @Agent, which only a plugin installed on that agent gates. Up to 1.8.2 such a server started and served the flagged-off capabilities, and @Agent({ featureFlag }) was ignored.
The toolName completion of ui://widget/{toolName}.html offers only the UI tools tools/list shows the caller: the SDK runs the caller’s tools:list-tools flow for it, so the list filter hides a flagged-off tool there too (#596).
Skill surfaces evaluate flags for the calling user on every transport, stdio and in-memory clients included, so userIdResolver and attributesResolver see the caller rather than an anonymous session.
The skill catalog the server embeds in its initialize instructions (and the SEP-2640 skill:// hints, when skillsConfig.sep2640InInstructions is on) is filtered the same way, for the client that initializes: a flag-disabled skill’s name and description are left out (#603).
A skill’s skill://<path>/SKILL.md entry in resources/list is gated by the skill that path serves now. When a skill is replaced at the same path, for example by a dynamic registration, the entry takes the new skill’s flag (#606).
String Shorthand
Object Form
Use the object form to specify adefaultValue — the fallback when the adapter throws or the flag is unknown (the
adapter has no answer for it: a key the static adapter was not given, or one a custom adapter’s evaluateFlags()
omits; Split.io, LaunchDarkly and Unleash answer every key with the service’s own default):
FeatureFlagRef Fields
When using the string shorthand (
featureFlag: 'key'), defaultValue is false.
Plugin Options
'static' | 'splitio' | 'launchdarkly' | 'unleash' | 'custom'
required
The feature flag provider to use.
FeatureFlagPlugin.init() with no adapter, or an unknown one, throws a
FeatureFlagConfigurationError at startup that names the option and the supported adapters (it used to start and
answer every request with a 500).Record<string, boolean | FeatureFlagVariant>
Static flag values (only used with
adapter: 'static')object
Provider-specific configuration (varies by adapter):
- Split.io:
{ apiKey: string } - LaunchDarkly:
{ sdkKey: string } - Unleash:
{ url: string, appName: string, apiKey?: string }
FeatureFlagAdapter
Custom adapter instance (required with
adapter: 'custom', ignored otherwise). Without one, or with an object that
lacks isEnabled(), getVariant() or evaluateFlags(), FeatureFlagPlugin.init({ adapter: 'custom' }) throws a
FeatureFlagConfigurationError that names adapterInstance and what is missing; with init({ inject, useFactory })
it is thrown at startup. It used to start and answer every request with a 500.boolean
default:"false"
Global fallback for
this.featureFlags.isEnabled() when the adapter throws an error during evaluation or has no
answer for the flag, and the call passes no defaultValue of its own. The gates on tools, resources and prompts use
their ref’s defaultValue instead (false for the string form).'session' | 'request' | 'none'
default:"'none'"
How to cache flag evaluation results:
session— cache per session, expires aftercacheTtlMsrequest— cache per request lifecyclenone— no caching, evaluate every time
number
default:"30000"
Cache TTL in milliseconds (used with
cacheStrategy: 'session')(ctx: FrontMcpContext) => string | undefined
Custom function to extract the user ID from the request context. By default, the plugin reads
authInfo.extra.sub, authInfo.extra.userId, or authInfo.clientId, and passes none for an anonymous caller (an anon: subject, which the server makes up for each request of a caller without a session).(ctx: FrontMcpContext) => Record<string, unknown>
Custom function to extract targeting attributes from the request context. Attributes are passed to the adapter for per-user targeting rules.
sessionId is the session the server verified (authInfo.sessionId). A caller without one gets none, so session-targeted rules fall back to the user or to anonymous. This includes an MCP 2026-07-28 client, which has no session: neither the mcp-session-id it sends nor the id the server makes up for each of its requests is one. Releases up to 1.8.2 passed whatever mcp-session-id the client sent, so a client could pick its own bucket. For the same reason, don’t build identity in these resolvers from ctx.sessionId or from request headers.
API Reference
FeatureFlagAccessor Methods
Access viathis.featureFlags in any execution context (ToolContext, AgentContext, etc.).
Promise<boolean>
Check if a feature flag is enabled. Uses caching if configured.
defaultValue (else the plugin’s defaultValue, else
false) is the answer when the adapter throws or has no answer for the flag, the same rule the gates apply; a flag
the adapter answers keeps its answer, false included. Up to 1.8.7 the default applied only when the adapter threw,
so isEnabled('unknown-flag', true) was false.Promise<FeatureFlagVariant>
Get the variant for a multi-variate flag
Promise<Map<string, boolean>>
Batch evaluate multiple flags at once
Promise<boolean>
Resolve a
FeatureFlagRef (string or object) to a booleanBest Practices
Use static adapter for development
Use static adapter for development
The static adapter requires no external dependencies and gives you instant, predictable flag values during development. Switch to a real adapter (Split.io, LaunchDarkly, Unleash) for staging and production.
Set defaultValue to control failure behavior
Set defaultValue to control failure behavior
When the adapter throws (network error, service down),
defaultValue controls whether the flag is treated as enabled or disabled. Set it globally or per-flag:Use caching with external adapters
Use caching with external adapters
External adapters involve network calls. Use
cacheStrategy: 'session' to avoid repeated evaluations:Use object-form for critical features
Use object-form for critical features
For features that should remain available even when the flag service is down, use the object form with This ensures the tool stays available if the adapter throws.
defaultValue: true:Complete Example
Links & Resources
Source Code
View the feature flags plugin source code
Plugin Guide
Learn more about FrontMCP plugins
Remember Plugin
For session memory storage
Approval Plugin
For tool authorization workflows