Status: plan validated against the codebase; ready to implement. Pick this up by working through the phases below. Validation pass added the "Reality corrections" section (after Locked decisions) — read it before touching code.
⚠️ Current behaviour differs from this plan in a few places (menu-bar build). The MCP server is now on by default (an explicit toggle-off persists), and lives behind the menu's MCP/Tunnel submenu rather than a Settings pane — MCP toggle, Open tunnel, and Open approval window are all menu items; Settings only holds the set-once values (auth token, port, tunnel name/URL, cloudflared path). The MCP surface is eight read-only tools — the write tools described below (mark_read,delete_messages,move_to_junk,diagnose_junk_mailboxes) were removed; Mail state changes go through Mail.app. OAuth discovery metadata is host-aware: a loopback request advertiseshttp://127.0.0.1:<port>as the resource/issuer (so a local client's RFC 8707 resource check matches), while tunnel requests use the configured public URL — the bearer check is independent of the advertised issuer. Local Claude Code authenticates with the static token in anAuthorizationheader (no OAuth). See README.md for the current setup steps. The protocol/transport/ tool design below is otherwise accurate.
Goal: when FMail.app is running, expose an MCP server on 127.0.0.1:8765 so Claude Code (or any MCP-compatible LLM client) can query the FMail index and mark messages read. The point is to leverage the existing index/threading/DSL so the LLM can triage email without loading everything into context. Standalone daemon (LaunchAgent) is not in scope for v1; we accept the lifecycle constraint that FMail must be open. See FMailSpec.md §10/§12 for the wider context.
These are the LLM-side flows we want to make easy. The MCP server only exposes data + a single write — the LLM does the reasoning.
- Triage: "what's actually important in my last 7 days of unread?"
- Find: "the email Anna sent about the school trip last March."
- Open-loop detection: "what threads have I sent into and not heard back on?"
- Wrong-address audit: "any threads where I might've replied to Kyoko's AppStore alias?"
- Thread summary: "give me the gist of this 40-message chain."
- Periodic digest: weekly "here's what you missed / need to act on."
- Mark-read-without-reading: LLM proposes "these 12 are newsletters, mark read" → user OKs →
mark_readcall.
Move-to-trash and move-to-spam shipped as a follow-up after the core 6 tools — see the "delete_messages / move_to_junk" section below. Archive is still deferred.
| Question | Decision |
|---|---|
| Transport | HTTP on 127.0.0.1:8765 (Streamable HTTP / SSE). Stdio doesn't fit — it inverts the lifecycle. |
| Network stack | Apple Network.framework (NWListener over TCP). No external HTTP-server dep. |
| MCP framework | Hand-roll JSON-RPC 2.0 + the MCP handshake. ~300 LOC total. No SDK dependency. |
| Concurrency | MCPServer is its own actor. Per-request Task awaits existing actors (IndexDB, BodyLoader, ReadStatusController). No new locking primitives. |
| Auth | None. Bind to 127.0.0.1 only. Local-trust. Optional bearer token deferred. |
| MCP output schema | Separate DTO types in FMail/MCP/MCPModels.swift. Don't expose MessageHeader / ThreadSummary directly so the MCP contract stays stable across internal refactors. |
| Discovery / setup | Settings sheet with "Copy Claude Code config" button. User pastes the snippet into ~/.claude/settings.json. |
| Default state | Off by default. MCP server reads every email — explicit opt-in only. Loud privacy banner in settings. |
| Default port | 8765. Configurable. |
| Sync on connect | No. Don't trigger incremental sync when an MCP client connects. The FSEvents-driven index is good enough; LLM may occasionally see stale state. Document this. |
| Body-index freshness | Accepted limitation. search_emails matches FTS body content as it gets indexed; if a recent message hasn't been body-indexed yet, the LLM won't find it via body text. Document; don't paper over. |
| Search interface | Single DSL string (search_emails(query: String, …)). The LLM learns the grammar from the tool description (paste FMailSpec.md §6.2 in). No search_emails_simple(from:, to:, …) second tool in v1. |
| Write surface | Only mark_read in v1. Routes through existing ReadStatusController pipeline. |
Long mark_read runs |
Document the bound; no SSE in v1. AppleScript dispatch is synchronous and can take 5–30 s for big batches across multiple Gmail accounts. Tool description tells the LLM to keep batches ≤ ~50. SSE/streaming progress would dodge client timeouts but adds a parser, a writer, and a per-tool decision — defer until usage demands it. |
| Move/delete reliability on Tahoe | Server-direct writeback backend — see WRITEBACK_PLAN.md. Tahoe broke Mail.app's AppleScript handler for mailbox-resolution; the fix is to route move/delete through Gmail API for Gmail accounts and IMAP for the rest. AppleScript stays as fallback for mark_read and delete (those still work via AppleScript). For move_to_junk, AppleScript is removed entirely — too broken on Tahoe to be a useful fallback; calls hard-fail with a clear "authorize Gmail or configure IMAP" message instead of timing out forever. |
Snippets in search_emails |
Omit in v1. The LLM can call get_email for body context. FTS5 snippet() is an optional A4 polish. |
Validation pass against the actual code surfaced six deltas the original draft of this document got wrong. They become preflight work folded into Phase A2:
-
MessageHeaderis leaner than the DTOs need.IndexDB.search()(IndexDB.swift:506) returns[MessageHeader]withrowId, mailboxRowId, subject, senderAddress, senderDisplay, dateSent, dateReceived, isRead, isFlagged, rfcMessageId, imapUID— nomailbox_path, nothread_id, nohas_attachment. The MCPEmailRefDTO needs these, so a side-fetchenrichForMCP(rowids:) -> [Int: (mailboxPath, threadId, hasAttachment)]is required. -
is_outgoingis NOT a stored column. The original plan claimed it was populated by the indexer. Reality: it's computed at query-time viaoutgoingFlagExpr(IndexDB.swift:970) —LOWER(m.sender_address) IN (SELECT LOWER(email_address) FROM accounts WHERE email_address IS NOT NULL).find_unanswered_threadsSQL must use the same expression. -
loadMessage(rowid:)does not exist. Bothget_emailandmark_readneed it. Add as a single-row SELECT mirroring the column list ofsearch(). -
BodyLoader.loadBody(messageRowId:mailbox:)requires aMailbox, not just a rowid. Soget_emailandget_threadbody fetches needIndexDB.loadMailbox(rowid:)first. (Alternative: hop to@MainActorand usemodel.mailboxes— uglier.) -
recipientstable lacks a read helper. Schema is(message_rowid, kind, position, address, display)withkind 0=to, 1=cc, 2=bcc, 3=from.get_email'sto/ccfields needloadRecipients(messageRowId:). -
ReadStatusController.setReadStatus(messages:isRead:)is fire-and-forget (ReadStatusController.swift:23). The MCPmark_readhandler needs to await the AppleScript dispatch result so it can return{applied, errors}. Add asetReadStatus(rowids: [Int], isRead: Bool) async -> (applied: Int, error: String?)variant that resolves rowids →MessageHeaderviaIndexDB.loadMessageand runs the same pipeline but awaits theMailScripter.Resultinstead of dispatching detached.
These are folded into the phasing below.
Other reality checks that came back fine as the plan described them: actor model on IndexDB and BodyLoader; MailModel.boot() plug-in point at line 154 right after syncCoordinator is created; xcodegen auto-discovers any .swift under FMail/; no existing Settings scene in FMailApp.swift; footer location in AppShell.swift; MailScripter.setReadStatusBatch returns a structured Result. None of those need changes.
The original 6 plus delete_messages and move_to_junk, added after Phase A4 to mirror the Mark Read / Mark Unread bulk-action UI in the LLM surface. Both routes through the same ReadStatusController pipeline: optimistic UI removal + AppleScript dispatch + sync-skip window. Same time-bound caveat as mark_read — keep batches ≤ ~50.
Single source of truth: FMail/MCP/MCPTools.swift. JSON shapes below are illustrative — finalize when implementing.
Reuses: QueryParser + Evaluator + IndexDB.search. Tool description in the schema must include the DSL grammar so the LLM uses it correctly.
// Input
{
"scope": "all_mailboxes" | { "mailbox_rowid": 7 },
"since": "2025-04-01", // optional
"until": "2025-05-09", // optional
"unread_only": false, // optional, default false
"limit": 100 // 1–600, default 100
}
// Output
{
"threads": [
{
"thread_id": 9876,
"latest_subject": "School trip update",
"latest_sender_display": "Anna",
"latest_date_received": "2025-03-14T10:23:00Z",
"message_count": 4,
"unread_count": 1,
"flagged_count": 0,
"mailbox_path": "INBOX"
}
]
}Reuses: loadAllThreadSummaries / loadThreadSummaries. May need a thin overload that accepts since / until / unread_only filters; current API is (mailboxRowId, limit) only — add filtering on top, or do a Swift-side filter on the result for v1 (acceptable at limit ≤ 600).
// Input
{
"thread_id": 9876,
"include_bodies": true, // default true
"max_body_chars": 8000 // default 8000 per message; truncates with "[…truncated]"
}
// Output
{
"messages": [ /* array of EmailFull */ ]
}Reuses: loadThreadMessages (with .excludeDrafts, its default scope), then BodyLoader.loadBody per message.
// Input
{ "rowid": 12345, "max_body_chars": 8000 }
// Output (EmailFull)
{
"rowid": 12345,
"thread_id": 9876,
"mailbox_path": "INBOX",
"subject": "…",
"sender_display": "Anna",
"sender_address": "anna@example.com",
"to": ["me@me.com"], // parsed from headers
"cc": [],
"date_sent": "…",
"date_received": "…",
"is_read": false,
"is_flagged": false,
"rfc_message_id": "<…>",
"plain_text_body": "…", // truncated per max_body_chars
"html_body_present": true, // boolean only; we don't ship HTML to the LLM
"attachments": [
{ "name": "trip.pdf", "content_type": "application/pdf", "byte_count": 124000 }
]
}// Input
{
"since": "2025-04-01",
"our_address": "felix@me.com", // optional; if absent, any account address
"limit": 50
}
// Output
{
"threads": [
{
"thread_id": 9876,
"latest_outgoing": { /* EmailRef shape */ },
"days_silent": 12,
"recipient_addresses": ["someone@example.com"]
}
]
}New SQL needed. Sketch: for each thread that has at least one outgoing message from our_address (or any account address) after since, the latest message in the thread is outgoing AND its date_received is older than today. is_outgoing is computed, not stored: use the existing outgoingFlagExpr pattern (LOWER(m.sender_address) IN (SELECT LOWER(email_address) FROM accounts WHERE email_address IS NOT NULL)). When our_address is supplied, restrict to that one address; otherwise match any account email. Add as a method on IndexDB. Tests for this go in Phase A4 against an in-memory fixture DB.
// Input
{ "rowids": [12345, 12346, 12347], "is_read": true }
// Output
{ "applied": 3, "errors": [] } // errors[] populated if any rowid fails AppleScript dispatchRoutes through ReadStatusController but needs a new awaitable variant. The existing setReadStatus(messages:isRead:) is fire-and-forget — it Task { ... }s and returns. For MCP we want to await the MailScripter.Result and report it back.
Adopted approach (Option α): Add setReadStatus(rowids: [Int], isRead: Bool) async -> (applied: Int, error: String?):
- Resolve rowids →
MessageHeaders viaIndexDB.loadMessage(skipping any that don't resolve). - Run the existing optimistic-flip pipeline on the resolved headers.
- Await
MailScripter.setReadStatusBatchinstead ofTask.detached. - Return
(applied: matchedCount, error: errorMessage). The existingbulkActionErrorplumbing still fires for UI consistency.
Tool description tells the LLM: keep batches ≤ ~50; bigger batches risk client timeouts because osascript linearly scans Mail.app's per-mailbox messages.
// Both share the same shape:
// Input
{ "rowids": [12345, 12346] }
// Output (MarkReadResult shape — `applied: matched count`, `error: optional string`)
{ "applied": 2, "error": null }Routes through ReadStatusController.deleteMessages(rowids:) async / moveToJunk(rowids:) async, which use the same optimistic-removal + awaitable-AppleScript pipeline as mark_read. The optimistic flip removes rows from messagesInSelectedThread / searchResults, decrements thread messageCount/unreadCount (dropping threads that go to zero), decrements per-mailbox totals + the global badge. We do NOT update the DB — the next FSEvent-driven sync re-mirrors Apple's Envelope Index and reconciles naturally.
AppleScript actions:
-
Delete:
delete msg— Mail.app moves to the Trash mailbox of the relevant account, matching the Delete key in the UI. Single statement; works reliably across iCloud and Gmail. -
Junk: a 3-step block, generated by
MailScripter.moveToJunkAction(accountVar:):set junk mail status of msg to true— always succeeds, fast, local; also helps train Gmail's spam filter.- Resolve target mailbox: try
junk mailbox of <accountVar>first; ifmissing value, fall back to walkingmailboxes of <accountVar>for names matchingSpam/Junk/Spam mail/Bulk Mail/[Gmail]/Spam. set mailbox of msg to tgtMbox— the actual move.
The fallback exists because
junk mailbox of <account>returnsmissing valuefor some Gmail setups (observed in practice — symptom is silent no-op). The action's<accountVar>differs between the account-scoped block (theAccount) and the cross-account fallback (anAccount);MailScripter.runActionBatchaccepts two action strings to handle this.MailScripter.makeLookupBlockwas updated to indent every line of a multi-line action, not just the first.
Same time-bound caveat as mark_read. Move from [Gmail]/All Mail is a server-side IMAP MOVE — can take 10-60s and may exceed an MCP client's HTTP timeout while still completing on Mail.app's side. The optimistic UI removal masks this in FMail; from the MCP perspective, a move_to_junk timeout is recoverable by re-checking via search_emails after ~30-60 seconds.
If junk persistently doesn't take effect, run Tools → Diagnose Junk mailboxes… in FMail — it reports what Mail.app exposes as junk mailbox of <account> for each configured account (and surfaces missing value cases), so we can tell whether the failure is "our script picked the wrong mailbox" vs "Mail.app reports no junk mailbox at all for this account".
Tests: FMailTests/MailScripterTests.swift covers the AppleScript text construction — pins the 4 invariants above (status set, junk-mailbox lookup, name-search fallback, set-mailbox), the correct account-variable per context, and that multi-line actions get indented properly inside repeat with msg in matches. These are pure string tests; they don't invoke Mail.app.
MCP is JSON-RPC 2.0 over a transport. The Streamable HTTP transport is the v2024-11-05+ spec:
- Single endpoint:
POST /mcp - Each request is a JSON-RPC message; responses come back over the same connection (or via SSE for streaming).
- For our v1 we don't need streaming — handle each request synchronously and return the JSON-RPC response. (Add SSE later if we want progress notifications during long calls.)
Handshake to support:
initialize(client → server) — return server capabilities, protocol version, server info.initializednotification (client → server) — no response.tools/list— return the 6 tools with JSON-Schema input/output.tools/callwith{name, arguments}— dispatch to handler, return result.- (Optional)
pingfor keepalive.
JSON-RPC error codes: use standard codes (-32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal). Define an FMail-specific code for "index not ready" / "FDA missing" — -32000-and-down range is reserved for app-defined.
MailModel
├── boot() …
│ └── if MCPSettings.enabled && loadState == .ready
│ └── self.mcpServer = MCPServer(port:); await mcpServer.start()
└── (settings change → restart server)
MCPServer is @ObservationIgnored on MailModel (analogous to syncCoordinator and readStatus). Stop on app termination via applicationWillTerminate — or rely on process exit (NWListener cleans up).
NWListener (server port 8765)
└── per-connection: NWConnection
└── per-request: parse JSON-RPC → dispatch
└── await IndexDB / BodyLoader / etc. (existing actors)
└── encode DTO → JSON-RPC response → write back
MCPServer itself is an actor. Its dispatch(_ request:) async throws -> Response is the entry point each connection's read-loop calls.
Multiple in-flight requests serialize on IndexDB (one connection, actor-isolated). Acceptable — each call is fast.
All DTOs in MCPModels.swift are Sendable value types. MCPServer is an actor → implicitly Sendable. Handler closures are @Sendable.
FMail/MCP/
├── MCPServer.swift actor; owns NWListener; accept loop; request → tool dispatch.
├── MCPTransport.swift HTTP framing (request/response), JSON-RPC 2.0 envelope,
│ initialize/tools/list/tools/call dispatch table.
├── MCPTools.swift Tool registry: name → (JSON-Schema, handler closure).
├── MCPModels.swift Sendable Codable DTOs (EmailRef, EmailFull, ThreadRef,
│ UnansweredThread, AttachmentRef, …). The stable contract.
├── MCPHandlers.swift One async func per tool. Reads from IndexDB / BodyLoader /
│ ReadStatusController; encodes DTOs.
└── MCPSettings.swift @AppStorage-backed: enabled (Bool), port (Int).
FMail/UI/
├── MailModel.swift + var mcpServer: MCPServer? (ObservationIgnored).
│ boot() starts it when settings.enabled.
├── AppShell.swift + small footer pill: "MCP :8765" when running.
└── Settings/ new directory
└── SettingsView.swift Tools menu → "Settings…" (or ⌘,):
- MCP enabled toggle
- Port field
- Status (running / stopped / error)
- "Copy Claude Code config" button
- Privacy banner
FMailTests/
└── MCPTests.swift DTO encode/decode round-trip; tool input validation
(missing fields → invalid_params); findUnansweredThreads
SQL against fixture data; integration test that binds on
a random port and exercises the handshake + 1 of each tool.
FMail/MCP/MCPSettings.swift— singleton-ish wrapper aroundUserDefaults.standardformcp_enabled: Bool(default false) andmcp_port: Int(default 8765). NOT@AppStorage—MailModelis@Observable, not a SwiftUI View;@AppStorageonly works in views. The Settings view can use@AppStoragefor two-way binding; the model side reads/writes raw UserDefaults and the view explicitly callsmodel.applyMCPSettings()on toggle change.FMail/MCP/MCPTransport.swift— JSON-RPC 2.0 framing over HTTP/1.1. Read until\r\n\r\n, parse request line +Content-Length, read body, writeHTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: N\r\nConnection: close\r\n\r\n<body>. Single endpointPOST /mcp. ~150 LOC.FMail/MCP/MCPServer.swift— actor wrappingNWListenerbound toNWEndpoint.Host("127.0.0.1")only withparameters.allowLocalEndpointReuse = true.start()/stop(). Empty tool registry. Handlesinitialize/notifications/initialized(notification, no response) /tools/list(returns []) /tools/call(returns method-not-found). SurfaceslastError: String?for the eventual SettingsView.- Wire start/stop into
MailModel.boot()aftersyncCoordinatoris set (MailModel.swift:154). Add@ObservationIgnored var mcpServer: MCPServer?. Gate onMCPSettings.shared.enabled && loadState == .ready. Add afunc applyMCPSettings()on MailModel that the SettingsView calls on toggle change. - Done when: with toggle on,
curl -X POST localhost:8765/mcp -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'returns server info; Claude Code's MCP client connects and lists zero tools.
- IndexDB additions in a new
FMail/Core/Index/IndexDB+MCP.swiftextension to keep MCP plumbing isolated from the rest of the read API:loadMessage(rowid: Int) -> MessageHeader?— single-row SELECT mirroring the column list insearch()(IndexDB.swift:506).loadMailbox(rowid: Int) -> Mailbox?.loadRecipients(messageRowId: Int) -> [(kind: Int, address: String, display: String?)].enrichForMCP(rowids: [Int]) -> [Int: (mailboxPath: String, threadId: Int, hasAttachment: Bool)]— one SQL withapple_rowid IN (...)joiningmailboxes. Honors theeffectiveThreadIdExprso unthreaded messages get a synthetic thread id.
FMail/MCP/MCPModels.swift— Sendable Codable DTOs:EmailRef,EmailFull,ThreadRef,AttachmentRef,UnansweredThread. ISO-8601 dates as strings.FMail/MCP/MCPHandlers.swift(read half) —search_emails,list_threads,get_thread,get_email. Pattern per handler:- Validate input (throw
invalidParamson bad shape). - Await
IndexDB.search/loadAllThreadSummaries/loadThreadMessages/BodyLoader.loadBody. - For body fetch:
IndexDB.loadMailbox(rowid:)→BodyLoader.loadBody(messageRowId:mailbox:). Truncate plain text viaString.prefix(maxBodyChars). - Map internal types → DTOs. Skip snippets in v1.
- Validate input (throw
FMail/MCP/MCPTools.swift— register 4 tools with JSON Schemas.search_emailsdescription includes the DSL grammar table fromFMailSpec.md§6.2 verbatim so the LLM can compose queries.- Done when: in Claude Code,
search_emails {query: "from:anna last 30 days school"}returns sensible results;get_thread {thread_id: …}returns full bodies.
IndexDB.findUnansweredThreads(since:ourAddress:limit:)— pure SQL, no FTS. Uses the sameoutgoingFlagExprpattern asrepresentativeSelectList. Algorithm: for each thread containing at least one outgoing message after:since, find the latest message in the thread; emit if that message is outgoing AND no later incoming reply exists. Bindour_address(lowercased) when supplied; otherwise match against any account email.MCPHandlers.findUnansweredThreadshandler.ReadStatusController.setReadStatus(rowids: [Int], isRead: Bool) async -> (applied: Int, error: String?)— resolves rowids viaIndexDB.loadMessage, runs the existingapplyOptimisticThreadBulkRead/persistIsReadpath, then awaitsMailScripter.setReadStatusBatch(noTask.detached). Maps theResultto(applied, error). ExistingbulkActionErrorstill fires for UI consistency.MCPHandlers.markReadhandler.FMail/UI/Settings/SettingsView.swift— toggle + portTextField+ status (Running on :PORT / Stopped / Error: …) + privacy banner + "Copy Claude Code config" button:Toggle/port changes call{ "mcpServers": { "fmail": { "type": "http", "url": "http://127.0.0.1:8765/mcp" } } }model.applyMCPSettings()to start/stop the listener.FMailApp.swift— addSettings { SettingsView(model: model) }scene (gives⌘,for free) and route the model in via@FocusedValueor accept a singleton hook. Tools menu can stay as-is.- Done when: flipping the toggle on/off cleanly starts/stops the listener; Claude Code can mark messages read and the change appears in FMail's UI immediately (optimistic flip via existing pipeline).
FMailTests/MCPTests.swift:- DTO encode/decode round-trip for each of the 5 DTO types.
- JSON-RPC envelope edge cases (missing
id, malformed JSON, unknown method). findUnansweredThreadsagainst an in-memory fixture (IndexDBopened on a temporary file path; in-memory:memory:doesn't survive actor hops but a tmp file works fine).- Integration test: bind on port 0 (kernel picks free port), full handshake (
initialize+notifications/initialized+tools/list) + one call per tool against the fixture DB.
AppShell.swift— small "MCP :8765" pill infooterStatuswhenmodel.mcpServer?.isRunning == true. Hidden otherwise.- Update
IMPLEMENTATION.md— new Phase 5 entry describing the MCP server. - Add a one-paragraph blurb to
FMailSpec.md(probably as a new §15 or as a note in §12 Phase 5).
(Things that would be lost between sessions — read these before writing any code.)
initializerequest includesprotocolVersion,capabilities,clientInfo. Server responds with the same shape from its side. We saytools: { listChanged: false }(we don't change tools at runtime).initializedis a notification (noid, no response).tools/listreturns{ tools: [{ name, description, inputSchema }] }—inputSchemais JSON Schema.tools/calltakes{ name, arguments }and returns{ content: [{ type: "text", text: "<JSON-encoded result>" }], isError: false }. The convention is to JSON-stringify the result and put it in a single text content block.
- Bind to
NWEndpoint.Host("127.0.0.1")explicitly — not0.0.0.0and not default. Default binds to all interfaces. - Use
NWListener.State.failedto surface bind errors (port in use → user-visible error in settings). NWParameters.tcpwithparameters.allowLocalEndpointReuse = truefor clean restart.
- Read until
\r\n\r\n, parse status line + headers, readContent-Lengthbytes for body. Don't bother with chunked encoding (clients won't send it for small JSON requests). - Response:
HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: N\r\n\r\n<body>. Connection: closeis fine for v1 — one request per connection. Keep-alive can come later.
setReadStatus(messages:isRead:)(ReadStatusController.swift:23) is fire-and-forget — wraps the work inTask { @MainActor in ... }and returns. The MCPmark_readhandler can't reuse it directly: we need to await the AppleScript dispatch and get a result back.- New
setReadStatus(rowids: [Int], isRead: Bool) async -> (applied: Int, error: String?): same optimistic-flip + DB persist + AppleScript dispatch as the existing pipeline, but awaitsMailScripter.setReadStatusBatchdirectly (noTask.detached). The existingbulkActionErrorplumbing still fires for UI consistency. - Document the bound to the LLM: keep batches ≤ ~50. Mail.app linearly scans per-mailbox messages by
whose id is N; 100+ messages across multiple Gmail accounts can hit 30s+. The MCP transport is plain HTTP request/response — no SSE in v1 — so the call blocks until the AppleScript returns. If the LLM client times out, the work may still complete on Mail.app's side; the user can re-call to verify state.
- Description goes in
tools/listoutput. Paste the full DSL grammar fromFMailSpec.md§6.2 into thedescriptionfield ofsearch_emails's tool definition. The LLM reads it once and uses it. - Don't auto-translate natural language → DSL on the server side. The LLM does that.
- Always send
plain_text_body(fromMessageBody.displayText— already HTML-stripped). Never sendhtmldirectly; LLMs don't need it and it bloats context. - Set
html_body_present: trueif the message had an HTML part, so the LLM knows it's a marketing email vs. plain text.
- Bare
UserDefaults.standardformcp_enabled: Boolandmcp_port: Int. UserDefaults is fine — nothing sensitive. - React to changes the simple way: SettingsView is the only place that mutates these. On a toggle/port change, the view explicitly calls
model.applyMCPSettings()which decides whether to start, stop, or restart the listener. We don't need to subscribe toUserDefaults.didChangeNotificationfrom the model; that subscription has cross-actor headaches with@Observable. Direct call site is cheaper and clearer. @AppStoragecan be used inside the SettingsView itself for two-way binding to the toggle/port controls — but the model side reads raw UserDefaults viaMCPSettings.shared.
- Keep this scoped. Don't add
move_to_trash,propose_reply,summarize_thread, etc. The whole point is that the LLM does the reasoning. - If during use you find yourself wanting always-on without keeping FMail open → stop and pivot to Option B (
FMailCoreSwift package + LaunchAgent). Don't bolt daemon-mode onto FMail.app.
- Standalone daemon (Option B).
FMailCoreSwift-package extraction. Re-evaluate after 2 weeks of daily use. Move/trash/spam tools.Shipped asdelete_messagesandmove_to_junk(see Tool surface). Archive still deferred.- Attachment bytes. Endpoint to fetch attachment data. LLMs don't need bytes for triage.
- Streaming tool responses (SSE). Useful for long-running calls; none of our tools are slow enough yet.
- Auth / bearer tokens. Local-trust is fine for v1.
search_emails_simple(structured params instead of DSL string). Add only if the DSL form proves error-prone.- iOS companion (
FMailSpec.md§14). Independent of this work.
If, after ~2 weeks of daily Claude-Code use:
- You're using only one or two tools → trim the surface, don't grow it.
- You wish FMail didn't have to be open → promote to Option B (LaunchAgent +
FMailCore). - You want move-to-trash → that's a separate
MailScripterwork-stream, not a bigger MCP surface.