All articles

Agent

Mechanism: MCP — Letting Other Agents In

16 min read

We call this mechanism Puppeteer. Pixie's agent is not the only agent on your Mac. Claude Desktop is sitting right there. So is whatever local model you run in some other harness. Sooner or later you will want to type "ask Pixie who I've been neglecting" into a window that isn't Pixie's — and the moment you want that, Pixie has to expose its most sensitive data to a program it did not write, does not update, and cannot vouch for. This article is the machinery that makes that safe: a server that only your own machine can reach, reads that structurally cannot touch the sealed parts of your life, and writes that stop mid-flight until a human approves the exact words.

This is a mechanism deep-dive under The Homebase, which explains why a Mac joins your account as a full peer at all. Everything here lives on that Mac — the whole feature is fenced to the Catalyst build, because a door for co-located desktop agents only makes sense on the desktop. If you have read Skills, Tools, and the @-Namespace, keep one contrast in mind throughout: none of the tools in this article are the tools Pixie's own agent uses. This is a second, separate tool surface, built for guests.


1. The problem: a capable stranger

MCP — the Model Context Protocol — is the industry's standard answer to "how does an AI app use another app's abilities?" An application runs an MCP server that publishes a catalog of typed tools (each with a name, a description, and a JSON schema for its arguments); any compliant client — Claude Desktop, an IDE, a local-model harness — connects, calls tools/list to discover the catalog, and then invokes tools with tools/call. The protocol itself is boring plumbing, and that is its virtue: implement it once and every MCP-speaking agent, present and future, can drive your app.

That universality is exactly the danger. The client on the other end is a program you did not choose the weights for, running prompts you did not see, possibly manipulated by a webpage it just read. Whatever you expose over MCP, you expose to anything that can speak it. So the design question is never "which clients do we trust?" — you cannot answer that — but "what shape of door can we leave open to a stranger?"

Pixie's answer has three layers, and each is structural — enforced by what the code can reach, not by what a handler remembers to check:

  1. Reach: the server exists only on loopback, behind a per-enable bearer token, and only while a switch you flipped stays on.
  2. Reads: every read tool flows through one whitelisted projection layer that has no code path to the sealed zone, your circle assessments, or per-relationship state.
  3. Writes: every write suspends inside the tool call until you approve the exact draft on screen — and dismissing, ignoring, or timing out all mean no.

The rest of the article walks the layers in order, then closes with the two pieces of glue that make the whole thing legible: attribution and the audit log.


2. The door: loopback, and a token that dies with the switch

The server is off by default in the strongest sense: it is not merely idle, it is not listening — no socket exists until you flip the toggle in Settings. Flipping it constructs the stack in MCPServerService.start():

Claude Desktop ──stdio──▶ mcp-remote ──HTTP + Bearer──▶ 127.0.0.1:11939
                                                            │
                                                    MCPHTTPListener        loopback bind + token gate
                                                            │
                                              StatefulHTTPServerTransport  the MCP swift-sdk transport
                                                            │
                                                    MCPServerService
                                                      ├─ read tools ──▶ MCPReadModel (projections, §4)
                                                      └─ write tools ─▶ parse → gate → sheet (§5–6)

The bottom three boxes are Pixie's. The MCP swift-sdk provides Server and StatefulHTTPServerTransport, but the transport deliberately does not bind a network port — it just processes HTTP request values. MCPHTTPListener is the thin adapter that does bind one, and how it binds is the first structural guarantee: the underlying NWListener is pinned to the loopback interface, 127.0.0.1, port 11939. Not "bound to all interfaces and filtered," but bound to loopback — the operating system will not deliver an off-machine packet to it. There is no hosted endpoint, no hole punched in any firewall, nothing for anyone who is not running code on your Mac.

Loopback alone is not enough, though, because "code on your Mac" includes every process on your Mac. That is what the bearer token is for. When you enable the server, it generates 24 bytes from the system's cryptographic random source, encodes them URL-safely, and requires every single request to carry Authorization: Bearer <token> — checked in the listener, before the transport or any tool code ever sees the request; anything else gets a bare 401. The token is regenerated on every enable and destroyed on stop, so a token that leaked into some client's config file stops working the moment you cycle the switch.

One honest engineering note, straight from the code's own comment: the token lives in UserDefaults, not the Keychain. That looks like a mistake until you think about the threat model. The token's job is to keep other local processes out of a loopback port — the network never sees it. Any local attacker positioned to read Pixie's defaults could also read Pixie's memory or its Keychain entitlements; Keychain ceremony here would add ritual, not security. Storage should match the adversary, and this adversary is "another app on the same machine guessing a port."

Because the intended client is Claude Desktop through the mcp-remote shim (a small adapter that bridges Claude Desktop's stdio transport to a local HTTP endpoint), the Settings pane renders the exact configuration block to paste — server URL, header, and current token — so enabling the door and connecting a client is a copy-paste, not a scavenger hunt.

Last property of the door: it is single-occupancy. The transport is stateful and the server holds exactly one client slot, so there is one connected agent at a time — which turns out to matter for the next section.


3. Who's asking: attribution from the handshake

Every MCP session opens with an initialize handshake in which the client declares clientInfo — its name and version ("Claude", "Ollama", …). Pixie hooks that handshake: the server passes an initializeHook closure to the swift-sdk Server, which fires on connect and stamps the declared name into MCPServerService.connectedClientName. The name is cleared on stop and on every fresh start, so a stale identity never outlives its connection — and because the door is single-occupancy, "the connected client's name" is a well-defined fact rather than a guess.

That name becomes the attribution on everything the guest does. A client that never introduced itself falls back to the generic label "MCP client":

  • Every write's audit entry records who drove it — send_message (Claude), not just send_message.
  • The approval sheet's summary names the asker: "Claude wants to message Ada."
  • Every message or post the guest sends is stamped, in your local database only, with the client name — so your own copy of the conversation renders "You · Claude", and six months from now you can tell which of your words were ghostwritten.

One boundary and one caveat, both load-bearing. The boundary: attribution is local-only. It is stored on your device and echoed to your sibling devices, but it never rides the wire to the recipient — the peer receives a completely normal message, indistinguishable from one you typed. Whether to disclose your co-author is your call, not a protocol side effect. The caveat: the name is self-declared. Any client can call itself "Claude," so attribution is honest labeling, not authentication. It exists to keep your own records legible, and the actual security never rests on it — a client named anything at all still faces the same projections and the same approval sheet.


4. Reads: projections that cannot reach the sealed zone

The catalog serves six read tools:

Tool Returns
list_conversations your threads: peer name, last message preview, activity time, count
get_messages one conversation's messages, oldest → newest
search_messages keyword search across message bodies
list_contacts contact names + whether each is on Pixie
search_notebook matches from the agent's Notebook — clear zone only
list_posts the recent feed: post id, author, content, time

The interesting design is not the list; it is the plumbing every item shares. A read tool's handler does exactly one thing: dispatch, by name, into MCPReadModel — a single facade struct that is the only data surface MCP reads flow through. And the facade is a whitelist of projections: each method builds a purpose-shaped output struct (ConversationSummary, MessageOut, ContactOut, PostOut) containing exactly the fields the tool advertises, and nothing else exists to return.

The security claim is worth stating precisely, because it is the article's central trick: MCPReadModel has no code path to the Notebook's sealed zone (NotebookNote.sealedBody — the agent-only notes encrypted under a key you deliberately don't surface; see Mechanism: Memory & the Notebook), to RelationshipState.circle (the agent's private closeness assessment of each contact), or to p2pState (the raw per-relationship bookkeeping). This is enforcement by omission: the trust boundary is not a filter sprinkled through tool handlers ("remember to strip the circle field"), it is the absence of any function that reads those fields at all. A future contributor adding a seventh read tool composes existing projections and inherits the boundary; leaking the sealed zone would require writing new code that reaches for it, which is the kind of change a reviewer sees.

You can watch the principle at work in the smallest projection in the file:

struct ContactOut: Codable, Sendable {
    let name: String
    let onPixie: Bool
    // NB: no circle, no p2p_state — those never leave the owner's trust domain
}

The struct is the policy.

A few finer exclusions ride along. Deleted messages stay deleted — every message projection filters rows you removed. @pixie agent-tag rows — the inline summons where you talk privately to your own agent inside a thread — are excluded from every conversation read, mirroring the same exclusion the peer-send gate enforces on the wire: your side-channel with your agent is not thread content, for guests or anyone. And search_notebook does not query notes and filter; it calls the Notebook store's clear-only search entry point, so sealed rows are outside the query itself.

One more deliberate asymmetry, easy to miss: this catalog is not the tool registry Pixie's own agent uses. The on-device agent's @-namespace has no raw read-every-message tool and no message-send tool; those powers exist only here, on the consent-gated external surface. Two audiences, two doors — and the more powerful door has a human standing in it, as the next two sections show. (Amusingly, the two worlds collide even at the type level: the MCP protocol's call-a-tool request is named CallTool, and so is Pixie's own L2 meta-tool from Mechanism: CallTool — One Tool to Bind Them — the server code has to write MCP.CallTool to keep the compiler pointed at the right one.)


5. Writes: parse, then gate, then ask

There are exactly three write tools — send_message, create_post, comment_on_post — and their own catalog descriptions warn the calling model what it is in for: "The user approves every send inside Pixie before anything goes out — this exact text, this recipient, this once." Setting the client's expectations in the tool description is not decoration; a model that knows a human gate exists writes drafts for the human, not around them.

Every write call runs the same four-stage pipeline inside the server:

tools/call: send_message { conversation_key, body }
   │
   ├─ 1. PARSE + RESOLVE      malformed key, empty body,
   │      (MCPWriteRequest)    unknown contact… → error, no sheet
   ├─ 2. RATE / SIZE GATE     over a cap → error, no sheet
   │      (MCPWriteGate)
   ├─ 3. APPROVAL SHEET       deny / dismiss / 120 s silence
   │      (suspends the call)  → "The user declined this request."
   ├─ 4. SEND                 sealed send + sibling echo,
   │      (ComposeService)     stamped with attribution
   └─ 5. LEDGER RECORD        only now does it count toward the caps

Stage 1 — parse first, always. Arguments are parsed into a typed MCPWriteRequest — a small enum with one case per tool — and validated before anything else happens: bodies are trimmed and must be non-empty; a send_message key must be a well-formed 1:1 conversation key (p: + a 64-hex-character public key — group threads, keyed g:, are explicitly unsupported over MCP in v1, because the group send path needs member resolution this surface doesn't carry yet); a comment's post_id must decode to a real id, must name a post that exists, and must target a contact's post, never your own. Post recipients are resolved from contact names to actual keys, and one unknown name fails the whole call with the list of names it couldn't find.

The ordering is the point. Parsing and resolution complete before the gate and long before the sheet, which buys two properties at once: a malformed call fails cheaply without ever burning the approval surface, and — more importantly — the user only ever sees a well-formed, fully-resolved draft. The sheet never shows "send <maybe this parses> to <someone, hopefully>." By the time a human is asked, the machine's uncertainty is gone.

Stage 2 — the gate. MCPWriteGate is a deliberately tiny, pure value type — no side effects, no storage, injected clock — so tests can hammer it exhaustively. It enforces three caps: bodies at most 4,000 characters, at most 10 sends per rolling hour (messages and comments share this class), and at most 5 posts per rolling day. Its history comes from MCPWriteLedger, a persisted list of timestamps of prior approved writes — persisted so the caps survive an app relaunch; a client cannot reset its quota by getting you to restart Pixie. Two subtleties encode the philosophy. First, only writes that were approved and sent are recorded (stage 5), so a denied or failed request never burns quota — the caps meter what actually went out, not what was attempted. Second, notice what the rate limit actually protects: not the relay, which could shrug off far more, but you. An approval sheet is only meaningful if it is rare enough to read. A runaway client that could raise sixty sheets an hour would train you to tap Approve without looking; the gate caps the ask-rate so each ask stays a real question. Fatigue is an attack surface.

Stages 3–5 are the approval ceremony itself, which deserves its own section.

Two availability notes complete the pipeline. If Pixie isn't fully onboarded on the Mac yet, write tools report themselves unavailable rather than half-working. And every write attempt — including ones that fail parsing or get denied — lands in the audit log the moment it arrives, before any other stage runs. The log records asks, not just outcomes.


6. The sheet: the exact draft, or nothing

Here is the mechanism the whole article has been building toward. When a write reaches stage 3, the tool handler calls requestApproval and suspends — the server parks the in-flight MCP call on a Swift continuation and publishes an MCPPendingWrite: the tool name, a human summary ("Claude wants to message Ada"), and the body, verbatim. The MCP client sees nothing yet; from its side, the tool call is simply still running.

The pending write is observed from the app root. A view modifier installed at Pixie's root view (via a small process-wide hub, MCPHub) presents the approval sheet the moment a pending write exists — which means approval works wherever you are in the app, not just if you happen to have Settings open. The sheet shows four things: the action (send_message), the request summary with the asker's name, a plain-language footer ("An AI app connected over MCP wants Pixie to send this. Nothing goes out unless you approve — this exact text, this recipient, this once."), and a Draft section containing the exact body that will go out. Not a summary of the draft, not a truncated preview — the text itself, selectable, the very string that stage 4 will hand to the send path. What you approve is what is sent, by construction: the approved MCPWriteRequest value is the input to the send, with no re-parse and no rewrite between the sheet and the wire.

Every way out of the sheet except the Approve button is a denial:

  • Deny resumes the suspended call with a refusal.
  • Dismissing the sheet — swiping it away, the universal "not now" gesture — is wired to the same denial path. Ambiguity resolves to no.
  • Silence: a 120-second watchdog auto-denies. This one exists for the guest's sake as much as yours — a headless MCP client must never hang forever on a sheet nobody is looking at. (The timeout checks that the write it is expiring is still the pending write, so a timer from an already-decided request can never kill a later one.)
  • Concurrency: one pending write at a time. A second write arriving while a sheet is up is denied outright — not queued. A queue would mean sheets popping in sequence, each pre-loaded while you were reading the last; every ask must arrive on a quiet surface.

A denial, by any route, resumes the tool call with the string "The user declined this request." flagged as an error — a deliberate phrasing. The client's model learns that a human said no, which is exactly the context it needs to stop, rephrase, or ask its own user what to do differently.

On Approve, stage 4 hands the draft to ComposeService — the one door all of Pixie's outbound writes leave through, MCP or not. That single funnel means an MCP-driven message inherits the entire send machinery for free: it goes out as a sealed send (end-to-end encrypted, with the relay unable to link sender to recipient — the Relay series covers that sealing), and it echoes to your sibling devices, so the conversation your phone shows includes the message your Mac's guest just sent — labeled "You · Claude." Only after the send succeeds does stage 5 record the ledger timestamp; a send that fails reports the failure to the client and costs no quota.


7. The receipts: an audit log

The last piece is the humblest: a rolling audit log — tool name and timestamp, newest first, capped at the last 200 calls — rendered in the same Settings pane that hosts the on/off toggle. Read calls log their name; write calls log their name plus the asking client's attribution, so the pane shows who drove each send, not merely that one happened.

It would be easy to dismiss this as UI garnish, but it closes a real gap. Reads never raise a sheet — that is by design, since a per-read ceremony would make the whole surface unusable, and reads were shaped safe in §4 rather than gated. The audit log is the compensating control: reads are invisible in the moment but visible in the record. If a client you connected turns out to be chatty — polling your conversations every thirty seconds, searching for things you never asked it to care about — the log is where that behavior becomes legible, and the toggle above it is where it stops. Ephemeral, local, capped: not surveillance infrastructure, just enough memory to answer "what has this thing been doing?"


8. The shape of consent

Step back and the design resolves into one sentence per layer:

Layer Mechanism Guarantee
Reach loopback bind + per-enable bearer token only a program on your Mac, only while the switch is on
Identity initialize handshake → attribution every act labeled with its driver, "You · Claude"
Reads one whitelisted projection facade sealed zone / circle / p2p state unreachable by omission
Writes parse → gate → suspend on the sheet the exact draft, approved per call, or nothing
Memory rolling audit log the invisible calls leave a visible record

The recurring move — the one worth carrying to any system that lets a foreign agent in — is that consent here is a structural property, not a policy. The server does not promise to keep sealed notes from guests; there is no function that returns them. The write path does not promise a human reviewed the text; the tool call is physically suspended on a continuation that only the sheet can resume. Policies are sentences in documentation that code can drift away from. Structure is what the code cannot do, and against a guest whose behavior you cannot predict — which is every MCP client, forever — what the code cannot do is the only guarantee that survives contact with a stranger.


References

  • Model Context Protocol — the open specification: clients, servers, tools/list, tools/call, and the initialize handshake that carries clientInfo.
  • MCP Swift SDK — provides the Server and stateful HTTP transport the loopback listener adapts.
  • mcp-remote — the stdio↔HTTP shim that lets Claude Desktop reach a local HTTP MCP server with a bearer header.
  • Apple, Network framework (NWListener) — the loopback-pinned socket under the door.

Next: Mechanism: Memory & the Notebook.