Skip to content
Conduit

mcp server

strands-icloud

🍎 Strands tool for the Apple ecosystem β€” mail, calendar, messages, contacts, photos, notes, reminders, Find My

cagataycali/strands-icloudcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "strands-icloud": {
      "command": "uvx",
      "args": [
        "strands-icloud"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 22, 2026

🍎 strands-icloud

PyPI Python License: MIT Awesome Strands Agents

One Strands tool for the entire Apple ecosystem. Mail, Calendar, Messages, Contacts, Photos, Notes, Reminders, Find My β€” local-first on macOS, remote-capable via standard protocols, exposable over MCP.

Built for Strands Agents. Battle-tested inside devduck.

use_apple(action="messages.search", query="flight")
use_apple(action="mail.unread", limit=10)
use_apple(action="photos.search", query="drone")   # ← returns REAL image blocks the model can SEE
use_apple(action="findmy.locate", device="iPhone")

Table of Contents


Architecture

Two access layers, one dispatch surface:

flowchart TB
    A["🍎 use_apple(action=...)"]:::hub

    A --> L
    A --> R
    A --> C

    subgraph L["πŸ–₯ LOCAL Β· on-Mac Β· zero auth"]
        direction TB
        AS["AppleScript bridge<br/>Mail Β· Calendar Β· Contacts<br/>Notes Β· Reminders Β· Messages (send)"]
        FS["Direct file access<br/>chat.db (SQLite, read-only)<br/>Photos library (osxphotos)"]
    end

    subgraph R["🌐 REMOTE Β· anywhere Β· app-specific password βœ“"]
        direction TB
        IMAP["IMAP :993<br/>imap.mail.me.com"]
        CAL["CalDAV<br/>caldav.icloud.com"]
        CARD["CardDAV<br/>contacts.icloud.com"]
    end

    subgraph C["☁️ CLOUD · pyicloud · real password + 2FA"]
        FM["Find My<br/>devices Β· locate Β· play sound"]
    end

    classDef hub fill:#0a84ff,stroke:#0a84ff,color:#fff,font-weight:bold
    classDef default fill:#1c1c1e,stroke:#3a3a3c,color:#f5f5f7
    style L fill:#0e2a12,stroke:#32d74b,color:#f5f5f7
    style R fill:#0b1f33,stroke:#0a84ff,color:#f5f5f7
    style C fill:#2a0e1a,stroke:#ff375f,color:#f5f5f7
flowchart LR
    subgraph clients["Consumers"]
        DD["πŸ¦† devduck<br/>(pipx inject)"]
        SA["Strands Agent<br/>(pip install)"]
        CC["Claude Code / Desktop<br/>Cursor Β· Kiro<br/>(MCP)"]
        TERM["Terminal<br/>(uvx strands-icloud)"]
    end

    DD --> UA["use_apple"]
    SA --> UA
    CC -->|"strands-icloud-mcp<br/>stdio / HTTP"| UA
    TERM -->|"CLI"| UA

    classDef default fill:#1c1c1e,stroke:#3a3a3c,color:#f5f5f7
    style UA fill:#0a84ff,stroke:#0a84ff,color:#fff,font-weight:bold
    style clients fill:#000,stroke:#3a3a3c,color:#98989d

Why local-first? Your Mac already syncs everything via iCloud. Reading local data means zero auth, zero rate limits, zero Apple-breaking-the-API risk, and full history (Messages back to day one). Remote protocols are the fallback for headless/off-Mac deployments; pyicloud is only used where there is no local equivalent (Find My).

Module layout

strands_icloud/
β”œβ”€β”€ __init__.py      # exports use_apple
β”œβ”€β”€ use_apple.py     # @tool dispatch + all local backends (~850 lines)
β”œβ”€β”€ remote.py        # IMAP / CalDAV / CardDAV clients (stdlib imaplib + caldav + raw DAV)
β”œβ”€β”€ mcp.py           # MCP server entrypoint (strands-icloud-mcp)
└── cli.py           # terminal CLI (strands-icloud / uvx strands-icloud)

Installation

Into devduck (pipx)

pipx inject devduck strands-icloud
# or from a local clone:
pipx inject devduck /path/to/strands-icloud

Register the tool (add to ~/.zshrc):

export DEVDUCK_TOOLS="$DEVDUCK_TOOLS;strands_icloud:use_apple"

Standalone (any Strands agent)

pip install strands-icloud
from strands import Agent
from strands_icloud import use_apple

agent = Agent(tools=[use_apple])
agent("what's on my calendar this week?")

Dependencies

PackageUsed forRequired?
strands-agents@tool decoratoryes
osxphotosPhotos library accessphotos.* only
pyicloudFind Myfindmy.* only
caldavCalDAV calendar clientcaldav.* only
keyringmacOS Keychain credential lookupoptional (falls back to env)
strands-mcp-serverMCP entrypointMCP only

Everything imports lazily β€” a missing optional dep only disables its domain, never the whole tool.


CLI

Every action, straight from your terminal β€” no agent required. Ships as the strands-icloud console script:

uvx strands-icloud reminders.list                 # zero-install, straight from PyPI
strands-icloud messages.search --query flight     # or the installed script

Examples

# Messages
strands-icloud messages.chats -n 10
strands-icloud messages.search -q "baklava"
strands-icloud messages.send --to "+15517274110" --text "hey from the terminal"

# Mail β€” local (Mail.app) or remote (IMAP, faster on big inboxes)
strands-icloud mail.unread -n 10
strands-icloud imap.search -q invoice --folder INBOX -n 5
strands-icloud imap.read -q 4762                  # read by message ID

# Calendar β€” local or CalDAV (syncs to all devices)
strands-icloud calendar.events --days 14
strands-icloud caldav.create --title "Dentist" --start "2026-07-25 14:30" --calendar Home

# Photos β€” save image files locally
strands-icloud photos.search --label dog -n 3 --save ./out
strands-icloud photos.recent -n 5 --no-images     # metadata only

# Everything else
strands-icloud contacts.search -q mom
strands-icloud notes.read -q "meeting"
strands-icloud findmy.locate --device iPhone
strands-icloud actions                            # list every available action
strands-icloud mcp --http --port 8010             # start the MCP server

# Machine-readable output
strands-icloud reminders.list --json | jq '.content[0].text'

CLI flags

FlagMaps toNotes
--query / -qquerysearch text or message/note ID
--limit / -nlimitdefault 20
--chat / --folderchatchat filter (messages) or IMAP folder
--calendarcalendar_name
--save DIRβ€”write photo images to disk
--no-imageswith_images=Falsemetadata only, saves tokens/time
--jsonβ€”structured output (image bytes omitted)
all otherssame-named param--to --text --subject --body --title --start --end --due --days --person --label --device --from-date --to-date

Exit code is 0 on success, 1 on error β€” safe for scripting.


Permissions Setup (macOS)

macOS gates each data source separately. You need:

1. Full Disk Access (required: Messages, speeds up Mail)

System Settings β†’ Privacy & Security β†’ Full Disk Access β†’ add the app that hosts the Python process:

  • Running in Terminal/iTerm/Ghostty β†’ add that terminal app
  • Running as a launchd service β†’ add the Python binary itself
  • Running inside an IDE β†’ add the IDE

Restart the process after granting. Without FDA, messages.* fails with unable to open database file.

2. Automation permissions (per-app, auto-prompted)

First time you call each domain, macOS shows "...wants to control Mail.app" β€” click OK. One prompt per (host app Γ— target app) pair. These land in System Settings β†’ Privacy & Security β†’ Automation.

If you accidentally denied one:

tccutil reset AppleEvents          # resets ALL automation grants

3. Photos library access

osxphotos reads the Photos SQLite directly β€” covered by Full Disk Access. Export of edited/iCloud-offloaded originals may trigger a Photos permission prompt on first use.


Credentials & Keychain

App-specific password (IMAP / CalDAV / CardDAV)

Generate at account.apple.com β†’ Sign-In and Security β†’ App-Specific Passwords. Format: xxxx-xxxx-xxxx-xxxx.

Store it in macOS Keychain (recommended):

security add-generic-password -a "you@icloud.com" -s "strands-icloud" -w "xxxx-xxxx-xxxx-xxxx"

Then only the username goes in your shell config:

# ~/.zshrc
export ICLOUD_USERNAME="you@icloud.com"

Credential resolution order for remote protocols:

  1. ICLOUD_PASSWORD env var (if set β€” e.g. CI, containers)
  2. Keychain service strands-icloud (via keyring)
  3. Keychain service pyicloud (legacy compat)
  4. β†’ error with instructions

Real password (Find My only)

pyicloud drives the iCloud web API, which uses Apple's GSA/SRP handshake β€” app-specific passwords do not work there. If you want Find My:

security add-generic-password -a "you@icloud.com" -s "strands-icloud-real" -w "YOUR_REAL_PASSWORD"

Resolution order: ICLOUD_PASSWORD_REAL env β†’ keychain strands-icloud-real β†’ keychain pyicloud.

First login triggers 2FA once (run any findmy.* action interactively, or use the icloud CLI that ships with pyicloud). The session cookie is cached in ~/.pyicloud and lasts ~weeks.


Action Reference

Every action returns {"status": "success"|"error", "content": [{"text": ...}, ...]}. Photos actions append image blocks after the text block.

πŸ’¬ Messages

Backend: direct SQLite read of ~/Library/Messages/chat.db (opened mode=ro β€” never locks Messages.app), AppleScript for sending. Full history, fast, works even if Messages.app is closed.

ActionParamsDescription
messages.listchat (optional filter: name/number/email substring), limitRecent messages, chronological. chat matches chat_identifier, display_name, or handle
messages.searchquery (required), limitFull-text LIKE search over all message text, all time
messages.chatslimitConversations sorted by most recent activity, with message counts
messages.sendto (phone/email), textSends via Messages.app. Tries iMessage participant first, falls back to generic buddy (SMS relay if configured)
use_apple(action="messages.list", chat="+31638236164", limit=50)
use_apple(action="messages.search", query="baklava")
use_apple(action="messages.send", to="cagataycali@icloud.com", text="hey from my agent")

Timestamps are converted from Apple epoch (nanoseconds since 2001-01-01) automatically.

πŸ“§ Mail (local β€” Mail.app)

Backend: AppleScript. Mail.app must be running (open -gja Mail launches it hidden/background).

ActionParamsDescription
mail.unreadlimitUnread from inbox. Scans newest→oldest, capped at 400 most recent (see Performance)
mail.searchquery (required), limitSubject OR sender contains query. Uses Mail's whose filter β€” slow on huge inboxes
mail.readquery (subject substring)Full body (plain text) of first matching email
mail.sendto, subject, bodyComposes and sends immediately via default account
use_apple(action="mail.unread", limit=10)
use_apple(action="mail.read", query="Run failed: CI")
use_apple(action="mail.send", to="x@y.com", subject="hi", body="sent by agent")

πŸ“… Calendar (local β€” Calendar.app)

Backend: AppleScript across all calendars.

ActionParamsDescription
calendar.eventsdays (default 7), limitUpcoming events across every calendar
calendar.createtitle, start (YYYY-MM-DD HH:MM), end (optional, default +1h), calendar_name (optional, default first writable)Creates event
use_apple(action="calendar.events", days=14)
use_apple(action="calendar.create", title="Dentist", start="2026-07-25 14:30", calendar_name="Home")

πŸ‘€ Contacts (local β€” Contacts.app)

ActionParamsDescription
contacts.searchquery (name substring), limitName, all phones, all emails

πŸ–Ό Photos β€” osxphotos with image blocks

Backend: osxphotos reading the Photos library database directly. The killer feature: results include Bedrock Converse-API image content blocks, so the model literally sees the photos (same mechanism as strands_tools.image_reader).

ActionParamsDescription
photos.searchquery, person, label, from_date/to_date (YYYY-MM-DD), limit, with_imagesAll filters AND-combined. query matches filename/title/description/labels/persons/albums. person = Photos face names. label = Apple's on-device ML labels (e.g. "Baklava", "Document", "Sea")
photos.recentlimit, with_imagesNewest photos
photos.albumsβ€”All album names
use_apple(action="photos.search", person="mom", from_date="2026-01-01")
use_apple(action="photos.search", label="dog", limit=5)
use_apple(action="photos.recent", limit=3, with_images=False)   # metadata only

Image pipeline: export original β†’ if HEIC/oversized, convert via sips to JPEG (quality 80, max edge 1600px) β†’ attach as {"image": {"format": ..., "source": {"bytes": ...}}}. Hard caps: max 5 images per response, 4 MB per image (context window protection). Exports land in /tmp/apple_photos_export/.

πŸ“ Notes β€” Notes.app

ActionParamsDescription
notes.listlimitTitles + modification dates (default account)
notes.readquery (title substring)Full plaintext of first match
notes.createtitle, bodyNew note in default account

β˜‘οΈ Reminders β€” Reminders.app

ActionParamsDescription
reminders.listlimitIncomplete reminders with due dates
reminders.createtitle, due (optional)New reminder. Due date support is limited by AppleScript date quirks β€” title always works

πŸ“± Find My β€” pyicloud

Requires real Apple ID password (see Credentials). Session cached after first 2FA.

ActionParamsDescription
findmy.devicesβ€”All devices: name, model, battery %, coordinates
findmy.locatedevice (name substring)Coordinates + Apple Maps link + accuracy + timestamp
findmy.play_sounddevicePing the device (the classic "find my phone in the couch")
use_apple(action="findmy.locate", device="iPhone")
# πŸ“ Γ‡ağatay's iPhone: (52.37403, 4.88969)
# https://maps.apple.com/?ll=52.37403,4.88969

🌐 IMAP (remote β€” imap.mail.me.com)

Backend: Python stdlib imaplib, SSL port 993. Works from any machine β€” Linux server, container, CI. App-specific password is sufficient.

ActionParamsDescription
imap.foldersβ€”List all mailboxes (INBOX, Archive, Sent Messages, ...)
imap.searchquery, chat (=folder, default INBOX), limitSearches SUBJECT OR FROM OR BODY. Returns message IDs for imap.read
imap.unreadchat (=folder), limitUNSEEN messages
imap.readquery (=message ID from search results), chat (=folder)Full body. Prefers text/plain, falls back to tag-stripped HTML. All fetches use BODY.PEEK β€” never marks messages as read

Note: chat is reused as the folder parameter to keep the tool signature lean.

πŸ“… CalDAV (remote β€” caldav.icloud.com)

ActionParamsDescription
caldav.calendarsβ€”List calendars
caldav.eventsdays, limitUpcoming events across all calendars, expanded recurrences
caldav.createtitle, start, end, calendar_nameCreate event β€” syncs to all your Apple devices in seconds

πŸ‘€ CardDAV (remote β€” contacts.icloud.com)

ActionParamsDescription
carddav.contactsquery (optional name filter), limitFull addressbook via raw DAV: PROPFIND principal discovery β†’ addressbook-home-set β†’ REPORT addressbook-query β†’ vCard parse (FN/TEL/EMAIL)

MCP Server

Everything above, exposed over the Model Context Protocol β€” usable from Claude Code, Claude Desktop, Kiro, Cursor, or any MCP client. Pattern borrowed from strands-cad.

No install needed β€” uvx strands-icloud with no arguments is the MCP stdio server:

uvx strands-icloud                          # stdio MCP server (default when no action given)
uvx strands-icloud mcp --http --port 8010   # streamable HTTP (multi-client)
uvx strands-icloud mcp --http --stateless   # stateless HTTP (horizontally scalable)

Claude Code

claude mcp add apple -- uvx strands-icloud

Claude Desktop

{
  "mcpServers": {
    "apple": {
      "command": "uvx",
      "args": ["strands-icloud"]
    }
  }
}
pip install strands-icloud
strands-icloud-mcp                      # dedicated MCP entrypoint
strands-icloud-mcp --http --port 8010   # HTTP mode
strands-icloud-mcp --agent-invocation   # also expose invoke_agent
strands-icloud-mcp --debug              # verbose logging (stderr)

If installed inside a pipx venv, use the absolute path: ~/.local/pipx/venvs/devduck/bin/strands-icloud-mcp.

Implementation notes (learned the hard way in strands-cad)

  • stdout is sacred in stdio mode β€” it's the protocol channel. All logging goes to stderr.
  • The server calls the raw tool function (mcp_server._tool_func), not agent.tool.mcp_server(...). Going through agent.tool.* marks the agent as mid-invocation; since stdio blocks forever, every nested tool call would then be rejected by the SDK.
  • --agent-invocation is off by default: tools-only surface. Enable it to let MCP clients run full agentic conversations against an embedded Strands agent.

Verified handshake

initialize            β†’ serverInfo: strands-agent (1.27.x)
tools/list            β†’ ["use_apple"]
tools/call use_apple(action="reminders.list")
                      β†’ β˜‘οΈ 3 open reminders: ...

Parameter Reference

Full signature β€” every param is optional except action:

ParamTypeDefaultUsed by
actionstrβ€”everything (required)
querystrNonesearch actions, mail.read, notes.read, imap.read (=msg ID)
tostrNonemessages.send, mail.send
textstrNonemessages.send
subject, bodystrNonemail.send, notes.create
titlestrNonecalendar.create, notes.create, reminders.create, caldav.create
chatstrNonemessages.list (chat filter), imap.* (folder name)
person, labelstrNonephotos.search
devicestrNonefindmy.*
calendar_namestrNonecalendar.create, caldav.create
start, endstrNoneevent creation, YYYY-MM-DD HH:MM
duestrNonereminders.create
from_date, to_datestrNonephotos.search, YYYY-MM-DD
daysint7calendar.events, caldav.events
limitint20all list/search actions
with_imagesboolTruephotos.* β€” set False for metadata-only (saves tokens)

Response Format

# text-only actions
{
    "status": "success",
    "content": [{"text": "πŸ“§ 5 unread:\n- [...] ..."}]
}

# photos with images β€” text block first, then image blocks
{
    "status": "success",
    "content": [
        {"text": "πŸ–Ό 3 photos:\n- IMG_4321.HEIC | 2026-07-21 12:59 | labels: Baklava, ..."},
        {"image": {"format": "jpeg", "source": {"bytes": b"..."}}},
        ...
    ]
}

# errors are never raised β€” always structured
{
    "status": "error",
    "content": [{"text": "chat.db not found at ..."}]
}

Design Decisions

  1. One mega-tool, dot-namespaced actions (use_apple(action="messages.search")) instead of 25 separate tools. Keeps the agent's tool list small, matches the use_aws/use_spotify/adb convention, and lets one docstring teach the model the entire surface.
  2. Local-first, remote-fallback. AppleScript + direct file reads beat any network API for latency, history depth, and reliability. Remote protocols exist for off-Mac deployment of the same tool.
  3. Read-only by default at the storage layer. chat.db opens with ?mode=ro; IMAP fetches use BODY.PEEK. Writes only happen through explicit send/create actions via Apple's own apps/APIs.
  4. Graceful degradation. Optional deps import lazily inside the action branch. No osxphotos? Photos actions error cleanly with install instructions; everything else works.
  5. Structured errors, never exceptions. The agent can read the failure and route around it (e.g., fall back from mail.unread to imap.unread).
  6. Image budget. Max 5 images Γ— ≀4 MB, auto-downscaled to 1600px JPEG q80. A photo dump should never blow up the context window.

Performance Notes

OperationCostNotes
messages.* (read)~10 msDirect SQLite, indexed
mail.unread2–30 sAppleScript iteration, newest-first, capped at 400 recent messages. The naive whose read status is false filter took >120 s on a 16k inbox β€” that's why it scans instead
mail.search10 s–3 minUses Mail's whose filter (server-side in Mail), acceptable but not instant on big inboxes. Prefer imap.search for speed
imap.search1–3 siCloud does the searching
calendar.events5–60 sAppleScript iterates every calendar; slow with many calendars. caldav.events is usually faster
photos.search5–30 s first callosxphotos loads the library DB once per call; big libraries pay upfront. Filtering itself is in-memory and fast
caldav/carddav1–5 sPlain HTTPS

Rules of thumb: Messages β†’ local always. Mail search β†’ IMAP. Calendar list β†’ CalDAV. Photos β†’ local (only option). Sends β†’ local (Apple handles delivery).


Troubleshooting

SymptomCauseFix
unable to open database file on messages.*No Full Disk AccessGrant FDA to host app, restart the process
Timeout on mail.*Mail.app not running or gigantic inboxopen -gja Mail, wait for index; use imap.* instead
Not authorized to send Apple eventsAutomation permission deniedSystem Settings β†’ Automation, or tccutil reset AppleEvents
PyiCloudFailedLoginException on findmy.*Using app-specific passwordpyicloud needs the real password (keychain strands-icloud-real)
2FA loop on findmyExpired sessionRe-auth interactively once; check ~/.pyicloud is writable
imap.* AUTHENTICATIONFAILEDBad/revoked app-specific passwordRegenerate at account.apple.com, update keychain entry
Photos export returns nothingOriginal offloaded to iCloud ("Optimize Mac Storage")Tool retries with use_photos_export=True (slower, downloads); ensure disk space
Keychain prompt blocks headless runkeyring needs UI unlocksecurity unlock-keychain in session, or use env vars for that host
MCP client sees no toolsstdout polluted by a printRun with --debug, check nothing but JSON-RPC goes to stdout

Nuclear option for AppleScript weirdness: quit and relaunch the target app.


Security Model

  • App-specific password (keychain strands-icloud): grants mail/calendar/contacts via legacy protocols. Revocable at account.apple.com anytime without touching your real password. Cannot be used to log into iCloud web, change account settings, or access Find My.
  • Real password (keychain strands-icloud-real, optional): only needed for Find My. Never written to disk by this tool; keychain access requires your login session.
  • No plaintext secrets in dotfiles: only ICLOUD_USERNAME lives in ~/.zshrc.
  • Local data never leaves the machine except as tool results into your model provider (mind what you query if using a cloud LLM β€” that's the actual data boundary here).
  • chat.db is opened read-only; the tool physically cannot modify or delete message history.

Known Limitations

  • macOS only for local actions (AppleScript, chat.db, osxphotos). Remote actions (imap.*, caldav.*, carddav.*) run anywhere Python does.
  • reminders.create due dates: AppleScript date construction is unreliable across locales; reminders are created without due dates for now.
  • messages.send requires Messages.app signed in; SMS relay needs an iPhone with Text Message Forwarding enabled.
  • mail.unread scans the 400 most recent inbox messages β€” unread mail older than that won't appear (use imap.unread for exhaustive).
  • Group chat display names in Messages may be empty (falls back to chat identifier).
  • pyicloud is an unofficial client of a private API β€” Apple can (and occasionally does) break it. Local + DAV paths are unaffected.
  • HEIC β†’ JPEG conversion via sips loses HDR/depth metadata (irrelevant for model vision).

Development

git clone git@github.com:cagataycali/strands-icloud.git
cd strands-icloud
uv venv && source .venv/bin/activate
uv pip install -e .

# smoke test without an agent
python -c "
from strands_icloud.use_apple import use_apple
fn = getattr(use_apple, '_tool_func', use_apple)
print(fn(action='reminders.list', limit=3))
"

# reinject into devduck after changes
pipx inject devduck . --force

License

MIT Β© Γ‡ağatay Γ‡alΔ±

↑↓ navigate openesc close