> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Feature Flags Plugin

> Dynamic capability gating with Split.io, LaunchDarkly, Unleash, and custom adapters.

The Feature Flags Plugin gates tools, resources, prompts, and skills based on feature flag evaluation — enabling progressive rollouts, A/B testing, and dynamic capability management for FrontMCP servers.

## Why Use Feature Flags?

<CardGroup cols={2}>
  <Card title="Declarative Gating" icon="toggle-on">
    Annotate capabilities with `featureFlag` metadata — no conditional logic in your tool code
  </Card>

  <Card title="Multiple Providers" icon="plug">
    Split.io, LaunchDarkly, Unleash, static flags, or your own custom adapter
  </Card>

  <Card title="Per-User Targeting" icon="user">
    Evaluate flags per user or session via auth context for targeted rollouts
  </Card>

  <Card title="Execution Gate" icon="shield">
    Blocks direct `tools/call`, `resources/read`, `prompts/get`, `completion/complete` and skill loads that bypass the list filter — no sneaking past disabled flags
  </Card>
</CardGroup>

## Installation

```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
npm install @frontmcp/plugin-feature-flags
```

## How It Works

<Steps>
  <Step title="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 runs
  </Step>

  <Step title="Metadata Collection">
    When capabilities are listed, the plugin collects `featureFlag` refs from each entry's metadata
  </Step>

  <Step title="Batch Evaluation">
    Flags are batch-evaluated via the configured adapter in a single call
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

## Quick Start

### Basic Setup

<CodeGroup>
  ```ts Class-based App theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  import { FrontMcp, App } from '@frontmcp/sdk';
  import { FeatureFlagPlugin } from '@frontmcp/plugin-feature-flags';

  @App({
    id: 'my-app',
    name: 'My App',
    plugins: [
      FeatureFlagPlugin.init({
        adapter: 'static',
        flags: { 'beta-search': true, 'experimental-agent': false },
      }),
    ],
    tools: [/* your tools */],
  })
  class MyApp {}

  @FrontMcp({
    info: { name: 'My Server', version: '1.0.0' },
    apps: [MyApp],
  })
  export default class Server {}
  ```

  ```ts FrontMcp decorator theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  import { FrontMcp } from '@frontmcp/sdk';
  import { FeatureFlagPlugin } from '@frontmcp/plugin-feature-flags';

  @FrontMcp({
    info: { name: 'My Server', version: '1.0.0' },
    plugins: [
      FeatureFlagPlugin.init({
        adapter: 'static',
        flags: { 'beta-search': true, 'experimental-agent': false },
      }),
    ],
  })
  export default class Server {}
  ```
</CodeGroup>

### Annotating a Tool

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { Tool, ToolContext } from '@frontmcp/sdk';
import { z } from '@frontmcp/sdk';

@Tool({
  name: 'beta-search',
  description: 'Search with beta features enabled',
  inputSchema: { query: z.string() },
  featureFlag: 'beta-search', // Hidden when flag is off
})
export default class BetaSearchTool extends ToolContext {
  async execute(input: { query: string }) {
    return `Beta results for: ${input.query}`;
  }
}
```

### Programmatic Flag Checks

The plugin extends all execution contexts with `this.featureFlags`:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Tool({
  name: 'smart-tool',
  description: 'A tool that adapts based on flags',
  inputSchema: { query: z.string() },
})
export default class SmartTool extends ToolContext {
  async execute(input: { query: string }) {
    const useBeta = await this.featureFlags.isEnabled('beta-algorithm');

    if (useBeta) {
      return this.betaSearch(input.query);
    }
    return this.standardSearch(input.query);
  }
}
```

***

## Adapters

### Static

Best for: Development, testing, and demos with fixed flag values.

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
FeatureFlagPlugin.init({
  adapter: 'static',
  flags: {
    'beta-search': true,
    'experimental-agent': false,
    'premium-feature': true,
  },
})
```

### Split.io

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
FeatureFlagPlugin.init({
  adapter: 'splitio',
  config: {
    apiKey: process.env.SPLITIO_API_KEY!,
  },
})
```

<Info>
  Requires the `@splitsoftware/splitio` peer dependency:

  ```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  npm install @splitsoftware/splitio
  ```
</Info>

### LaunchDarkly

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
FeatureFlagPlugin.init({
  adapter: 'launchdarkly',
  config: {
    sdkKey: process.env.LAUNCHDARKLY_SDK_KEY!,
  },
})
```

<Info>
  Requires the `@launchdarkly/node-server-sdk` peer dependency:

  ```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  npm install @launchdarkly/node-server-sdk
  ```
</Info>

### Unleash

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
FeatureFlagPlugin.init({
  adapter: 'unleash',
  config: {
    url: process.env.UNLEASH_URL!,
    appName: 'my-mcp-server',
    apiKey: process.env.UNLEASH_API_KEY,
  },
})
```

<Info>
  Requires the `unleash-client` peer dependency:

  ```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  npm install unleash-client
  ```
</Info>

### Custom

Provide your own adapter implementing the `FeatureFlagAdapter` interface:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import type { FeatureFlagAdapter } from '@frontmcp/plugin-feature-flags';

const myAdapter: FeatureFlagAdapter = {
  async initialize() { /* connect to your service */ },
  async isEnabled(flagKey, context) {
    return await myService.checkFlag(flagKey, context.userId);
  },
  async getVariant(flagKey, context) {
    const variant = await myService.getVariant(flagKey, context.userId);
    return { name: variant.name, value: variant.value, enabled: variant.enabled };
  },
  async evaluateFlags(flagKeys, context) {
    const results = new Map<string, boolean>();
    for (const key of flagKeys) {
      results.set(key, await myService.checkFlag(key, context.userId));
    }
    return results;
  },
  async destroy() { /* cleanup */ },
};

FeatureFlagPlugin.init({
  adapter: 'custom',
  adapterInstance: myAdapter,
})
```

The `FeatureFlagAdapter` interface:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
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>;
}
```

***

## Annotating Capabilities

Add `featureFlag` 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.

| Capability | Hidden from | Refused on |
| - | - | - |
| Tool | `tools/list`, and the `toolName` completion of `ui://widget/{toolName}.html` | `tools/call` |
| Resource | `resources/list` | `resources/read`, `completion/complete` |
| Resource template | `resources/templates/list` | `resources/read` of any URI it matches, `completion/complete` |
| Prompt | `prompts/list` | `prompts/get`, `completion/complete` |
| Skill | `skills/search`, `skills/list`, `skill://index.json`, its `skill://<path>/SKILL.md` entry in `resources/list`, `GET /skills`, `/llm.txt`, `/llm_full.txt`, CodeCall's `codecall:searchSkills` and `codecall:searchKnowledge` | `skills/load`, `skill://<path>/SKILL.md` and its files, `GET /skills/{id}` (answered exactly like a skill that does not exist) |

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](https://github.com/agentfront/frontmcp/issues/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](https://github.com/agentfront/frontmcp/issues/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](https://github.com/agentfront/frontmcp/issues/606)).

### String Shorthand

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Tool({ name: 'beta-search', featureFlag: 'beta-search' })
@Resource({ uri: 'data://beta', featureFlag: 'beta-data' })
@ResourceTemplate({ name: 'beta-reports', uriTemplate: 'reports://beta/{id}', featureFlag: 'beta-reports' })
@Prompt({ name: 'beta-prompt', featureFlag: 'beta-prompt' })
@Skill({ name: 'beta-skill', featureFlag: 'beta-skill' })
```

### Object Form

Use the object form to specify a `defaultValue` — the fallback when the adapter throws or the flag is unknown:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Tool({
  name: 'critical-tool',
  featureFlag: { key: 'critical-tool', defaultValue: true }, // fail-open
})
```

### FeatureFlagRef Fields

| Field | Type | Description |
| - | - | - |
| `key` | `string` | The flag key to evaluate |
| `defaultValue` | `boolean` | Fallback value when evaluation fails (default: `false`) |

When using the string shorthand (`featureFlag: 'key'`), `defaultValue` is `false`.

***

## Plugin Options

<ParamField path="adapter" type="'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).
</ParamField>

<ParamField path="flags" type="Record<string, boolean | FeatureFlagVariant>">
  Static flag values (only used with `adapter: 'static'`)
</ParamField>

<ParamField path="config" type="object">
  Provider-specific configuration (varies by adapter):

  * **Split.io**: `{ apiKey: string }`
  * **LaunchDarkly**: `{ sdkKey: string }`
  * **Unleash**: `{ url: string, appName: string, apiKey?: string }`
</ParamField>

<ParamField path="adapterInstance" type="FeatureFlagAdapter">
  Custom adapter instance (only used with `adapter: 'custom'`)
</ParamField>

<ParamField path="defaultValue" type="boolean" default="false">
  Global fallback when the adapter throws an error during evaluation
</ParamField>

<ParamField path="cacheStrategy" type="'session' | 'request' | 'none'" default="'none'">
  How to cache flag evaluation results:

  * `session` — cache per session, expires after `cacheTtlMs`
  * `request` — cache per request lifecycle
  * `none` — no caching, evaluate every time
</ParamField>

<ParamField path="cacheTtlMs" type="number" default="30000">
  Cache TTL in milliseconds (used with `cacheStrategy: 'session'`)
</ParamField>

<ParamField path="userIdResolver" type="(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).
</ParamField>

<ParamField path="attributesResolver" type="(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.
</ParamField>

The evaluation context's `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 via `this.featureFlags` in any execution context (ToolContext, AgentContext, etc.).

<ParamField path="isEnabled(flagKey, defaultValue?)" type="Promise<boolean>">
  Check if a feature flag is enabled. Uses caching if configured.

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  const enabled = await this.featureFlags.isEnabled('beta-feature');
  const safe = await this.featureFlags.isEnabled('critical-feature', true); // fail-open
  ```
</ParamField>

<ParamField path="getVariant(flagKey)" type="Promise<FeatureFlagVariant>">
  Get the variant for a multi-variate flag

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  const variant = await this.featureFlags.getVariant('search-algorithm');
  console.log(variant.name);    // e.g., 'v2'
  console.log(variant.value);   // e.g., { boost: 1.5 }
  console.log(variant.enabled); // true
  ```
</ParamField>

<ParamField path="evaluateFlags(flagKeys)" type="Promise<Map<string, boolean>>">
  Batch evaluate multiple flags at once

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  const results = await this.featureFlags.evaluateFlags(['flag-a', 'flag-b', 'flag-c']);
  if (results.get('flag-a')) {
    // flag-a is enabled
  }
  ```
</ParamField>

<ParamField path="resolveRef(ref)" type="Promise<boolean>">
  Resolve a `FeatureFlagRef` (string or object) to a boolean

  ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  const ref = { key: 'my-flag', defaultValue: true };
  const enabled = await this.featureFlags.resolveRef(ref);
  ```
</ParamField>

***

## Best Practices

<AccordionGroup>
  <Accordion title="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.

    ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    FeatureFlagPlugin.init({
      adapter: 'static',
      flags: { 'beta-tools': true, 'experimental-agent': true },
    })
    ```
  </Accordion>

  <Accordion title="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:

    ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    // Global default: treat unknown flags as disabled
    FeatureFlagPlugin.init({
      adapter: 'launchdarkly',
      config: { sdkKey: '...' },
      defaultValue: false,
    })

    // Per-flag: critical features should fail-open
    @Tool({
      name: 'core-tool',
      featureFlag: { key: 'core-tool', defaultValue: true },
    })
    ```
  </Accordion>

  <Accordion title="Use caching with external adapters">
    External adapters involve network calls. Use `cacheStrategy: 'session'` to avoid repeated evaluations:

    ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    FeatureFlagPlugin.init({
      adapter: 'splitio',
      config: { apiKey: '...' },
      cacheStrategy: 'session',
      cacheTtlMs: 30000, // 30 seconds
    })
    ```
  </Accordion>

  <Accordion title="Use object-form for critical features">
    For features that should remain available even when the flag service is down, use the object form with `defaultValue: true`:

    ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    @Tool({
      name: 'payment-processor',
      featureFlag: { key: 'payment-processor', defaultValue: true },
    })
    ```

    This ensures the tool stays available if the adapter throws.
  </Accordion>
</AccordionGroup>

***

## Complete Example

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { FrontMcp, App, Tool, ToolContext, Resource, ResourceContext, Prompt, PromptContext } from '@frontmcp/sdk';
import { FeatureFlagPlugin } from '@frontmcp/plugin-feature-flags';
import { z } from '@frontmcp/sdk';

// Configure with static adapter
const featureFlagPlugin = FeatureFlagPlugin.init({
  adapter: 'static',
  flags: {
    'beta-search': true,
    'experimental-agent': false,
    'premium-reports': true,
  },
  defaultValue: false,
});

// Tool gated by a feature flag
@Tool({
  name: 'beta-search',
  description: 'Search with beta algorithm',
  inputSchema: { query: z.string() },
  featureFlag: 'beta-search',
})
class BetaSearchTool extends ToolContext {
  async execute(input: { query: string }) {
    return `Beta results for: ${input.query}`;
  }
}

// Tool with fail-open behavior
@Tool({
  name: 'always-on-tool',
  description: 'A tool that defaults to enabled',
  inputSchema: { input: z.string() },
  featureFlag: { key: 'always-on', defaultValue: true },
})
class AlwaysOnTool extends ToolContext {
  async execute(input: { input: string }) {
    return `Processed: ${input.input}`;
  }
}

// Tool with programmatic flag check
@Tool({
  name: 'adaptive-tool',
  description: 'Adapts behavior based on flags',
  inputSchema: { data: z.string() },
})
class AdaptiveTool extends ToolContext {
  async execute(input: { data: string }) {
    const usePremium = await this.featureFlags.isEnabled('premium-reports');

    if (usePremium) {
      return `Premium report for: ${input.data}`;
    }
    return `Standard report for: ${input.data}`;
  }
}

// Resource gated by feature flag
@Resource({
  uri: 'report://premium-status',
  name: 'Premium Status Report',
  featureFlag: 'premium-reports',
})
class PremiumStatusResource extends ResourceContext {
  async read() {
    return { contents: [{ uri: 'report://premium-status', text: 'Premium data...' }] };
  }
}

// Prompt gated by feature flag
@Prompt({
  name: 'experimental-analysis',
  description: 'Experimental analysis prompt',
  featureFlag: 'experimental-agent',
})
class ExperimentalPrompt extends PromptContext {
  async execute() {
    return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Analyze...' } }] };
  }
}

@App({
  id: 'flagged-app',
  name: 'Flagged App',
  plugins: [featureFlagPlugin],
  tools: [BetaSearchTool, AlwaysOnTool, AdaptiveTool],
  resources: [PremiumStatusResource],
  prompts: [ExperimentalPrompt],
})
class FlaggedApp {}

@FrontMcp({
  info: { name: 'Feature Flag Server', version: '1.0.0' },
  apps: [FlaggedApp],
  http: { port: 3000 },
})
export default class Server {}
```

***

## Links & Resources

<CardGroup cols={2}>
  <Card title="Source Code" icon="github" href="https://github.com/agentfront/frontmcp/tree/main/plugins/plugin-feature-flags">
    View the feature flags plugin source code
  </Card>

  <Card title="Plugin Guide" icon="puzzle-piece" href="/frontmcp/extensibility/plugins">
    Learn more about FrontMCP plugins
  </Card>

  <Card title="Remember Plugin" icon="brain" href="/frontmcp/plugins/remember-plugin">
    For session memory storage
  </Card>

  <Card title="Approval Plugin" icon="shield-check" href="/frontmcp/plugins/approval-plugin">
    For tool authorization workflows
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.