Guide · Feature Flags

MCP Tools for LaunchDarkly — Flag evaluation, targeting rules, SDK vs REST API, and health monitoring

LaunchDarkly exposes two distinct surfaces: the REST API v2 (for flag management, configuration reads, and admin operations) and the SDK streaming connection (for real-time flag evaluation, maintained by each application server). MCP tools must understand which surface to use for which operation — and how to detect when the SDK stream has silently reconnected, because LaunchDarkly's /healthcheck endpoint reports process liveness only, not SDK stream health. An SDK that reconnects every 30 seconds indicates backend pressure that will delay flag update propagation, but the management API continues returning HTTP 200 throughout.

TL;DR

Auth: Authorization: Bearer <sdk-key> for SDK endpoints; Authorization: Bearer <access-token> for REST API v2 at https://app.launchdarkly.com/api/v2/. List flags: GET /api/v2/flags/{projectKey}. Get one flag: GET /api/v2/flags/{projectKey}/{featureKey}. Get flag status (active/inactive/archived): GET /api/v2/flag-statuses/{projectKey}/{environmentKey}. Toggle flag targeting: PATCH /api/v2/flags/{projectKey}/{featureKey} with Content-Type: application/json and semantic patch operations. Health probe: GET /api/v2/caller-identity — returns the token's project/environment scope; use this to catch revoked tokens before a flag evaluation call fails.

LaunchDarkly architecture for MCP tool authors

LaunchDarkly's evaluation model separates configuration from evaluation. Flag configurations (targeting rules, variations, rollout percentages) are stored in LaunchDarkly and delivered to server-side SDK instances over a persistent SSE streaming connection. Each SDK instance maintains an in-memory flag store and evaluates flags locally — no network call per evaluation. The REST API v2 is for management: creating flags, reading flag configuration, toggling targeting on/off, listing environments and projects.

Two credential types exist and must never be conflated:

import LaunchDarkly from '@launchdarkly/node-server-sdk';

// Module-level singleton — initialize once, reuse across all MCP tool calls
let ldClient = null;

async function getLDClient(sdkKey) {
  if (ldClient !== null) return ldClient;

  ldClient = LaunchDarkly.init(sdkKey, {
    // Offline mode: evaluate from local JSON file when LaunchDarkly is unreachable
    // offline: true,
    // Bootstrap from REST API snapshot to avoid cold-start latency
    // bootstrapFlagValues: await fetchSnapshotFromCache(),
  });

  // Wait for flag store to be populated from the streaming connection
  await ldClient.waitForInitialization({ timeout: 10 });
  return ldClient;
}

// REST API v2 client for management operations
async function ldRestApi(accessToken, path, method = 'GET', body = null) {
  const res = await fetch(`https://app.launchdarkly.com/api/v2${path}`, {
    method,
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'LD-API-Version': '20240415',
    },
    body: body ? JSON.stringify(body) : null,
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`LaunchDarkly REST API ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`);
  }
  return method === 'DELETE' ? null : res.json();
}

Flag evaluation with targeting context

LaunchDarkly evaluates flags against a context (formerly called "user") — a structured object with a kind and key that targeting rules are evaluated against. The evaluation result is a variation — a specific value from the flag's variation list. The SDK returns the variation value plus an evaluation reason that tells MCP tools why that variation was returned.

// Context shapes for different entity types
const userContext = {
  kind: 'user',
  key: 'user-123',         // required, stable identifier
  email: 'alice@example.com',
  plan: 'enterprise',
  country: 'US',
};

const multiContext = {
  kind: 'multi',
  user: { kind: 'user', key: 'user-123', email: 'alice@example.com' },
  organization: { kind: 'organization', key: 'org-456', plan: 'enterprise' },
};

// Boolean flag evaluation
async function evaluateFlag(sdkKey, flagKey, context, defaultValue = false) {
  const client = await getLDClient(sdkKey);

  // variationDetail returns value + evaluation reason
  const detail = await client.variationDetail(flagKey, context, defaultValue);

  return {
    value: detail.value,           // the variation value
    variationIndex: detail.variationIndex,  // which variation (0-based index)
    reason: detail.reason,         // WHY this variation was returned
    // reason.kind: TARGET_MATCH | RULE_MATCH | PREREQUISITE_FAILED |
    //              FALLTHROUGH | OFF | ERROR
    // reason.ruleIndex: which rule matched (when kind=RULE_MATCH)
    // reason.ruleId: the rule's ID (stable across rule reordering)
    // reason.errorKind: FLAG_NOT_FOUND | MALFORMED_FLAG | WRONG_TYPE | EXCEPTION
  };
}

// String / number / JSON flag evaluation
async function evaluateStringFlag(sdkKey, flagKey, context, defaultValue = '') {
  const client = await getLDClient(sdkKey);
  return client.variationDetail(flagKey, context, defaultValue);
}

// Evaluate all flags for a context in one call (useful for debugging)
async function getAllFlags(sdkKey, context) {
  const client = await getLDClient(sdkKey);
  const state = await client.allFlagsState(context, {
    clientSideOnly: false,
    withReasons: true,    // include evaluation reasons for every flag
    detailsOnlyForTrackedFlags: false,
  });
  return state.toJSON();
}

The evaluation reason is critical for MCP tool transparency. When an LLM asks "why did user X get the control variant?", the reason RULE_MATCH with ruleId: "enterprise-rollout" gives an actionable answer. Returning just the variation value leaves the LLM unable to diagnose unexpected targeting behavior.

Flag management via REST API v2

MCP tools that list, inspect, or toggle flags use the REST API v2. The key patterns are flag status (active vs inactive vs archived), environment-specific targeting state (a flag can be on in production and off in staging), and the semantic patch format for safe flag mutations.

// List all flags in a project with their environment-specific targeting state
async function listFlags(accessToken, projectKey, environmentKey = 'production') {
  // env query param filters the environments[] array in each flag response
  const flags = await ldRestApi(
    accessToken,
    `/flags/${projectKey}?env=${environmentKey}&summary=true`
  );

  return flags.items.map(flag => ({
    key: flag.key,
    name: flag.name,
    description: flag.description,
    kind: flag.kind,  // boolean | multivariate
    archived: flag.archived,
    temporary: flag.temporary,  // temporary flags should have an expiry date
    targeting: flag.environments?.[environmentKey]?.on ?? false,  // is targeting enabled?
    variationCount: flag.variations?.length ?? 0,
    tags: flag.tags,
    maintainer: flag.maintainerId,
  }));
}

// Get detailed flag configuration including all targeting rules
async function getFlag(accessToken, projectKey, flagKey, environmentKey = 'production') {
  const flag = await ldRestApi(
    accessToken,
    `/flags/${projectKey}/${flagKey}?env=${environmentKey}`
  );

  const env = flag.environments?.[environmentKey] ?? {};
  return {
    key: flag.key,
    name: flag.name,
    kind: flag.kind,
    variations: flag.variations,  // [{_id, value, name, description}]
    targeting: {
      on: env.on,
      targets: env.targets ?? [],          // user-specific targets
      rules: env.rules ?? [],              // segment/attribute rules
      fallthrough: env.fallthrough,        // default when no rule matches and targeting=on
      offVariation: env.offVariation,      // which variation when targeting=off
    },
    prerequisites: env.prerequisites ?? [],
  };
}

// Toggle flag targeting on/off using semantic patch (safe concurrent update)
async function toggleFlagTargeting(accessToken, projectKey, flagKey, environmentKey, enable) {
  return ldRestApi(
    accessToken,
    `/flags/${projectKey}/${flagKey}`,
    'PATCH',
    {
      environmentKey,
      instructions: [
        { kind: enable ? 'turnFlagOn' : 'turnFlagOff' }
      ],
    }
  );
}

// Get flag evaluation status — active=requested recently, inactive=not requested, archived
async function getFlagStatuses(accessToken, projectKey, environmentKey) {
  const statuses = await ldRestApi(
    accessToken,
    `/flag-statuses/${projectKey}/${environmentKey}`
  );
  return statuses.items;  // [{_access: {action, time}, name, default, lastRequested}]
}

The semantic patch format (instructions array) is critical for flag mutations. Unlike a standard JSON PATCH, semantic patch instructions are named operations (turnFlagOn, addTargets, addRule) that LaunchDarkly validates for correctness before applying. Standard JSON PATCH is also supported but does not validate business logic constraints — semantic patch is the safe choice for MCP tools.

Impression events and experiment tracking

When a flag is part of an experiment (A/B test), LaunchDarkly requires that evaluation events be sent back to its Events endpoint so it can measure experiment metrics. The Node.js SDK handles this automatically via buffered event flushing. MCP tools that bypass the SDK and call the REST API directly lose experiment data — a flag in an active experiment with no impression data silently produces invalid experiment results.

// The SDK flushes events automatically, but MCP tools should
// flush before process exit to avoid losing buffered impressions
async function gracefulShutdown(sdkKey) {
  const client = await getLDClient(sdkKey);
  await client.flush();
  client.close();
  ldClient = null;
}

// Track a custom metric event (for experiment goals)
async function trackMetric(sdkKey, context, metricKey, value = 1) {
  const client = await getLDClient(sdkKey);
  client.track(metricKey, context, null, value);
  // Events are buffered and flushed on an interval (default 5s)
  // Call client.flush() if you need immediate delivery
}

// Check if a flag is part of an experiment in an environment
async function getFlagExperiments(accessToken, projectKey, flagKey, environmentKey) {
  const flag = await ldRestApi(accessToken, `/flags/${projectKey}/${flagKey}?env=${environmentKey}`);
  const env = flag.environments?.[environmentKey] ?? {};

  const experimentRules = (env.rules ?? []).filter(rule =>
    rule.trackEvents === true || rule.rollout?.experimentAllocation
  );

  return {
    fallthrough: env.trackEventsFallthrough,
    experimentRules: experimentRules.map(r => ({
      ruleId: r._id,
      description: r.description,
      trackEvents: r.trackEvents,
    })),
  };
}

Health check: what the /healthcheck endpoint misses

LaunchDarkly's GET /healthcheck endpoint (at https://app.launchdarkly.com/healthcheck) returns the status of the LaunchDarkly service itself — process liveness, not your SDK's streaming connection health. The SDK stream can be silently disconnecting and reconnecting every few seconds (due to network conditions, token scoping changes, or backend restarts) while /healthcheck returns {"status": "healthy"}. During aggressive reconnection cycles, flag updates take longer to propagate to your application.

async function healthCheck(sdkKey, accessToken) {
  const results = await Promise.allSettled([
    // 1. REST API token validity and scope
    (async () => {
      const identity = await ldRestApi(accessToken, '/caller-identity');
      return {
        kind: 'rest_api',
        tokenType: identity.tokenType,  // personal | service
        role: identity.role,
        projectAccess: identity.inlineRole?.length ?? 'unrestricted',
      };
    })(),

    // 2. SDK initialization — was the streaming connection established?
    (async () => {
      const client = await getLDClient(sdkKey);
      const initialized = client.isInitialized();
      // Evaluate a known stable flag as a synthetic canary
      // (if flagStore is empty, variation() returns the default value,
      //  not an error — so isInitialized() check is mandatory)
      if (!initialized) {
        throw new Error('SDK not initialized — streaming connection failed');
      }
      return { kind: 'sdk', initialized: true };
    })(),

    // 3. Flag store has data (not empty after initialization)
    (async () => {
      const client = await getLDClient(sdkKey);
      const state = await client.allFlagsState({ kind: 'user', key: '__health_probe__' });
      const flags = state.toJSON();
      const flagCount = Object.keys(flags).filter(k => !k.startsWith('$')).length;
      return { kind: 'flag_store', flagCount };
    })(),
  ]);

  const health = {
    healthy: results.every(r => r.status === 'fulfilled'),
    components: results.map(r =>
      r.status === 'fulfilled'
        ? { ...r.value, ok: true }
        : { ok: false, error: r.reason?.message }
    ),
  };

  return health;
}

The flag store count check is the most important probe: an SDK can report isInitialized() = true after receiving an empty streaming payload (e.g., if the SDK key belongs to an environment with zero flags). If flagCount is 0 but you expect flags, investigate: the SDK key might target the wrong environment, or the project may have had all flags archived.

Set AliveMCP alert thresholds: critical when the REST API returns 401 (token revoked) or the SDK fails to initialize within 15 seconds; warning when flagCount drops unexpectedly (could indicate a mass archival accident) or the SDK emits more than 3 reconnecting events per minute.

Common integration pitfalls

Conflating SDK keys with access tokens
SDK keys (sdk-xxx) work with the streaming endpoint only. Passing them to REST API v2 returns 401. Access tokens work with REST API v2 only. Each has a distinct credential prefix and is used in a different Authorization header context.
Multiple SDK client instances
Each LaunchDarkly.init() call opens a new streaming connection. In a long-running server, creating a new client per request multiplies streaming connections and causes event delivery duplication. Always use a module-level singleton initialized once at startup.
Missing await waitForInitialization()
Without waitForInitialization(), the flag store may be empty when your first evaluation call runs. The SDK silently returns the default value (not an error). On cold start, flag evaluations before initialization completes are invisible bugs — all users get the default variation regardless of their targeting rules.
Semantic patch vs standard PATCH confusion
LaunchDarkly supports both application/json (standard PATCH) and semantic patch (instructions array). The same PATCH HTTP method is used for both — the difference is the request body shape. Semantic patch instructions are validated atomically; standard PATCH can produce partial mutations. Always use semantic patch for production MCP tools.
Evaluation in offline mode returning defaults silently
When the SDK is initialized with offline: true or fails to connect, every variation() call returns the default value with reason {kind: "ERROR", errorKind: "CLIENT_NOT_READY"}. The error is in the reason object, not as a thrown exception — log detail.reason for every evaluation in your MCP tool, not just the value.

Related guides