Pixieby Sociofabric

Mechanism: MCP, Letting Other Agents In

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. 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, the mechanism we call Puppeteer: 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 (the Mac build of the same codebase), because a door for desktop agents living on the same machine 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: a machine-readable spec of the arguments it accepts. 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 (the trained numbers that make a model behave the way it does), 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 (the address a computer uses to talk to itself, so traffic never leaves your Mac) behind a per-enable bearer token (a secret pass every request must carry), and only while a switch you flipped stays on.
  2. Reads: every read tool flows through one whitelisted projection layer (a fixed set of narrow, purpose-built views of your data) 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. Dismissing, ignoring, or timing out all mean no.
CLAUDE / FOREIGN MCP CLIENT 1 · REACH 127.0.0.1 · SWITCH ON PER-ENABLE BEARER TOKEN 2 · READS MCPREADMODEL / WHITELIST PURPOSE-SHAPED PROJECTIONS ONLY READ OUTPUT NARROW STRUCT 3 · WRITES PARSE → RATE GATE → HUMAN SHEET WRITE PARKED HUMAN APPROVE REACH IS LOCAL · READS ARE PROJECTED · WRITES WAIT FOR A HUMAN

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 network socket (the operating-system object that accepts connections) exists until you flip the toggle in Settings. Flipping it constructs the stack in MCPServerService.start(). A foreign client like Claude Desktop speaks stdio (plain text over the pipes local programs use to talk to each other) to mcp-remote, which forwards each request as HTTP with a bearer token to 127.0.0.1:11939.

There MCPHTTPListener (the loopback bind and the token gate) feeds request values to the swift-sdk's StatefulHTTPServerTransport, which drives MCPServerService. From that point, read tools resolve against MCPReadModel's projections (§4), and write tools enter the parse → gate → sheet pipeline (§5–6).

The bottom three layers 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. 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>. The check happens in the listener, before the transport or any tool code ever sees the request; anything else gets a bare 401, the standard HTTP "not authorized" refusal.

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 note on where the token lives, since it is the obvious question: nowhere. It is an in-memory value, minted when the server starts and dropped when it stops. It is never written to disk in any form, so there is nothing at rest for a Keychain (Apple's encrypted credential store) to protect. That also settles the threat model, the question of exactly which attacker you are defending against.

The token's job is to keep other local processes out of a loopback port. The network never sees it. And a local attacker positioned to read Pixie's memory has already won more than the token would give them. 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 (the protocol's opening exchange) in which the client declares clientInfo: its name and version ("Claude", "Ollama", …). Pixie hooks that handshake: the server passes an initializeHook closure (a small callback function) 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":

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, your other devices on the same account, 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's 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 (one front object every request passes through) that is the only data surface MCP reads flow through. And the facade is a whitelist of projections, a projection being a narrow copy of the data that holds only chosen fields. 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 three things. Not 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). Not to RelationshipState.circle (the agent's private closeness assessment of each contact). And not 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, the kind of change a reviewer sees.

ALLOWED SOURCES MCPREADMODEL NARROW OUTPUT MESSAGES CONTACT NAMES CLEAR NOTEBOOK POSTS WHITELIST FACADE ONE PORT PER READ TOOL NO GENERIC STORE HANDLE CONTACT { NAME, ON PIXIE } PURPOSE-SHAPED ONLY OWNER TRUST DOMAIN SEALED NOTEBOOK CIRCLE ASSESSMENTS RAW P2P STATE NO EDGE · NO CALL · NO VALUE CAN LEAVE THE SENSITIVE ISLANDS THE READ BOUNDARY IS ENFORCED BY ABSENCE

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. That mirrors the exclusion the peer-send gate enforces when a message actually leaves for a peer: 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. 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, so 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 pipeline inside the server. First, parse and resolve into a typed request. Then the rate and size gate: both fail with an error and no sheet. Then the approval sheet that suspends the call, the sealed send stamped with attribution, and only then the ledger record that makes 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: plus a public key written as 64 hexadecimal characters. (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. That 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, and a clock handed in from outside), so tests can control time and hammer it exhaustively. It enforces three caps: bodies at most 4,000 characters, at most 10 sends per rolling hour (counted over the last sixty minutes, not per calendar 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 matters: the caps survive an app relaunch, so 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 (the server that carries Pixie's messages, 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 (a paused function held in memory along with a handle that can resume it exactly once) 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:

THE VALUE THE HUMAN READS IS THE VALUE COMPOSE RECEIVES HUMAN APPROVAL SHEET ADA · EXACT TEXT APPROVE MCP TOOL CALL PARSE + RESOLVE RATE / SIZE SUSPENDED CONTINUATION ADA · EXACT TEXT = COMPOSE SEALED SEND SAME VALUE DENY · DISMISS · 120S · SECOND WRITE → ∅ · NOTHING SENT APPROVE RELEASES THE EXACT DRAFT · EVERY OTHER EXIT SENDS NOTHING

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, so only the recipient can read it, 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

Next: Mechanism: Memory & the Notebook.

← Mechanism: Sharing a HomebaseMechanism: Memory & the Notebook →