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 lifecycle —
getServers,addServer,updateServer,removeServer. - Capabilities —
getCachedCapabilities,fetchCapabilities,cancelCapabilities,clearCapabilities(backs Cancelling & Refreshing Discovery and Capabilities Cache). - Invocation —
callTool,readResource,getPrompt. - Mid-call events —
onToolNotification,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. - Auth —
authorizeServer,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/getServers→store.tsmcp:fetchCapabilities/callTool/readResource/getPrompt/authorizeServer→mcpClient.tsmcp:clearAuth/getAuthDetails→oauthStore.tsmcp:isEncryptionAvailable→secrets.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 latermcp:cancelCapabilitiescall 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 bywebContents.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 contract —
preload/index.d.tstypes 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.
Related
- See Process Model for how
preload/andmain/ipc.tsfit alongside the rest of the app. - See Secrets & Credential Storage for how sensitive values are kept out of this bridge entirely.
- See
Cancelling & Refreshing Discovery
for the user-facing behavior
fetchAbortersimplements.