Dual-Mode MCP Reverse Proxy

Agentic AI Systems intermediate Building with AI ~25 min read

Overview

A dual-mode reverse proxy is a server-side routing layer that sits between a client application and multiple possible backend services, dynamically directing traffic to one of two targets based on a runtime configuration flag. In the context of a desktop AI application, this pattern allows a single Electron application to seamlessly proxy MCP (Model Context Protocol) and REST API calls to either locally running AI backend services or a remote cloud API endpoint — without requiring code changes, restarts, or manual reconfiguration by the user.

The proxy exposes three named routes — /memory, /docs, and /cloud — each of which maps to a logical capability of the AI backend. When operating in local mode, all three routes forward to ports on localhost where local AI services are listening. When cloud mode is activated, the same routes forward to the production API, automatically injecting an ApiKey authorization header that is never sent in local mode. Critically, any subpath appended to a base route — such as /memory/search or /docs/chunks/42 — is preserved verbatim in the upstream request, ensuring that clients need no awareness of which backend is active.

This design addresses a core tension in developer-facing AI tools: local instances offer low-latency, privacy-preserving, offline-capable inference, while the cloud API provides more powerful models, persistent storage, and cross-device access. Rather than forcing users to switch configurations manually or maintain two separate code paths, the dual-mode proxy abstracts backend selection into a single toggle. The hot-swap capability — changing backends at runtime without restarting the proxy — makes the pattern especially powerful for desktop agents that need to remain responsive while the user or an orchestrator flips between modes.

Core Concepts

Route-Based Backend Selection

Routes like /memory, /docs, and /cloud act as stable, semantic entry points. Each route maps to a named capability regardless of where that capability is physically hosted. The proxy’s routing table binds each route name to a resolver function that returns the correct upstream base URL based on the current mode, keeping client request URLs constant across backend changes.

Local vs. Cloud Mode Hot-Swapping

Hot-swapping means the active backend can change while the proxy is running, without dropping existing connections or restarting the server. This is achieved by storing the current mode in a mutable reference (e.g., a shared config object or a closure variable) rather than baking it into route registration at startup. Each incoming request reads the current mode value at dispatch time, so a mode change takes effect immediately for the next request.

Subpath Preservation in Proxy Forwarding

When a client calls /memory/vectors/search?k=5, the proxy must strip only the route prefix (/memory) and forward /vectors/search?k=5 to the upstream base URL. If subpaths are not preserved, every proxied service would need to expose its entire API at the root, breaking REST conventions and MCP endpoint contracts. Correct subpath handling requires careful string slicing or pattern matching against the incoming URL, including query string passthrough.

Conditional Header Injection (ApiKey in Cloud Mode Only)

Security-sensitive headers like ApiKey or Authorization should only be added to requests targeting the cloud endpoint. Injecting auth headers into local requests is unnecessary and potentially dangerous if the local service echoes headers in logs. The proxy checks the active mode before building the upstream request headers, adding the cloud credential only when cloud mode is confirmed. This keeps local traffic clean and avoids leaking credentials to local processes.

MCP Protocol Proxying Alongside REST

The Model Context Protocol uses HTTP as its transport layer (typically SSE streams or JSON-RPC over POST), which means an HTTP reverse proxy can handle MCP traffic transparently. The proxy does not need to understand MCP message semantics — it simply forwards the raw HTTP request and streams back the response. The key requirement is preserving streaming semantics: SSE responses must not be buffered, and the Transfer-Encoding and Content-Type headers from the upstream response must be forwarded unchanged to the client.

How It Works

Route Registration & Backend Resolution

At startup, the proxy server registers a handler for each known route prefix. Each handler captures a reference to the shared mode state object rather than a static URL string. When a request arrives, the handler calls a resolveBackend(route, mode) function that returns the appropriate upstream base URL. The resolver encapsulates all knowledge of which local port each service runs on and what the cloud base URL is, keeping that mapping in a single place.

Route registration typically uses a framework like Express or a built-in Node.js http server. With Express, app.use('/memory', handler) matches all requests under the /memory prefix and makes the remainder of the path available via req.path. This remainder, combined with the resolved upstream base URL, forms the final target URL for the proxied request.

Proxy Request Forwarding

Once the upstream URL is resolved, the proxy constructs an outbound HTTP request that mirrors the incoming one: same method, same body, same content-type, and the preserved subpath. The response from the upstream is piped directly back to the client response stream. For SSE (Server-Sent Events) used by MCP, the proxy must set res.flushHeaders() immediately and pipe the upstream response without buffering, so events are delivered to the client as they arrive rather than batched.

Libraries like http-proxy-middleware or custom node-fetch/undici pipelines are common choices. The key contract is that the proxy is transparent to both the client and the upstream: the client sees a single local endpoint, and the upstream sees a normal HTTP request with no proxy-specific artifacts in the path.

Mode Switching & Header Injection

Mode switching is triggered by an IPC message from the Electron renderer process (e.g., the user clicks a “Use Cloud” toggle in the UI) or by a programmatic call from the agent orchestrator. The main process updates the shared mode object, which is read by every subsequent proxy dispatch. The switch itself is atomic at the JavaScript event-loop level — since Node.js is single-threaded, there is no race condition between reading and writing the mode flag during normal request handling.

Header injection is applied in a pre-dispatch hook. If the resolved mode is 'cloud', the outbound request headers receive an ApiKey field sourced from the application’s secure credential store (e.g., Electron’s safeStorage or an environment variable loaded at startup). If the mode is 'local', the hook is a no-op and no credentials are attached.

Implementation Patterns

The following examples show how to implement the proxy route setup and mode-switching logic in TypeScript within an Electron main process or a standalone Node.js proxy server.

Proxy Route Setup with Backend Resolution

import express, { Request, Response, NextFunction } from 'express';
import { createProxyMiddleware, Options } from 'http-proxy-middleware';

// Shared mutable mode state — read at dispatch time, not at registration
type BackendMode = 'local' | 'cloud';
interface ProxyConfig {
  mode: BackendMode;
  cloudBaseUrl: string;
  cloudApiKey: string;
  localPorts: Record;
}

const config: ProxyConfig = {
  mode: 'local',
  cloudBaseUrl: 'https://api.example.com',
  cloudApiKey: process.env.CLOUD_API_KEY ?? '',
  localPorts: {
    memory: 7701,
    docs:   7702,
    cloud:  7703,
  },
};

function resolveUpstream(route: string, cfg: ProxyConfig): string {
  if (cfg.mode === 'cloud') {
    // All routes share the same cloud base; the route name becomes a path segment
    return `${cfg.cloudBaseUrl}/${route}`;
  }
  const port = cfg.localPorts[route];
  if (!port) throw new Error(`No local port configured for route: ${route}`);
  return `http://localhost:${port}`;
}

function buildProxyOptions(route: string): Options {
  return {
    // target is resolved dynamically per-request via router function
    router: (_req) => resolveUpstream(route, config),
    changeOrigin: true,
    // Strip the leading / prefix before forwarding
    pathRewrite: { [`^/${route}`]: '' },
    // Forward SSE responses without buffering
    selfHandleResponse: false,
    on: {
      proxyReq: (proxyReq, req, _res) => {
        // Inject ApiKey only when cloud mode is active
        if (config.mode === 'cloud' && config.cloudApiKey) {
          proxyReq.setHeader('ApiKey', config.cloudApiKey);
        }
        // Remove any stale ApiKey header that leaked in from the client
        if (config.mode === 'local') {
          proxyReq.removeHeader('ApiKey');
        }
      },
      error: (err, _req, res) => {
        console.error(`[proxy] upstream error:`, err.message);
        if (!res.headersSent) {
          (res as Response).status(502).json({ error: 'Bad Gateway', detail: err.message });
        }
      },
    },
  };
}

const app = express();

// Register the three named routes
const routes = ['memory', 'docs', 'cloud'] as const;
for (const route of routes) {
  app.use(`/${route}`, createProxyMiddleware(buildProxyOptions(route)));
}

// Health check — also reports current mode
app.get('/_proxy/health', (_req, res) => {
  res.json({ status: 'ok', mode: config.mode });
});

const server = app.listen(8080, '127.0.0.1', () => {
  console.log('[proxy] listening on http://127.0.0.1:8080');
});

export { server, config };

Mode Switching Logic with Electron IPC Integration

import { ipcMain } from 'electron';
import { safeStorage } from 'electron';
import { config } from './proxy-server';

// IPC channel names
const IPC_SET_MODE   = 'proxy:set-mode';
const IPC_GET_MODE   = 'proxy:get-mode';
const IPC_SET_APIKEY = 'proxy:set-api-key';

/**
 * Switch the proxy backend at runtime. The change takes effect for the
 * very next request — no server restart required.
 */
function setProxyMode(mode: 'local' | 'cloud'): void {
  const previous = config.mode;
  config.mode = mode;
  console.log(`[proxy] mode changed: ${previous} -> ${mode}`);
}

/**
 * Securely store and activate a new API key. Uses Electron safeStorage
 * so the key is encrypted at rest and never written to plain text.
 */
async function activateApiKey(plaintextKey: string): Promise {
  if (!safeStorage.isEncryptionAvailable()) {
    // Fall back to in-memory only — warn but don't block
    console.warn('[proxy] safeStorage unavailable; key held in memory only');
    config.cloudApiKey = plaintextKey;
    return;
  }
  const encrypted = safeStorage.encryptString(plaintextKey);
  // Persist encrypted bytes to app userData for next launch
  const { app } = await import('electron');
  const keyPath = `${app.getPath('userData')}/cloud.key`;
  const { writeFileSync } = await import('fs');
  writeFileSync(keyPath, encrypted);
  config.cloudApiKey = plaintextKey; // keep decrypted value in memory
}

/**
 * Load a previously stored API key on startup.
 */
async function loadStoredApiKey(): Promise {
  try {
    const { app } = await import('electron');
    const keyPath = `${app.getPath('userData')}/cloud.key`;
    const { readFileSync } = await import('fs');
    const encrypted = readFileSync(keyPath);
    config.cloudApiKey = safeStorage.decryptString(encrypted);
    console.log('[proxy] loaded stored API key');
  } catch {
    // No stored key yet — cloud mode will fail until the user provides one
    console.log('[proxy] no stored API key found');
  }
}

// Register IPC handlers so the renderer can toggle mode and set credentials
ipcMain.handle(IPC_SET_MODE, (_event, mode: 'local' | 'cloud') => {
  setProxyMode(mode);
  return { ok: true, mode: config.mode };
});

ipcMain.handle(IPC_GET_MODE, () => {
  return { mode: config.mode };
});

ipcMain.handle(IPC_SET_APIKEY, async (_event, plaintextKey: string) => {
  await activateApiKey(plaintextKey);
  return { ok: true };
});

// Initialize on app ready
export async function initProxyIpc(): Promise {
  await loadStoredApiKey();
  console.log(`[proxy] IPC handlers registered; initial mode: ${config.mode}`);
}

Practical Applications

In a desktop AI application using this pattern, the dual-mode proxy is the central nervous system connecting the Electron UI to its AI backends. When a user installs the app and runs it for the first time without internet access, the proxy defaults to local mode and routes all MCP calls to the bundled local AI services running on known localhost ports. The agent tools — semantic memory search via /memory, document retrieval via /docs, and general-purpose tool calls via /cloud — all work without network connectivity or API credentials.

When the user authenticates with the cloud service and clicks “Enable Cloud Mode,” the renderer sends a proxy:set-mode IPC message. The main process updates the shared config object, and the very next tool call from the agent is routed to https://api.example.com with the stored API key injected. The agent code itself — which simply calls http://127.0.0.1:8080/memory/search — is completely unaware of the switch. This zero-change-to-agent-code property is the proxy’s most valuable feature: agent logic, prompts, and tool definitions remain identical regardless of backend.

The pattern also enables graceful degradation: if the cloud API returns a 5xx error or the network becomes unavailable, a watchdog can automatically revert to local mode by calling setProxyMode('local'). Users experience a momentary capability downgrade (e.g., smaller context window, no cross-device memory) rather than a hard failure. This resilience is difficult to achieve when the backend URL is hardcoded in agent tool definitions or environment variables that require a restart to change.

Common Pitfalls

Further Reading