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:
- Reach: the server exists only on loopback, behind a per-enable bearer token, and only while a switch you flipped stays on.
- 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.
- 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 justsend_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 theinitializehandshake that carriesclientInfo. - MCP Swift SDK — provides the
Serverand 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.