How Feature Flags Are Implemented in Desktop Commander MCP

Desktop Commander MCP implements feature flags through a singleton FeatureFlagManager class that loads cached values immediately, fetches fresh configuration from a remote URL in the background, and exposes promise-based accessors for code that needs guaranteed fresh data.

The wonderwhy-er/DesktopCommanderMCP repository uses this manager to control UI A/B tests, onboarding flows, and gradual feature rollouts without blocking server startup. This article breaks down the implementation in src/utils/feature-flags.ts and shows how the rest of the codebase consumes the flag API.


The Singleton Manager Pattern

Desktop Commander MCP exports a single global instance that coordinates all feature flag operations:

export const featureFlagManager = new FeatureFlagManager();

This singleton lives in [src/utils/feature-flags.ts](https://github.com/wonderwhy-er/DesktopCommanderMCP/blob/main/src/utils/feature-flags.ts) and exposes five public methods: get(), getAll(), wasLoadedFromCache(), waitForFreshFlags(), and refresh().

The constructor initializes three critical pieces of state:

  • this.flags — an in-memory object holding the current flag values
  • this.cachePath — a local JSON file at feature-flags.json in the user's config directory
  • this.freshFlagsPromise — a promise that resolves after the first network fetch completes (success or failure)

Startup Flow: Non-Blocking Initialization

The server bootstraps flags without delaying startup. In src/index.ts, you'll find:

await featureFlagManager.initialize();

The initialize() method runs this sequence:

  1. Synchronous cache readthis.loadFromCache() populates this.flags from disk if available
  2. Background network fetchthis.fetchFlags() requests fresh configuration with a 3-second timeout
  3. Periodic refreshsetInterval schedules fetches every 5 minutes (this.cacheMaxAge)
// From feature-flags.ts
async initialize(): Promise<void> {
  await this.loadFromCache();
  // Don't await — let the server start immediately
  this.fetchFlags();
  this.refreshInterval = setInterval(() => this.fetchFlags(), this.cacheMaxAge);
}

The hard timeout uses AbortController with Promise.race to prevent hanging TCP connections:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await Promise.race([
  fetch(url, { signal: controller.signal }),
  new Promise<never>((_, reject) => 
    setTimeout(() => reject(new Error('Timeout')), 3000)
  )
]);

Remote Configuration Source

By default, flags fetch from https://desktopcommander.app/flags/v2/production.json. Operators can override this via environment variable:

const url = process.env.DC_FLAG_URL || 'https://desktopcommander.app/flags/v2/production.json';

The response is validated and written to this.cachePath using standard fs operations:

await fs.writeFile(this.cachePath, JSON.stringify(this.flags), 'utf8');

The Fresh-Flags Promise Pattern

Code that requires guaranteed fresh values—like A/B test assignment on first install—uses waitForFreshFlags():

if (!featureFlagManager.wasLoadedFromCache()) {
  await featureFlagManager.waitForFreshFlags();
}
const variant = featureFlagManager.get('experiment_variant', 'control');

This promise resolves in two cases:

  • Success: After the first network fetch completes and updates this.flags
  • Failure: After the fetch errors, so callers never hang indefinitely

The constructor creates this promise and the fetchFlags() method resolves it:

// Constructor sets up the promise
this.freshFlagsResolve = null;
this.freshFlagsPromise = new Promise((resolve) => {
  this.freshFlagsResolve = resolve;
});

// After first fetch (success or failure)
if (this.freshFlagsResolve) {
  this.freshFlagsResolve();
  this.freshFlagsResolve = null;
}

Real-World Usage Patterns

Basic Flag Access with Defaults

Most code simply calls get() with a fallback:

const onboardingEnabled = featureFlagManager.get('welcome_page_enabled', true);

A/B Test Coordination

The welcome page flow in src/utils/welcome-onboarding.ts demonstrates the full pattern:

const enabled = featureFlagManager.get('welcome_page_enabled', true) !== false;

if (!featureFlagManager.wasLoadedFromCache()) {
  try {
    await featureFlagManager.waitForFreshFlags();
  } catch {
    // Continue with defaults on timeout
  }
}

const showOnboarding = featureFlagManager.get('showOnboardingPage', false);

Admin Refresh and Diagnostics

The config tool in src/tools/config.ts exposes flag state for debugging:

const allFlags = featureFlagManager.getAll();

Tests and admin commands can force a refresh:

await featureFlagManager.refresh();

Cleanup and Lifecycle

The manager includes explicit cleanup via destroy() to prevent timer leaks:

destroy(): void {
  if (this.refreshInterval) {
    clearInterval(this.refreshInterval);
    this.refreshInterval = undefined;
  }
}

This is particularly important for test environments that create and tear down multiple server instances.


Summary

  • Singleton architecture: One FeatureFlagManager instance exported from src/utils/feature-flags.ts serves the entire application
  • Non-blocking startup: initialize() returns immediately after cache load; network fetch runs in background
  • Hard timeouts: 3-second AbortController timeout prevents startup hangs on slow networks
  • Fresh-data promise: waitForFreshFlags() lets critical paths await guaranteed current configuration
  • Local caching: JSON file at feature-flags.json enables offline operation and fast cold starts
  • Periodic refresh: 5-minute interval keeps flags current without excessive requests

Frequently Asked Questions

How does Desktop Commander MCP handle feature flags when offline?

The manager silently continues with cached values if the network request fails. On startup, loadFromCache() populates this.flags from disk before any fetch attempt. If no cache exists, get() calls return their default values until a successful refresh occurs.

What happens if the feature flag endpoint is slow or down?

The fetch uses a 3-second hard timeout via AbortController. If the timeout fires or the request errors, the promise still resolves so waitForFreshFlags() callers don't hang. The manager continues with existing cached values or defaults.

Can I change the feature flag URL without modifying code?

Yes. Set the DC_FLAG_URL environment variable before starting the server. The manager checks process.env.DC_FLAG_URL before falling back to the production default.

Where is the flag cache stored on disk?

The cache location is feature-flags.json in the same directory as the user's config file: path.join(path.dirname(CONFIG_FILE), 'feature-flags.json'). This path is computed in the FeatureFlagManager constructor.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →