architecture/ipc-bridge-design

IPC Bridge Design

The typed, narrow bridge between the sandboxed renderer and the main process — src/preload/index.ts and src/main/ipc.ts.

The renderer has no direct access to Node.js or the OS — Chromium runs it sandboxed with context isolation enabled. Every capability it needs (talking to an MCP server, reading config, triggering OAuth) crosses into the main process through a deliberately narrow, typed bridge. Two files define this boundary: src/preload/index.ts and src/main/ipc.ts.

The preload script

src/preload/index.ts exposes exactly one object, window.api, via contextBridge.exposeInMainWorld. Every method on it is a thin wrapper around ipcRenderer.invoke(...) or ipcRenderer.on(...) — there is no way for the renderer to reach arbitrary main-process functionality, only what’s explicitly listed here. The API surface falls into a few groups:

  • Server lifecyclegetServers, addServer, updateServer, removeServer.
  • CapabilitiesgetCachedCapabilities, fetchCapabilities, cancelCapabilities, clearCapabilities (backs Cancelling & Refreshing Discovery and Capabilities Cache).
  • InvocationcallTool, readResource, getPrompt.
  • Mid-call eventsonToolNotification, onElicitationRequest/ onElicitationClosed/respondToElicitation, onSamplingRequest/ onSamplingClosed/respondToSampling (backs Live Notifications, Elicitation, Sampling). Each subscription method returns an unsubscribe function, so renderer components can clean up listeners on unmount.
  • AuthauthorizeServer, clearAuth, getAuthDetails (explicitly redacted — client ID, scopes, expiry, never tokens), isEncryptionAvailable, onAuthEvent.

Nothing sensitive crosses this bridge in raw form: getAuthDetails returns a redacted summary, not tokens; secrets (see Secrets & Credential Storage) are decrypted only inside the main process and never serialized across IPC.

The main-process handlers

src/main/ipc.ts’s registerIpcHandlers() registers one ipcMain.handle(...) per preload method, each delegating to a focused module rather than containing logic itself:

  • mcp:addServer/updateServer/removeServer/getServersstore.ts
  • mcp:fetchCapabilities/callTool/readResource/getPrompt/ authorizeServermcpClient.ts
  • mcp:clearAuth/getAuthDetailsoauthStore.ts
  • mcp:isEncryptionAvailablesecrets.ts
  • Cached capability reads/writes → capabilitiesCache.ts
  • Pending elicitation/sampling state → elicitations.ts / samplings.ts

ipc.ts also owns two pieces of cross-cutting bookkeeping:

  • Cancellation — a Map<serverId, AbortController> (fetchAborters) tracks in-flight capability fetches, so a later mcp:cancelCapabilities call can abort the specific fetch still running for that server. One entry per server; a new fetch simply replaces any prior controller.
  • Event broadcasting — OAuth flow events are fanned out to every live BrowserWindow (not just one), guarded by webContents.isDestroyed() checks so a closed/destroyed window is never sent to. The app is single-window today, but this makes the pattern resilient if that changes.

Validating server IDs at the boundary

Every handler that takes a server ID calls assertValidServerId(id) (src/main/serverId.ts) before that ID reaches any filesystem path. Server IDs are always crypto.randomUUID() values generated by store.ts, and every per-server on-disk path (capabilities cache, OAuth store) is built by joining the ID directly onto a base directory — safe only if the ID can’t contain path separators or ... Since IDs arrive over IPC from a renderer that also displays fully attacker-controlled MCP server content, this check rejects any non-UUID string at the trust boundary rather than relying on it never occurring. This is the same class of hardening called out in the README’s Security section.

Why this structure

  • Least privilege — the renderer can only call the specific, typed operations preload exposes; it can’t reach fs, child_process, or arbitrary main-process state.
  • Single source of truth for the contractpreload/index.d.ts types the bridge, so a mismatch between what preload exposes and what the renderer expects is caught at compile time.
  • Defense in depth at the ID boundary — even though the renderer is “trusted UI code,” IDs it forwards can originate from untrusted MCP server responses (e.g. a resource URI or a display value), so validation happens again at the IPC layer rather than assuming the UI already checked.

Edit this page on GitHub