Skip to the page
Conch
DocsGitHub

Decisions

0073 — Conch for your other apps: an MCP door, paired by you

Status: accepted · 2026-10-04

  • Status: accepted
  • Date: 2026-10-04
  • Builds on: ADR 0009 (apps and their policies), ADR 0028 (the guard after reading), ADR 0047 (skill holds), ADR 0049 (apps are Conch's own), ADR 0063 (loopback isn't trust; launchers prove themselves), ADR 0064 and ADR 0065 (the address, and confirming it's you)

Context

What people set up in Conch — their memory, their skills, their apps (Gmail, Slack, Notion…) and the browser — only reached Conch's own chats. People also work in Claude Desktop, Cursor, VS Code or Zed, and those apps speak MCP. Without a way in, they set everything up a second time, by hand, in each one: JSON files, tokens in plain config, and no guard at all (ADR 0009 lists the complaints).

OpenClaw and Hermes don't offer this. The MCP specification's security guidance for a local server is short and specific: validate Origin to stop DNS rebinding, bind to loopback, and authenticate every connection. Recent incidents add to it: tool poisoning and rug pulls (Invariant Labs, 2025), and agents tricked by what they read (Greshake et al., 2023).

Decision

One door, on Conch's own port

Conch answers MCP at /mcp on its own port (streamable HTTP, stateless, JSON answers), beside /mcp/hello and /mcp/session for the launcher. It is outside /api, so the gateway's sign-in doesn't apply; the gateway's Host allowlist and Fetch Metadata checks still run first, and the door adds its own:

  1. A program, not a page. A request with Origin, or Sec-Fetch-Site other than none, is refused: only browsers send them. text/plain is never parsed (the gateway removed that parser), so a form can't post to it.
  2. This computer. A request must look local (Gatekeeper.looksLocal: loopback socket, loopback Host, no forwarding headers). The one exception is below.
  3. A paired app, with proof. See "Who an app is".

Who an app is

Pairing is a person's press in Settings → Other apps, by the owner in a browser (this computer, or a signed-in device that's let in) who confirmed it's them in the last ten minutes (ADR 0065). Never a script's access key, never a tool: the assistant can't pair an app, and an app can't pair itself or widen what it may use. Narrowing and removing need only a sign-in.

A paired app has an id and a key (cmcp.<id>.<secret>, 256 random bits). Conch keeps a SHA-256 of the key in ~/.conch/mcp/clients.json, and the key itself in ~/.conch/mcp/keys/<id>.key (0600), for the launcher. ~/.conch/mcp is a protected path, so the assistant's own tools can't read it.

  • The launcher (~/.conch/mcp/launcher.mjs, plain Node, written by Conch on each start) is what an app runs. It reads the key, asks /mcp/hello for a nonce, and answers /mcp/session with HMAC-SHA256(key, "conch-mcp/1 <id> <nonce>"). Conch gives it a session token for a day, kept in memory (a restart ends it; the launcher proves itself again). The key never travels: ADR 0063's reason applies — whatever holds Conch's port while Conch is stopped (another account, say) would collect a key sent to it, but a proof over a nonce the real Conch never issued is worth nothing. A nonce answers once, for its own app, within a minute.
  • An app that can't start the launcher can be paired for HTTP: it sends its key as a bearer token, compared by hash in constant time. That key does travel, so it's only for apps marked http; the launcher's apps' keys are refused as bearers.
  • Failures are throttled like sign-ins (SignInLimiter, per address).

Connecting in one press

Claude Desktop, Cursor and VS Code are found where they keep their settings (the Microsoft Store's Claude included). Connect shows the file it will write, then writes one entry: the Node Conch runs on, the launcher, and --client <id>. No key or token goes into another app's settings, which more programs read than the app.

  • Only Conch's own entry is written or removed; everything else stays. The file as it was is kept beside it once (.before-conch).
  • A file Conch can't read as JSON (comments, a typo) is left alone, and the person gets the entry to add. Someone else's conch entry is left alone too.
  • Connecting again starts afresh: the pairing the old entry named is removed.
  • Healing. On start and in Repair everything, the launcher is rewritten if it differs, an entry whose Node is gone or whose launcher moved is rewritten (only ours), and an entry for an app that's no longer paired is taken out. Each fix is one sentence under "Fixed on its own".

What an app may use

An app has a list of scopes and nothing is implied:

ScopeTools
memory.readsearch_memory (meaning search; memories waiting for an OK never show)
memory.writesuggest_memory: always waits for the person's OK (ADR 0032)
skillslist_skills (on, safe, signature intact), use_skill
browserbrowser_*, except handing it to the person and passkeys
app:<id>that app's tools, as the person set them in Apps; tools that are off stay off

Never offered, whatever the scopes: passwords, files, commands, the terminal, delegate and tasks, routines, making apps, and memory it could forget. The list is worked out again for every tools/list and every tools/call, from the app's record as it is now, so a scope taken away holds at once.

How a call runs

Memory is read and suggested directly. Every other call runs as one turn of the app's own chat in Conch (origin { kind: 'client' }), with a CallEngine in place of a provider: there is no model, the app already chose the tool. The turn is scoped to that one tool (toolAllowed, and apps for the one MCP server it needs), and the engine calls it as a model API engine would: arguments checked against the schema, the guard, then permission (authorizeTool, the bridge's requestPermission). So everything a chat is held to holds:

  • the person's choices in Apps (Ask, Off, "Don't ask");
  • the guard after reading: the app's chat remembers what it read across calls, and a sink after that asks (ADR 0028);
  • a skill it loaded holds the chat to its list (ADR 0047);
  • Activity and Undo see it; "always allow" never carries from one call to the next;
  • asking the person: the question waits in the app's chat, the approvals notification names the app ("Claude Desktop needs your OK"), and Conch's open pages show a toast.

Calls from one app run one at a time. When the app hangs up (its request closes), the turn stops and a waiting question expires. The app's chat is a log: nobody writes in it (send refuses), and it stays out of the chat list.

From elsewhere, only when you open it

By default the door answers only programs on this computer. The person can turn on Let apps you mark in through your address (owner, confirmed). Then a request that doesn't look local is let in only if it came over HTTPS (Gatekeeper.isSecure: Conch's own listener, or a TLS proxy on this computer) to one of Conch's names, with the key of an http app the person also marked remote. Plain HTTP from the network never gets in, and launcher sessions never work from elsewhere. The security checkup warns while any app may come in this way.

Whole Conch

  • Backups: mcp/** is derived. A restore pairs nothing, so another computer's apps never arrive with it; the guide says to connect them again.
  • Protected paths: ~/.conch/mcp.
  • Repair everything: the mcp check (heals, and says when an app's settings no longer start Conch, with Open Other apps).
  • Security checkup: paired apps (info), and apps let in through the address (warn).
  • ⌘K: "Other apps", "Claude Desktop", "Cursor", "VS Code", "MCP server".

Threat model

WhoWhat they tryWhat stops itTested in
A program nobody pairedCall tools401 with how to pair; nothing listedendpoint.test.ts
Anyone guessingBrute-force a key256-bit keys, hash compare, throttling (429)endpoint.test.ts
A web page (CSRF)Post to /mcp from the browser with a stolen keyOrigin / Sec-Fetch-Site refused; text/plain not parsed (415); no cookies countendpoint.test.ts
DNS rebindingReach loopback under its own nameHost allowlist (421), then Origin, then the keyendpoint.test.ts
Another computerReach the door over the networkRefused unless the switch, HTTPS, and a marked HTTP appendpoint.test.ts
Another account here, or a port squatterCapture a key while Conch is stoppedLauncher sends an HMAC over Conch's nonce, never the key; sessions die at restartendpoint.test.ts, launcher.test.ts
A replayed proofOpen a session twice, or for another appSingle-use nonce bound to the app, one minuteendpoint.test.ts
A paired appUse what it wasn't given (scope escape)Listed and checked per call from the current record; the turn allows only that tool; Conch's own powers never offeredservice.test.ts, endpoint.test.ts
A paired app, misled by what it readSend, change or remember on its ownThe chat's guard, the app's policies, OK for writes, memories waitservice.test.ts
The assistant in a Conch chatPair an app, read a key, widen a scopeNo tool for it; owner + confirmation on the routes; ~/.conch/mcp protectedendpoint.test.ts
A removed appKeep using an open sessionSessions end on removal; key file deletedendpoint.test.ts, pairing.test.ts
Another app's settings fileCarry a secret, or lose the person's own entriesNo secret written; only Conch's entry touched; unreadable files left alonepairing.test.ts

What's left: an app paired for HTTP sends its key on each request, so whatever holds Conch's port while it's stopped could collect it; the launcher is the default for that reason, and Remove revokes a key at once. A tool's description from one of the person's own MCP apps reaches the other app as that app's server would give it (the person connected that app to Conch; Conch's tool pinning still applies to calls). An app that speaks to the person can still say whatever it likes in its own window; Conch only governs what it does through Conch.

Not now: Conch as an ACP agent

Editors that speak the Agent Client Protocol (Zed and others) can run an agent over stdio. Conch speaking ACP as the agent would stream whole conversations to the editor, map editor sessions to Conch chats, bridge session/request_permission to Conch's questions, and carry cancellation. That's a separate piece with its own threat model, not a small addition to this door. Zed and the other editors also take MCP servers, so they can already use Conch through this door. ACP agent mode is a follow-up.

Consequences

  • An app paired once uses the person's memory, skills, apps and browser, with every provider and model behind it, and asks the person in Conch for what matters.
  • Conch keeps a key per paired app on disk (0600, protected) and a launcher in ~/.conch/mcp.
  • A call that needs an OK waits for the person. Most apps give up after about a minute, so a slow answer means asking again from the app.
  • No new dependencies: the MCP SDK and Node's crypto were already here.

Sources

  • Model Context Protocol, Security Best Practices and the Streamable HTTP transport (2025-06-18): validate Origin, bind locally, authenticate connections.
  • OWASP Cross-Site Request Forgery Prevention Cheat Sheet; W3C Fetch Metadata Request Headers, and the resource-isolation policy the gateway already follows.
  • OWASP ASVS 5.0 V6 and V7 (authentication and session management), NIST SP 800-63B-4 §3.2.2 (throttling); the HMAC challenge as in ADR 0063's launcher design.
  • Invariant Labs, "MCP Security Notification: Tool Poisoning Attacks" (2025); Greshake et al., "Not what you've signed up for" (2023).