Skip to content
PricingBlog
✨ Markdown

MCP servers

The runtime ships an embedded MCP registry that connects agents to external Model Context Protocol servers — both locally-spawned stdio servers and remote Streamable HTTP servers. Tools, resources, and prompts exposed by those servers become available to every entity at the next wake without per-agent wiring.

Registering servers

Registry is the primary API. Agent authors call into it directly when they're defining or hosting agents in code. mcp.json and the desktop app's settings.json are file-based convenience layers that the runtime turns into the same Registry.applyConfig() calls under the hood.

Programmatic — Registry.addServer() / applyConfig()

BuiltinAgentsServer exposes the registry through mcpRegistry. Add servers from code anywhere it's the right shape — at boot from your own config source, in response to user actions, or per-session for tools an agent should only see during a specific task:

ts
import { BuiltinAgentsServer } from "@electric-ax/agents"

const server = new BuiltinAgentsServer({
  agentServerUrl: "http://localhost:4437",
  port: 4448,
  workingDirectory: process.cwd(),
})

await server.start()

const result = await server.mcpRegistry?.addServer({
  name: "stripe",
  transport: "http",
  url: "https://mcp.stripe.com/mcp",
  auth: {
    mode: "apiKey",
    headerName: "Authorization",
    key: process.env.STRIPE_MCP_KEY!,
  },
})

addServer returns a discriminated AddServerResult{ state: "ready" | "authenticating" | "error", … }. The state landscape is described in Server states below; the full lifecycle (hot-reload, reauthorize, timeouts) lives in Lifecycle.

The bulk methods are:

  • applyConfig(cfg) — replace the full set of servers. Idempotent on unchanged entries; removes anything not in the supplied config. This is what file-based config layers compile down to.
  • subscribe(handler) — push-based view of the live state, including ready / authenticating / error transitions. Useful when an embedder renders its own UI on top of the registry.
  • reauthorize(name), disable(name), enable(name), removeServer(name) — single-server lifecycle.

Static secrets (apiKey.key, clientCredentials.clientId / clientSecret) are passed inline at the call site — typically read from process.env. The runtime never reads environment variables on the embedder's behalf. See McpServerConfig for the full schema.

File-based — mcp.json

For static, project-scoped configuration the runtime can load mcp.json from the configured workingDirectory, watch it for changes, and hot-reload adds, removes, and reconfigurations through applyConfig — exactly as if you'd called the API yourself. In-flight tool calls finish on the old config; new calls pick up the new one.

mcp.json loading is opt-in: stdio MCP servers spawn local commands, so picking a working directory must not auto-execute config from it. The Electron desktop and the electric-ax CLI opt in by default. Library embedders that construct BuiltinAgentsServer directly enable it with loadProjectMcpConfig: true (which loads <workingDirectory>/mcp.json and watches it).

mcp.json carries structural shape only — no secrets:

jsonc
{
  "servers": {
    "honeycomb": {
      "transport": "http",
      "url": "https://mcp.honeycomb.io/mcp",
      "auth": {
        "mode": "authorizationCode",
        "scopes": ["mcp:read", "mcp:write"]
      }
    },
    "internal-api": {
      "transport": "http",
      "url": "https://api.example.com/mcp",
      "auth": {
        "mode": "apiKey",
        "headerName": "X-Api-Key"
      }
    },
    "git-local": {
      "transport": "stdio",
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-git",
        "--repository",
        "${workspaceRoot}"
      ]
    }
  }
}

For authorizationCode servers in mcp.json, the runtime auto-wires keychainPersistence so OAuth tokens survive process restarts via the OS keychain.

Desktop settings layer

The Electron desktop app exposes a second file-based layer: a global mcp.servers block in its settings.json, applied to every workspace. The shape mirrors mcp.json — keyed by server name — so entries can be copy-pasted between the two files. It composes with the workspace mcp.json instead of replacing it:

  • Servers from both files load together when their names don't collide.
  • On a name collision, the workspace mcp.json wins (project scope overrides global).
  • keychainPersistence is auto-wired for OAuth servers from either source.

The settings.json lives at:

OSPath
macOS~/Library/Application Support/Electric Agents/
Linux~/.config/Electric Agents/
Windows%APPDATA%\Electric Agents\

Example shape:

jsonc
{
  "servers": [...],
  "activeServer": {...},
  "workingDirectory": "/Users/me/workspace/foo",
  "apiKeys": {...},
  "mcp": {
    "servers": {
      "linear": {
        "transport": "http",
        "url": "https://mcp.linear.app/sse",
        "auth": { "mode": "authorizationCode", "scopes": ["mcp:read"] }
      }
    }
  }
}

Programmatic embedders (other than the desktop) pass the resolved set as an array via BuiltinAgentsServer({ extraMcpServers }) — that's the in-memory shape settings.json is rewritten into when the desktop loads it.

Per-agent allowlist

Entity definitions opt into MCP servers explicitly via the mcp.tools() helper from @electric-ax/agents-runtime:

ts
import { mcp } from "@electric-ax/agents-runtime"

registry.define("research-agent", {
  async handler(ctx) {
    ctx.useAgent({
      systemPrompt: "...",
      tools: [
        ...ctx.electricTools,
        ...mcp.tools(["sentry", "github"]), // explicit list
        // or: ...mcp.tools()                 // every registered server
      ],
    })
    await ctx.agent.run()
  },
})

The resolved tool set is recorded in the agent's manifest at compose time. Tools are exposed to the model with always-prefixed names matching Anthropic's tool-name regex (^[a-zA-Z0-9_-]{1,128}$):

  • Tools: mcp__sentry__search, mcp__github__create_issue, …
  • Resources: mcp__<server>__list_resources, mcp__<server>__read_resource
  • Prompts: mcp__<server>__list_prompts, mcp__<server>__get_prompt

Built-in entities horton and worker opt in to all registered servers via mcp.tools().

Auth modes

Each server declares one auth mode. The runtime keeps a valid token in hand on every call: silent refresh when possible, or a structured auth_unavailable error to the agent's model when not.

apiKey

ts
auth: {
  mode: "apiKey",
  key: process.env.X_API_KEY!,
  headerName: "X-Api-Key",  // default "Authorization"
  valuePrefix: "Bearer ",   // optional
}

The header is sent on every request. Rotate by editing the config; the registry's idempotency check picks up the change and rebuilds the transport on the next reload.

clientCredentials

ts
auth: {
  mode: "clientCredentials",
  tokenUrl: "https://auth.example.com/oauth/token",
  clientId: process.env.X_CLIENT_ID!,
  clientSecret: process.env.X_CLIENT_SECRET!,
  scopes: ["mcp:read"],
}

The runtime exchanges the client credentials for short-lived access tokens silently. No user interaction.

authorizationCode (OAuth)

ts
auth: {
  mode: "authorizationCode",
  scopes: ["mcp:read"],
  // optional — pre-registered OAuth client (skips DCR)
  client: { clientId: "...", clientSecret: "..." },
  // optional — pre-existing tokens (skips OAuth flow on boot)
  tokens: { accessToken: "...", refreshToken: "...", expiresAt: 1736e9 },
  // fires on initial auth + every refresh — wire to your persistence
  onTokensChanged: async (t) => { /* persist */ },
  // fires once after RFC 7591 DCR completes
  onClientRegistered: async (c) => { /* persist */ },
}

The MCP SDK handles PKCE, RFC 7591 Dynamic Client Registration, RFC 9728 Protected Resource Metadata discovery, and 401-retry transparently. The first time a server is used:

  1. The runtime captures an authorize URL and surfaces it through the openAuthorizeUrl(url, server) hook on BuiltinAgentsServer.
  2. The Electron desktop opens the URL in a sandboxed BrowserWindow and intercepts the redirect_uri navigation client-side — the redirect URL is never actually fetched, so no HTTP listener is needed.
  3. The runtime exchanges the captured code + state for tokens and fires onTokensChanged.

Subsequent restarts re-seed from persisted tokens; refresh-token rotation happens silently on every call.

The redirect URI registered with the auth server during DCR is <mcpOAuthRedirectBase>/oauth/callback/<server-name>. Embedders that listen on an ephemeral port (the desktop runs on port: 0) MUST pass a stable mcpOAuthRedirectBase to BuiltinAgentsServer — otherwise the cached DCR client info goes stale on every restart and users have to re-authorize every launch. The desktop sets it to a fixed loopback literal (http://127.0.0.1:53117) per RFC 8252 §7.3; nothing actually listens at the port. Headless embedders that use port 0 with persisted credentials need to do the same.

Persistence helpers

@electric-ax/agents-mcp ships two opt-in helpers that produce the auth-config slice:

ts
import { keychainPersistence, filePersistence } from "@electric-ax/agents-mcp"

const honeycomb = await keychainPersistence({ server: "honeycomb" })

await mcpRegistry.addServer({
  name: "honeycomb",
  transport: "http",
  url: "https://mcp.honeycomb.io/mcp",
  auth: {
    mode: "authorizationCode",
    scopes: ["mcp:read"],
    ...honeycomb,
  },
})
HelperBacking storeWhen to use
keychainPersistence({ server })OS keychain (macOS security, Linux secret-tool)Local dev / desktop apps; tokens encrypted by the OS
filePersistence({ path, server })Mode-0600 JSON fileCI / containers without an OS keychain

For Vault, SSM, or a custom secret system, write your own onTokensChanged and onClientRegistered directly. The contract is two callbacks and two optional values.

Server states

Every server entry the registry tracks is in exactly one of five states. The state is the status field on ListedEntry (returned by Registry.list() and emitted on every snapshot through subscribe), and it's the discriminator on the AddServerResult envelope returned from addServer / applyConfig / finishAuth / enable.

StateMeaningSide data
connectingTransport is being built (RFC 9728 discovery, RFC 7591 DCR, stdio spawn, HTTP handshake) or rebuilt after reauthorize / enable.
authenticatingAn authorizationCode server needs the user. The SDK has produced an authorize URL; the embedder's openAuthorizeUrl hook fired.authUrl: string
readyConnected. Tools listed. Calls succeed and stream through the bridge.toolCount: number, tools: [...]
errorTransport, auth-config, or addServer validation failure. The entry stays in list() so the UI can surface the failure.error: { kind, message, details? }
disabledOperator paused the server via Registry.disable(name). Transport closed; tokens stay in the cache.

Transitions are driven by registry methods. The high-level shape:

                    ┌──────────────┐    success     ┌──────────┐
   addServer ──────▶│  connecting  │───────────────▶│  ready   │
   applyConfig      └──────┬───────┘                └────┬─────┘
   enable                  │                             │
                           │ no tokens / 401             │
                           ▼                             │
                    ┌──────────────┐  finishAuth         │
                    │authenticating│───────────────────▶─┘
                    └──────┬───────┘  (retries connect)

                           │ unrecoverable

                    ┌──────────────┐
                    │    error     │
                    └──────────────┘

   reauthorize:  any non-disabled  ──▶ connecting ──▶ authenticating
   disable:      any               ──▶ disabled
   enable:       disabled          ──▶ connecting ──▶ ready (or authenticating, or error)
   removeServer: any               ──▶ (entry gone)

A few specifics worth knowing:

  • error is sticky. It doesn't auto-recover. Reach ready again by calling addServer with the same config (idempotency picks up changes), reauthorize(name), or — for transient transport issues — re-running through applyConfig. The entry stays in the snapshot the whole time.
  • reauthorize always lands in connecting first, then typically authenticating because tokens are intentionally cleared. The mutation is in-place — subscribers never see the entry disappear, so renderers don't flicker.
  • disable is recoverable. It closes the transport but keeps tokens, hooks, and the entry. enable rebuilds the transport from the same config; if tokens are still valid, the next state is ready without an OAuth round-trip.
  • removeServer is destructive. It clears tokens from the in-memory cache (persisted tokens via onTokensChanged stay where the operator put them) and removes the entry. There is no UI affordance for it on the desktop — Disable is the recoverable equivalent.

For the full per-method API (including subscribe, RegistrySnapshot, and RegistryOpts), see the McpRegistry reference.

Lifecycle

Hot-reload

Editing mcp.json (or calling applyConfig programmatically) takes effect immediately:

  • New server. Tools available at the next tool-selection step in any active wake; manifests of agents using mcp.tools() update at the next compose.
  • Removed server. In-flight tool calls complete or fail cleanly; no new calls dispatch; stdio subprocesses terminate after in-flight calls drain.
  • Reconfigured server. Takes effect on the next tool call to that server. In-flight calls finish on the old config.

addServer and applyConfig are idempotent on unchanged config — they compare by (name, url, transport, authMode, scopes, timeoutMs, command, args) and short-circuit when nothing changed. Spurious file-system events from macOS reload watchers won't tear down healthy connections.

Re-authorize

Calling Registry.reauthorize(name) forces a fresh OAuth flow without removing the entry from the registry. The transport is closed, tokens are dropped from the in-memory cache (hooks remain registered), and the SDK produces a new authorize URL that fires through the openAuthorizeUrl hook. The entry stays in every snapshot throughout, so subscribers don't see it disappear and reappear.

The desktop's Authorize button routes through this method. It's enabled when the server is in authenticating (initial sign-in) or error (recover from a stale-token state). Once the server is ready the same button label switches to Re-authorize and forces a fresh OAuth flow — useful when refresh-token rotation has stopped working and you want to re-bootstrap without removing the server.

Per-call timeouts

Every MCP tool call has a timeout (default 30 seconds, overridable per server via timeoutMs). When exceeded, the bridge cancels the call (JSON-RPC cancellation for stdio servers; HTTP request abort for HTTP servers) and resolves it with a timeout error result. The agent's model decides what to do — retry, fall back, abort.

The timeout is a hygiene feature, not a long-running-call solution. Tool calls in v1 are synchronous within the wake.

Connected Services UI (desktop)

The Electron desktop ships a Settings → MCP Servers page that mirrors Registry.subscribe over Electron IPC. Each row shows:

  • Name and transport (stdio / http).
  • Auth mode (apiKey / clientCredentials / authorizationCode).
  • Statusconnecting, authenticating, ready, error, or disabled.
  • Tool count + expandable tool list.
  • Per-row actions: Authorize (only when a server is in authenticating), Reconnect, Disable / Enable.

The page is the operator's primary mechanism for noticing and fixing broken credentials, and the developer's primary surface for kicking off initial OAuth flows. There is no Disconnect action: removal of an entry happens via editing the config file. Disable pauses without losing state and is recoverable from the UI.

Failure modes

The runtime returns a structured error to the agent's model on any tool-call failure it can't transparently recover from:

KindMeaning
auth_unavailableSilent refresh failed and no credential is usable; operator must reauthorize.
transport_errorServer unreachable, connection dropped, malformed response.
timeoutCall exceeded its per-call timeout.
server_errorThe MCP server returned a structured error.
tool_not_foundCapability mismatch (e.g. server's tool list changed since compose).

Agents handle these like any other tool error: retry, fall back, give up gracefully, or escalate to the user. The runtime doesn't block tool calls indefinitely waiting for out-of-band recovery.

Reference

  • McpRegistry — full API: addServer, applyConfig, subscribe, reauthorize, the lifecycle, snapshot envelope, and RegistryOpts for custom embedders.
  • McpServerConfig — schema for the cfg argument to addServer / applyConfig.
  • BuiltinAgentsServer options — the extraMcpServers and openAuthorizeUrl options used to wire embedder-specific MCP behavior.