mcp server
strands-icloud
π Strands tool for the Apple ecosystem β mail, calendar, messages, contacts, photos, notes, reminders, Find My
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`)
{
"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
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
- Installation
- CLI
- Permissions Setup (macOS)
- Credentials & Keychain
- Action Reference
- MCP Server
- Parameter Reference
- Response Format
- Design Decisions
- Performance Notes
- Troubleshooting
- Security Model
- Known Limitations
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
| Package | Used for | Required? |
|---|---|---|
strands-agents | @tool decorator | yes |
osxphotos | Photos library access | photos.* only |
pyicloud | Find My | findmy.* only |
caldav | CalDAV calendar client | caldav.* only |
keyring | macOS Keychain credential lookup | optional (falls back to env) |
strands-mcp-server | MCP entrypoint | MCP 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
| Flag | Maps to | Notes |
|---|---|---|
--query / -q | query | search text or message/note ID |
--limit / -n | limit | default 20 |
--chat / --folder | chat | chat filter (messages) or IMAP folder |
--calendar | calendar_name | |
--save DIR | β | write photo images to disk |
--no-images | with_images=False | metadata only, saves tokens/time |
--json | β | structured output (image bytes omitted) |
| all others | same-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:
ICLOUD_PASSWORDenv var (if set β e.g. CI, containers)- Keychain service
strands-icloud(viakeyring) - Keychain service
pyicloud(legacy compat) - β 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.
| Action | Params | Description |
|---|---|---|
messages.list | chat (optional filter: name/number/email substring), limit | Recent messages, chronological. chat matches chat_identifier, display_name, or handle |
messages.search | query (required), limit | Full-text LIKE search over all message text, all time |
messages.chats | limit | Conversations sorted by most recent activity, with message counts |
messages.send | to (phone/email), text | Sends 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).
| Action | Params | Description |
|---|---|---|
mail.unread | limit | Unread from inbox. Scans newestβoldest, capped at 400 most recent (see Performance) |
mail.search | query (required), limit | Subject OR sender contains query. Uses Mail's whose filter β slow on huge inboxes |
mail.read | query (subject substring) | Full body (plain text) of first matching email |
mail.send | to, subject, body | Composes 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.
| Action | Params | Description |
|---|---|---|
calendar.events | days (default 7), limit | Upcoming events across every calendar |
calendar.create | title, 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)
| Action | Params | Description |
|---|---|---|
contacts.search | query (name substring), limit | Name, 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).
| Action | Params | Description |
|---|---|---|
photos.search | query, person, label, from_date/to_date (YYYY-MM-DD), limit, with_images | All 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.recent | limit, with_images | Newest 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
| Action | Params | Description |
|---|---|---|
notes.list | limit | Titles + modification dates (default account) |
notes.read | query (title substring) | Full plaintext of first match |
notes.create | title, body | New note in default account |
βοΈ Reminders β Reminders.app
| Action | Params | Description |
|---|---|---|
reminders.list | limit | Incomplete reminders with due dates |
reminders.create | title, 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.
| Action | Params | Description |
|---|---|---|
findmy.devices | β | All devices: name, model, battery %, coordinates |
findmy.locate | device (name substring) | Coordinates + Apple Maps link + accuracy + timestamp |
findmy.play_sound | device | Ping 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.
| Action | Params | Description |
|---|---|---|
imap.folders | β | List all mailboxes (INBOX, Archive, Sent Messages, ...) |
imap.search | query, chat (=folder, default INBOX), limit | Searches SUBJECT OR FROM OR BODY. Returns message IDs for imap.read |
imap.unread | chat (=folder), limit | UNSEEN messages |
imap.read | query (=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:
chatis reused as the folder parameter to keep the tool signature lean.
π CalDAV (remote β caldav.icloud.com)
| Action | Params | Description |
|---|---|---|
caldav.calendars | β | List calendars |
caldav.events | days, limit | Upcoming events across all calendars, expanded recurrences |
caldav.create | title, start, end, calendar_name | Create event β syncs to all your Apple devices in seconds |
π€ CardDAV (remote β contacts.icloud.com)
| Action | Params | Description |
|---|---|---|
carddav.contacts | query (optional name filter), limit | Full 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), notagent.tool.mcp_server(...). Going throughagent.tool.*marks the agent as mid-invocation; since stdio blocks forever, every nested tool call would then be rejected by the SDK. --agent-invocationis 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:
| Param | Type | Default | Used by |
|---|---|---|---|
action | str | β | everything (required) |
query | str | None | search actions, mail.read, notes.read, imap.read (=msg ID) |
to | str | None | messages.send, mail.send |
text | str | None | messages.send |
subject, body | str | None | mail.send, notes.create |
title | str | None | calendar.create, notes.create, reminders.create, caldav.create |
chat | str | None | messages.list (chat filter), imap.* (folder name) |
person, label | str | None | photos.search |
device | str | None | findmy.* |
calendar_name | str | None | calendar.create, caldav.create |
start, end | str | None | event creation, YYYY-MM-DD HH:MM |
due | str | None | reminders.create |
from_date, to_date | str | None | photos.search, YYYY-MM-DD |
days | int | 7 | calendar.events, caldav.events |
limit | int | 20 | all list/search actions |
with_images | bool | True | photos.* β 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
- One mega-tool, dot-namespaced actions (
use_apple(action="messages.search")) instead of 25 separate tools. Keeps the agent's tool list small, matches theuse_aws/use_spotify/adbconvention, and lets one docstring teach the model the entire surface. - 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.
- Read-only by default at the storage layer. chat.db opens with
?mode=ro; IMAP fetches useBODY.PEEK. Writes only happen through explicit send/create actions via Apple's own apps/APIs. - Graceful degradation. Optional deps import lazily inside the action branch. No osxphotos? Photos actions error cleanly with install instructions; everything else works.
- Structured errors, never exceptions. The agent can read the failure and route around it (e.g., fall back from
mail.unreadtoimap.unread). - 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
| Operation | Cost | Notes |
|---|---|---|
messages.* (read) | ~10 ms | Direct SQLite, indexed |
mail.unread | 2β30 s | AppleScript 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.search | 10 sβ3 min | Uses Mail's whose filter (server-side in Mail), acceptable but not instant on big inboxes. Prefer imap.search for speed |
imap.search | 1β3 s | iCloud does the searching |
calendar.events | 5β60 s | AppleScript iterates every calendar; slow with many calendars. caldav.events is usually faster |
photos.search | 5β30 s first call | osxphotos loads the library DB once per call; big libraries pay upfront. Filtering itself is in-memory and fast |
caldav/carddav | 1β5 s | Plain HTTPS |
Rules of thumb: Messages β local always. Mail search β IMAP. Calendar list β CalDAV. Photos β local (only option). Sends β local (Apple handles delivery).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
unable to open database file on messages.* | No Full Disk Access | Grant FDA to host app, restart the process |
Timeout on mail.* | Mail.app not running or gigantic inbox | open -gja Mail, wait for index; use imap.* instead |
Not authorized to send Apple events | Automation permission denied | System Settings β Automation, or tccutil reset AppleEvents |
PyiCloudFailedLoginException on findmy.* | Using app-specific password | pyicloud needs the real password (keychain strands-icloud-real) |
| 2FA loop on findmy | Expired session | Re-auth interactively once; check ~/.pyicloud is writable |
imap.* AUTHENTICATIONFAILED | Bad/revoked app-specific password | Regenerate at account.apple.com, update keychain entry |
| Photos export returns nothing | Original offloaded to iCloud ("Optimize Mac Storage") | Tool retries with use_photos_export=True (slower, downloads); ensure disk space |
| Keychain prompt blocks headless run | keyring needs UI unlock | security unlock-keychain in session, or use env vars for that host |
| MCP client sees no tools | stdout polluted by a print | Run 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_USERNAMElives 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.createdue dates: AppleScript date construction is unreliable across locales; reminders are created without due dates for now.messages.sendrequires Messages.app signed in; SMS relay needs an iPhone with Text Message Forwarding enabled.mail.unreadscans the 400 most recent inbox messages β unread mail older than that won't appear (useimap.unreadfor 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
sipsloses 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Δ±