McpRegistry
The MCP registry owns the live set of Model Context Protocol servers a runtime is connected to. It manages stdio subprocesses and HTTP clients, drives the OAuth state machine, and emits push-based snapshots so embedders can render UI without polling.
Source: @electric-ax/agents-mcp (re-exported as McpRegistry from @electric-ax/agents)
BuiltinAgentsServer.mcpRegistry exposes the instance that the embedded runtime owns; the usage guide walks through registering servers from agent-host code.
interface Registry {
addServer(cfg: McpServerConfig): Promise<AddServerResult>
applyConfig(cfg: McpConfig): Promise<AddServerResult[]>
removeServer(name: string): Promise<void>
list(): ReadonlyArray<ListedEntry>
get(name: string): Entry | undefined
finishAuth(server: string, code: string, state?: string): Promise<AddServerResult>
reauthorize(name: string): Promise<void>
disable(name: string): Promise<void>
enable(name: string): Promise<AddServerResult>
subscribe(handler: RegistrySubscriber): () => void
}Methods
| Method | Description |
|---|---|
addServer(cfg) | Register or reconfigure a single server. Idempotent on unchanged (name, url, transport, authMode, scopes, command, args). |
applyConfig(cfg) | Replace the full set of registered servers. Adds new ones, reconfigures changed ones, and removes anything not in cfg. |
removeServer(name) | Tear down a single server: close the transport, drop tokens from the in-memory cache, remove the entry. |
list() | Returns the current snapshot as a plain array — same shape as the servers field of RegistrySnapshot. |
get(name) | Internal lookup of a single entry, with the live transport handle and the resolved provider. Used by IPC handlers. |
finishAuth(name, code, state?) | Complete the OAuth authorization-code flow for an authenticating server. Called by the embedder after intercepting the redirect URI. |
reauthorize(name) | Force a fresh OAuth flow without removing the entry. Closes the transport, drops cached tokens (hooks remain registered), and rebuilds in place. |
disable(name) | Pause a server. Closes the transport but keeps the entry; tokens stay in the cache. |
enable(name) | Re-add a previously-disabled server using its last-known config. |
subscribe(handler) | Push-based view of registry state. The handler fires synchronously with a sentinel snapshot, then on every mutation. Returns an unsubscribe function. |
addServer vs applyConfig
Both feed the same internal pipeline; pick by what you have:
addServer(cfg)— register one server. Use when you're adding an entry in response to a user action, a per-session tool, or a one-off integration.applyConfig({ servers })— replace the full set. Anything in the registry that isn't incfg.serversis removed; existing entries with unchanged config are left alone (no transport churn). This is what the file-based loaders formcp.jsonand the desktopsettings.jsoncompile down to.
Idempotency is the load-bearing property: editors save spuriously, file watchers fire double events on macOS, and most apps re-apply the same baseline on every restart. Calling applyConfig with the same shape twice does nothing the second time, so it's safe to wire to noisy upstreams.
AddServerResult
addServer and finishAuth return a discriminated union so the caller can react without inspecting the registry afterwards:
type AddServerResult =
| { state: "ready"; id: string; toolCount: number }
| { state: "authenticating"; id: string; authUrl: string }
| { state: "error"; id: string; error: McpToolError }| State | Meaning |
|---|---|
ready | Connected and tools listed; calls available at the next agent wake. |
authenticating | OAuth required. authUrl is the URL to send the user to. The desktop's openAuthorizeUrl hook opens it in a sandboxed BrowserWindow automatically. |
error | Connect, transport, or auth-config failure. error.kind and error.message describe what went wrong. |
applyConfig returns one AddServerResult per server in the supplied config (in the same order).
Lifecycle of an entry
Every entry transitions through one of five statuses, surfaced on the snapshot. The states that matter to UI:
| Status | Meaning |
|---|---|
connecting | Transport is being built (DCR, HTTPS discovery) or reconnecting. |
authenticating | OAuth flow needed; authUrl is set, browser window is open. |
ready | Transport connected; tools listed and callable. |
error | Transport or auth-config failure. The entry stays in list() so the UI can surface the failure. |
disabled | Operator paused the server via disable(name). Recoverable through enable(name). |
Transitions are atomic with respect to subscribers: every state change fires a single snapshot in which the entry shows its new status. reauthorize mutates entries in place — the row never disappears from list(), even mid-rebuild, so renderers don't see a flicker.
subscribe(handler) and RegistrySnapshot
type RegistrySubscriber = (snapshot: RegistrySnapshot) => void
interface RegistrySnapshot {
seq: number
servers: ReadonlyArray<ListedEntry>
}Subscribing is the primary way to drive a UI off the registry. The first invocation is synchronous and carries seq: 0 as a sentinel — embedders treat it as the bootstrap snapshot, not part of the event stream. After that, every mutation increments seq (1, 2, 3, …) and broadcasts the full snapshot. A late subscriber still sees seq: 0 on its first delivery; emitted events continue from the registry's current counter.
Handlers must not throw. The registry catches exceptions per subscriber so a misbehaving consumer can't break the others, but the catch is a safety net, not a feature — log and swallow inside your handler.
const off = registry.subscribe((snap) => {
if (snap.seq === 0) {
// bootstrap — render the initial list
} else {
// diff against the previous snapshot, or just re-render
}
})
// ...
off()ListedEntry
The shape of each servers[] entry inside a snapshot:
interface ListedEntry {
name: string
status: McpServerStatus
toolCount: number
transport?: "http" | "stdio"
authMode?: "none" | "apiKey" | "clientCredentials" | "authorizationCode"
authUrl?: string
error?: McpToolError
tools: Array<{ name: string; description?: string; inputSchema: unknown }>
capabilities?: unknown
}| Field | Type | Description |
|---|---|---|
name | string | The server's stable identifier. |
status | McpServerStatus | Current lifecycle state — see the table above. |
toolCount | number | Number of tools the server advertises. 0 until status === "ready". |
transport | "http" | "stdio" | The transport variant in use. |
authMode | string | none / apiKey / clientCredentials / authorizationCode. UI badges + "show Authorize" check use this. |
authUrl | string | Set while status === "authenticating". The URL to open for OAuth consent. |
error | McpToolError | Set while status === "error". |
tools | Array<{ name; description?; inputSchema }> | Tool metadata as advertised by the server. Each becomes mcp__<server>__<tool> for the LLM. |
capabilities | unknown | Server-declared MCP capabilities object (resources, prompts, etc.). |
McpToolError
interface McpToolError {
kind: McpToolErrorKind
message: string
details?: unknown
}
type McpToolErrorKind =
| "auth_unavailable"
| "transport_error"
| "timeout"
| "server_error"
| "tool_not_found"The same shape surfaces on entry-level error (when addServer fails to connect) and on individual tool calls. See the usage guide's failure modes table.
RegistryOpts
BuiltinAgentsServer constructs the registry on your behalf. You only see this shape if you instantiate agents-mcp directly (e.g. from a custom embedder):
interface RegistryOpts {
publicUrl?: string
openAuthorizeUrl?: (url: string, server: string) => void
}
function createRegistry(opts: RegistryOpts): Registry| Field | Description |
|---|---|
publicUrl | Base URL used to construct the OAuth redirect_uri (full URI is <publicUrl>/oauth/callback/<server-name>). MUST be stable across restarts — DCR registers it with the auth server and persists it in the keychain, so a value that drifts forces re-authorization on every launch. Embedders that listen on an ephemeral port should pass a fixed loopback literal (the desktop uses http://127.0.0.1:53117); nothing actually listens at the URL — the embedder's BrowserWindow intercepts the redirect by prefix. |
openAuthorizeUrl | Hook invoked when an authorizationCode server first needs consent. Receives the SDK-generated authorize URL. The desktop opens it in a sandboxed BrowserWindow; headless embedders can read the URL from the authenticating envelope of addServer and surface it themselves. |
See also
- MCP servers usage guide — the practical walkthrough of registering servers, OAuth, persistence, and the per-agent allowlist.
McpServerConfig— schema for thecfgargument toaddServer/applyConfig.BuiltinAgentsServer— host options that affect MCP, includingextraMcpServersandopenAuthorizeUrl.