A Mac-resident MCP server that proxies Apple-native services — Mail, Calendar, iCloud Drive, Voice Memos, Reminders — to AI agents over stdio or HTTP. One trust boundary, one audit log, one place to enforce safety.
The bridge is built around a simple premise: an LLM agent talking to your iCloud should look more like a service account with scoped permissions than a fully-trusted user. Every call passes through the same policy pipeline (auth → ACL → redaction → injection-tagging → approval-gate → audit), and every layer is configurable per token.
Status: v1.0.0-beta.3 (public beta). 43 tools across 6 services, codesigned + notarized Developer ID build, 111 unit tests (incl. a schema validator that walks every registered tool), daemon + menubar UI with first-launch onboarding, auto-update via Sparkle (UI) and deckard self-update (CLI), CI on every push. Designed for personal homelab use; security model documented in docs/security-model.md. Known beta issues + roadmap in CHANGELOG.md.
Most "AppleScript MCP" projects expose Mail or Calendar as a thin RPC: tools fire, results come back as flat strings, the agent reads whatever the user reads. That's fine for trusted prompts and demo screenshots; it's wrong for any system where the agent might be compromised, the email content might be hostile, or the action chain might run unattended.
Deckard sits between the agent and macOS and adds:
- Default-deny ACL with per-token profiles. A "triage" agent gets
mail.list_messages+mail.mark_read+ nothing else. A "trusted" agent gets the full surface butmail.sendstill routes through an approval dialog. A "readonly" experiment can't write anything anywhere. - Outbound redaction. Before any tool result reaches the model, secret-shaped substrings are replaced with
[REDACTED:<rule>]. Cloud creds (AWS / GCP / Azure / DO), API keys (OpenAI / Anthropic / Stripe / Google / Twilio / npm), GitHub PATs, Slack/JWT tokens, RSA private blocks, SSN-like patterns — and one-time tokens: 2FA / OTP / verification / sign-in codes, magic-link URL params, inlinepassword:/passwd=values,PIN: 1234. The OTP rule requires the matched value to contain at least one digit, so common words like "expired" or "invalid" don't trip it. New rules drop in via config. - Inbound prompt-injection tagging. Mail bodies, calendar event notes, voice-memo titles, drive-file contents — anything the user didn't author — comes back wrapped in
<untrusted>…</untrusted>so the agent treats it as data, not instructions. When known injection patterns ("ignore previous instructions", role-impersonation prefixes, etc.) are detected, the wrapper escalates to a strong warning banner. - Approval gating for destructive actions.
mail.send,drive.write,calendar.delete_event,reminders.delete_reminder— set their ACL toapproveand every call pops a macOS dialog showing what's about to happen (recipients, body preview, file path, event title) before it executes. Per-tokeninteractive_approval = "never"lets trusted remote tokens skip the dialog (audit logs asapproved_by_policyfor forensics). - Multi-token auth with scoped profiles. Different agents get different secrets and different capabilities. Audit shows
caller: "bearer:triage"instead ofbearer:default. - Tailnet listener (opt-in). When
[tailscale] enabled = truethe daemon also binds the tailnet IPv4. Peer ACLs are delegated to tailscaled — set them in the Tailscale admin console, not here.tailscale whoisruns per request so audit rows for tailnet calls recordtransport=tailnet caller=ts:laptop:user@github. Bearer auth still applies on top. - Batch mail ops.
mail.move_message,mail.mark_read,mail.mark_unreadaccept a singleidOR anids: [string]array (up to 500). The batch path is one osascript invocation regardless of N — one Mail.app activation, one audit row, one approval dialog. - Append-only audit log with configurable retention (default 30 days). Every call records caller, transport, tool, arg-keys, decision, latency, byte count, error.
- Loopback by default. Tailscale binding is opt-in via config; nothing listens on a public interface.
- Per-tool tool-list filtering. Agents only see what their token can call. Denied tools don't surface in
tools/list, so context isn't burned on capabilities they can't use. - Codesigned with Developer ID + notarized. TCC grants persist across rebuilds; Gatekeeper accepts release artifacts on first open. No re-prompting on every fresh
swift build. - Auto-update with verification. The menubar app uses Sparkle (EdDSA-signed appcast) for "Check for Updates…"; the headless daemon ships a
deckard self-updatesubcommand that verifies SHA-256 + codesign + Developer ID team + Gatekeeper assess before swapping the binary andkickstart-ing the LaunchAgent.
What it doesn't do:
- It doesn't validate the agent's content before it leaves your network — that's the agent runtime's job.
- It doesn't prevent the user from misconfiguring a token to "allow everything." It documents the dangers; it can't read your mind.
- It doesn't promise to be safe under physical access to the machine. Anyone who can read
~/Library/Application Support/Deckard/tokens.tomlhas every bearer.
Tested on macOS 14+ (Sonoma) and macOS 26 (Tahoe). Apple Silicon.
Grab the latest DMG from the Releases page. Drag Deckard.app into /Applications, double-click, and the menubar icon appears. First launch opens a 6-step onboarding window (Welcome → Daemon → Token → Permissions → Connect → Done) that walks through token creation, surfaces required TCC grants with deep-links to System Settings, and gives you a copy-paste claude mcp add snippet.
After onboarding:
- Daemon listening at
http://127.0.0.1:8787/mcp - Audit log at
~/Library/Logs/Deckard/audit.jsonl - Default token at
~/Library/Application Support/Deckard/tokens.toml - Restarts on login
The release artifacts are codesigned and notarized — Gatekeeper accepts them on first open. Verify the SHA-256 sidecar against the DMG before running if you'd like.
The book icon turns green when the daemon's running, outline-only when stopped. Click for status; "Open Settings…" for the multi-tab window. Reopen onboarding anytime via Settings → Status → "Show Onboarding…".
For a Mac Mini sitting on a shelf or any server-style deployment. The Homebrew tap pulls the same notarized release tarball the DMG uses; brew handles the version pinning and updates.
brew tap lapidakis/deckard
brew install deckard
deckard config init
deckard auth add default --profile trusted
deckard install # writes the LaunchAgent and bootstraps the daemonUpdates: brew upgrade deckard. deckard self-update refuses on Homebrew-managed binaries — brew owns the metadata, and a self-update would leave it stale.
If brew isn't an option (offline install, locked-down server), grab the tarball asset directly:
TAG="v1.0.0-beta.3" # latest tag from the Releases page
curl -L -o deckard.tar.gz \
"https://github.com/lapidakis/Deckard/releases/download/${TAG}/deckard-${TAG}-arm64.tar.gz"
curl -L -o deckard.tar.gz.sha256 \
"https://github.com/lapidakis/Deckard/releases/download/${TAG}/deckard-${TAG}-arm64.tar.gz.sha256"
# Verify the checksum before extracting (sidecar is `<sha> <filename>`).
shasum -a 256 -c deckard.tar.gz.sha256
tar -xzf deckard.tar.gz
sudo mv deckard /usr/local/bin/
deckard config init # writes config.toml with defaults
deckard auth add default --profile trusted # mints a bearer token
deckard install # registers + bootstraps LaunchAgentdeckard install writes ~/Library/LaunchAgents/com.lapidakis.deckard.plist and bootstraps it under gui/<uid> so the daemon starts on login and respawns on crash. The first call to each surface (Mail / Calendar / Reminders / Apple Events) triggers a TCC prompt — click Allow once per surface; the grants persist across rebuilds because the binary is signed with a stable Developer ID.
To grab the bearer for your MCP client:
deckard auth show defaultTo uninstall:
deckard uninstall # bootouts + removes the LaunchAgent
sudo rm /usr/local/bin/deckard # or `brew uninstall deckard` if you used the tap
rm -rf ~/Library/Application\ Support/Deckard ~/Library/Logs/DeckardFor development, contributors, or anyone with a Developer ID and a preference for self-signed builds:
git clone https://github.com/lapidakis/Deckard.git
cd Deckard
make build # auto-detects your Developer ID; falls back to adhoc
.build/debug/deckard config init
.build/debug/deckard install # registers LaunchAgent, starts the daemon
make ui # builds the menubar app bundleThe codesign script (scripts/codesign.sh) resolves the signing identity in this order: $DECKARD_SIGN_IDENTITY → first detected Developer ID Application: in your keychain → adhoc with a warning. Adhoc builds run, but TCC grants don't persist across rebuilds — each make build re-prompts for Mail/Calendar/Reminders permissions.
CI runs swift test on every push (see .github/workflows/ci.yml). PRs that break the schema validator or any other test are blocked.
TOKEN=$(.build/debug/deckard auth show default)
claude mcp add --transport http deckard http://127.0.0.1:8787/mcp \
--header "Authorization: Bearer $TOKEN"Verify in any Claude Code session with /mcp — should show deckard ✓ connected and however many tools the default token's ACL allows.
- Architecture — modules, data flow, design principles
- Security model — threat model, layered defenses
- Configuration —
config.tomlandtokens.tomlreference - Operations — install, update (incl.
deckard self-updateand Sparkle one-time setup), audit, troubleshoot - Voice memo smoke test — example end-to-end test script
Built-in
health.ping— liveness probe; tiny payload, useful diagnostic
Mail (Phase 1) — Mail.app via NSAppleScript subprocess
mail.list_mailboxes,mail.list_messages,mail.searchmail.get_messagemail.create_draft(safe — opens in Mail.app for user),mail.send(approval-gated)mail.mark_read,mail.mark_unread,mail.move_message— each accepts singleidORids: [string](up to 500), returnsBatchResult { matched, missing, failed, elapsed_ms }
Calendar (Phase 2) — native EventKit
calendar.list_calendars,calendar.list_events,calendar.search_eventscalendar.get_event,calendar.nowcalendar.create_event,calendar.update_event,calendar.delete_event(all approval-gated)
Drive (Phase 3) — filesystem with traversal guard
drive.list,drive.stat,drive.read,drive.search,drive.usagedrive.materialize(force.icloudplaceholder download)drive.write(approval-gated; optional sandbox prefix in config)
Voice Memos (Phase 4) — read-only
voice_memo.list_recordings,voice_memo.get_recordingvoice_memo.read_audio(base64.m4a, 25 MiB hard cap)
Reminders (Phase 4.5) — EventKit .reminder entities
reminders.list_lists,reminders.list_reminders,reminders.get_reminderreminders.create_reminder,reminders.update_reminder,reminders.complete_reminder,reminders.delete_reminder
Contacts (Phase 4.6) — Contacts framework (CNContactStore)
contacts.search,contacts.get,contacts.list_groups,contacts.list_in_groupcontacts.create,contacts.update,contacts.delete,contacts.set_groups(all approval-gated)
Per-tool detail in docs/configuration.md.
Default-deny. Every tool starts at deny. ACL turns things on individually. There's no "everything's allowed" mode that you might forget to switch off.
Trust boundary at the bridge, not the agent. The agent could be malicious, compromised, or fed prompt-injected content. The bridge's job is to make every layer of that hostile input safe before it reaches code that touches iCloud, and to make every output safe before it reaches the model.
One audit log, append-only. Every call, every decision, with retention. If something happened, it's in the log, regardless of which agent made the call.
Native frameworks where they exist. EventKit for Calendar/Reminders, Contacts framework for the address book, FileManager + brctl for Drive, sqlite3 for Voice Memos and Mail's own indexes. AppleScript only when nothing else works (Mail.app, no public framework).
No fancy abstractions. Six service modules, one shape: *Adapter (talks to macOS) → *Tools (MCP handlers) → registered into the same dispatch pipeline. New phases plug in without touching BridgeCore.
Operate on files, not network APIs (for the UI). The menubar app reads tokens.toml, config.toml, and audit.jsonl directly. Same machine, same user. No control protocol to maintain.
- iMessage (Phase 5) — read
chat.db, send via AppleScript, sender allowlist - ACL editor in the menubar UI (currently view-only; mutations via CLI)
- Token CRUD in the menubar UI (creation lives in the onboarding flow; rotate / revoke / set-profile still CLI-only)
- XPC channel from daemon to menubar UI for approval dialogs — would let
.approveoutcomes prompt remote tokens reliably without falling back tointeractive_approval = "never" - Voice memo transcription via Apple Speech framework (currently agent-side STT)
SessionHolder.recreate()should drain in-flight requests before swapping the transport — closes the rare "Transport already started" race in the stale-session self-heal path