things-mcp-server
A local-first MCP server, written in Rust, that bridges Claude to a live Things 3 instance on macOS. Reads run against a read-only SQLite copy; writes go through Things' own things:///json URL scheme; tag-admin ops drive Things via AppleScript. No data leaves the machine.
Status: Plan 8 shipping (v0.2.2) — 29 MCP tools over stdio (Claude Code) and HTTP (Claude.ai Cowork via OAuth 2.1 + Tailscale Funnel + launchd).
Quick start
Hacking on the source instead? Use cargo install --path crates/things-mcp from the repo root.
For tools that mutate to-dos through the JSON URL scheme (things_add_todo, things_update_todo, the assign/unassign pair, etc.), pass the auth token at registration time:
# Get the token from Things 3 → Settings → General → "Enable Things URLs" → Manage
Tag-admin tools (things_create_tag, rename, merge, delete, move) go via /usr/bin/osascript and don't use the auth token. The first call will prompt for macOS Automation permission for things-mcp → Things 3.
To upgrade after pulling new commits:
# then /mcp → reconnect things-mcp inside Claude Code
Tool reference
Every tool returns structured JSON (an object — never a bare array). Field shapes are stable across versions; see crates/things-mcp/src/core/types.rs for the exact derives. Tool annotations (read_only_hint, destructive_hint, idempotent_hint, open_world_hint) are set per the MCP spec so the client can show appropriate UI affordances.
Reading lists
All list tools return { items: [...] } and accept an optional limit. Examples are written as the prompt you'd send to Claude — Claude picks the matching tool.
| Tool | When to reach for it | Example prompt |
|---|---|---|
things_list_inbox |
First-look triage; capture review. | "What's in my Things inbox?" — or "Show me everything in inbox including ones I've already checked off this week." (with include_completed: true) |
things_list_today |
Daily standup; ad-hoc "what am I doing now?" | "What's on Today?" — or "Read me my Today list and group by project." |
things_list_upcoming |
Weekly review; spotting deadline pile-ups. | "What's scheduled for the next two weeks?" — or "Anything upcoming between 2026-06-01 and 2026-06-15?" |
things_list_anytime |
Backlog scan; pulling work into Today. | "Show me everything in Anytime under area Personal." |
things_list_someday |
Quarterly review; spotting stale ideas. | "What's been parked in Someday for a while?" |
things_list_logbook |
Retrospectives; "what did I get done?" | "What did I complete in the last 7 days?" — or "Show me everything I finished between 2026-04-01 and 2026-04-30." |
things_list_trash |
Recovery; "did I delete something useful?" | "What's in my trash?" |
things_list_areas |
Discover the area set when writing area-scoped queries. | "List my Things areas." |
things_list_projects |
Find a project ID for follow-up writes; spot stale projects. | "List all my projects." — or "Show me my open projects in the Work area." |
things_list_by_tag |
Cross-cut a view across areas/projects via tag. | "Show me everything tagged 'Errand' or its children." — or "List items tagged 'Phone-call' without recursing into child tags." (recurse: false) |
Reading tags
| Tool | When to reach for it | Example prompt |
|---|---|---|
things_list_tags |
Explore the tag hierarchy; find candidates for merge/cleanup; pick a tag for a write. | "Show me my full tag tree." Returns both a flat list (every tag with its parent_id) and a roots tree (each root with nested children recursively). |
Searching
| Tool | When to reach for it | Example prompt |
|---|---|---|
things_search |
When list-by-X isn't enough — full-text + multi-filter. Combine free text with structural filters (tags, area, project, status, deadline range, scheduled range). | "Search my open to-dos for the word 'invoice' in the Finance area." — or "Find anything tagged 'Call' due before next Friday." — or "Search across open and completed to-dos for 'tax bill' in project Q2-Tax." |
Reading single items
| Tool | When to reach for it | Example prompt |
|---|---|---|
things_get_todo |
Pull full detail (notes, checklist, dates) for a known UUID. Returns { todo: null } when not found. |
"Open the to-do <uuid> and read me its notes and checklist." |
things_get_project |
Pull a project with all its items, headings, and per-heading to-dos. Returns { project: null } when not found. |
"Show me everything inside project <uuid>." |
Writing to-dos and projects
Every write returns a WriteOutcome { id?, action, verified, dry_run, latency_ms }. verified: true means the writer polled SQLite and confirmed the write landed. dry_run: true means safety mode short-circuited the write (test-DB mode).
| Tool | When to reach for it | Example prompt |
|---|---|---|
things_add_todo |
Quick capture; converting a chat into a to-do. Accepts title + optional notes/tags/area/project/deadline/scheduled/list (inbox/anytime/etc). | "Add a to-do 'Follow up with Sam about Q3 budget' to my Work area, tag it 'Email', and schedule it for tomorrow." |
things_add_project |
Spinning up a multi-step initiative. | "Create a new project called 'Q3 OKRs Planning' in my Work area, with notes 'Kickoff agenda in shared drive'." |
things_update_todo |
Editing any attribute — title, notes, dates, tags. (Tags replace wholesale; prefer assign/unassign for tag deltas.) |
"On to-do <uuid>, change the title to 'Draft Q3 budget memo' and move the deadline to 2026-06-20." |
things_update_project |
Editing project attributes. | "Update project <uuid>: set notes to 'Owner: AT; deadline 2026-06-30; deck in /Docs/Q3.'" |
things_complete_todo |
Marking work done. | "Mark to-do <uuid> as completed." |
things_cancel_todo |
Closing out without claiming completion (e.g. obsolete, duplicate). | "Cancel to-do <uuid> — it's a duplicate of <other-uuid>." |
things_move_todo |
Reassigning to a different project or area. Pass the destination's UUID as list_id, or omit to move to inbox. |
"Move to-do <uuid> into project <project-uuid>." |
things_bulk_json |
Power tool for batching. Send a raw array of Things JSON URL scheme operation objects (max 250). Skips per-element verify — verified is always false. |
"Send this batch: add three to-dos to my Inbox titled 'Pay rent', 'Renew domain', 'Email landlord'." (Claude assembles the array; this is mostly useful for scripts and migrations, not chat.) |
Tag write — attach/detach on a to-do
These go through the JSON URL chassis (read-modify-write through update.tags).
| Tool | When to reach for it | Example prompt |
|---|---|---|
things_assign_tag |
Add one or more tags to a single to-do. Idempotent — re-assigning an already-attached tag is a no-op. | "Tag to-do <uuid> with 'Errand' and 'Phone-call'." |
things_unassign_tag |
Remove one or more tags from a single to-do. Idempotent. | "Remove the 'Waiting' tag from to-do <uuid>." |
Tag admin — global tag tree changes
These go through AppleScript (osascript). Things 3 must be running; first call triggers macOS Automation permission for things-mcp → Things 3.
| Tool | When to reach for it | Example prompt |
|---|---|---|
things_create_tag |
New tag, optionally nested under an existing parent. | "Create a tag 'Deep-work' under 'Focus'." — or "Create a root-level tag 'Q3-launch'." |
things_rename_tag |
Cleanup; renaming globally propagates to every to-do that carries the old name. | "Rename 'Errand' to 'Errands'." |
things_merge_tags |
Tag-tree cleanup: reassign every to-do tagged source to also carry target, then delete source. Source ≠ target. |
"Merge tag 'Errands' into 'Errand' — I have a duplicate." |
things_delete_tag |
Remove a tag globally. The to-dos that carried it stay; only the tag is removed. | "Delete the tag 'Q3-launch' — that project is over." |
things_move_tag |
Re-nest a tag in the tree. Omit new_parent to promote to root. |
"Move 'Deep-work' under 'Focus'." — or "Move 'Phone-call' to the root of the tag tree." |
Effective patterns
A few high-leverage prompts the tools combine well on:
- Daily standup. "Read me Today and any overdue scheduled items, group by project, and note any with tag 'Urgent'." —
things_list_today+things_search(scheduled_before today) + post-filter. - Weekly review. "Walk me through the Logbook for the last 7 days, then Someday items I haven't touched in 90 days, then Upcoming for next 14 days." —
things_list_logbook+things_list_someday+things_list_upcoming. - Capture batch from chat. Paste a list of items into chat; "Add each of these to my Inbox." — single
things_add_todoper line, orthings_bulk_jsonfor speed. - Project kickoff. "Create project 'X' under area Work, then add these 6 to-dos under it." —
things_add_projectthenthings_add_todo× 6 (withlist_idset to the new project's id). - Tag tree spring-clean. "Show me my tag tree. Where do you see duplicates or orphans?" —
things_list_tagsreturns the tree; Claude proposes merges; you approve each. Thenthings_merge_tagsper pair. - Triage at the end of a project. "Find every open to-do tagged 'Q3-launch'. For each, tell me the project and any deadline." —
things_list_by_tagthen per-item context. Optionallythings_unassign_tagto clean up, orthings_delete_tagonce empty.
HTTP / Tailscale-Funnel transport (Claude.ai Cowork)
The binary defaults to stdio MCP for Claude Code on the same Mac. To reach Claude.ai Cowork — Anthropic's web sandbox, which sits in a VM that can't open stdio to your laptop — run the one-shot setup:
That walks through Tailscale Funnel detection, writes the launchd plist, enables Funnel on port 7892, bootstraps OAuth 2.1 + PKCE credentials, and prints the values to paste into Claude.ai → Settings → Connectors → Add custom:
Server URL https://<your-machine>.<tailnet>.ts.net/mcp
Advanced ▸ Client ID things-mcp-<random>
Advanced ▸ Client Secret <generated>
Health-check after install:
Enable writes from Cowork. The launchd-managed binary doesn't see your shell's environment, so passing THINGS_AUTH_TOKEN via --env (the stdio recipe) won't work. Put the token in config.toml under [things] instead:
# ~/Library/Application Support/dev.things-mcp.things-mcp/config.toml
[]
= "<token from Things 3 → Settings → General → 'Enable Things URLs' → Manage>"
Then launchctl kickstart -k gui/$(id -u)/com.things-mcp.http to reload. Without this, read-only tools work fine from Cowork but things_add_todo / things_update_todo / things_assign_tag etc. will respond with missing Things auth-token.
Under the hood:
- Streamable-HTTP MCP via
rmcp::StreamableHttpServicebound to127.0.0.1:7892. - Tailscale Funnel publishes that loopback service at an HTTPS URL. Tailscale terminates TLS; Funnel limits inbound to your tailnet plus Claude.ai's known egress.
- OAuth 2.1 + PKCE in front of
/mcp. Discovery is served at both/.well-known/oauth-authorization-server(RFC 8414) and/.well-known/openid-configuration(OIDC alias — required by Claude.ai's connector when the issuer URL has a path component). Tokens are SHA-256 hashed at rest under~/Library/Application Support/dev.things-mcp.things-mcp/(mode 0600). Default TTLs: 7-day access, 90-day refresh. - launchd plist (
com.things-mcp.http) keeps the HTTP server alive across reboots; logs to~/Library/Logs/things-mcp/.
The same 29 tools — same handlers, same safety gates, same DryRun mode — are served over both transports.
Running alongside another MCP server on the same Mac
Tailscale Funnel gives you one public hostname per machine on port 443, so two MCP servers on the same machine collide if both want https://<host>/mcp. things-mcp setup assumes single-tenant and will overwrite a sibling's Funnel mapping when invoked.
The current workaround is path-based routing on port 443, configured by hand after things-mcp setup:
# Mount things-mcp under /things-mcp on the shared :443 Funnel, alongside
# whatever already occupies "/":
Then edit the issuer in ~/Library/Application Support/dev.things-mcp.things-mcp/oauth.toml to include the path:
= "https://<machine>.<tailnet>.ts.net/things-mcp"
…and launchctl kickstart -k gui/$(id -u)/com.things-mcp.http. The connector URL in Claude.ai then becomes https://<machine>.<tailnet>.ts.net/things-mcp/mcp. A --path-prefix flag on things-mcp setup is queued for a follow-up plan so this becomes first-class.
Configuration
~/Library/Application Support/dev.things-mcp.things-mcp/config.toml is created on first run:
[]
= "/Users/<you>/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/Things Database.thingsdatabase/main.sqlite"
# auth_token = "..." # required for writes; stdio path can also pass THINGS_AUTH_TOKEN via env (HTTP/launchd path cannot)
[]
= 10
# directory = "..." # optional; defaults to <config_dir>/backups
[]
= 3000 # max time to confirm a write landed
= 100 # poll spacing
Environment overrides (useful for development against a fixture DB):
THINGS_DB_PATH=/path/to/test.sqlite— point at a non-live DB. Activates test-DB mode: writes are blocked unless you also set--allow-writes-on-test-db(in which case they're dry-run).THINGS_AUTH_TOKEN=...— JSON URL auth token.THINGS_MCP_ALLOW_WRITES_ON_TEST_DB=1— same as--allow-writes-on-test-db.RUST_LOG=things_mcp=debug— verbose tracing.
CLI flags:
things-mcp --db-path /path/to/test.sqlite --allow-writes-on-test-db
Safety
- Startup backup. Every launch snapshots your live Things SQLite to
<config_dir>/backups/things-<timestamp>.sqlite(retains the last 10 by default). The snapshot uses SQLite's online backup API, not a raw file copy — safe to run while Things is open. - Read-only pool. The reader opens the DB read-only + immutable. Writes never touch SQL — they flow through Things' own JSON URL scheme (the same API the iOS share extension uses).
- Three safety modes.
Live(production),DryRun(test-DB +--allow-writes-on-test-db— renders the URL/script but doesn't fire),Forbidden(test-DB only). Both the JSON URL executor and the AppleScript driver respect the mode. - Auth-token redaction. The token is wrapped in a
secrecy::Secret<String>so it can't accidentally land in logs or panic messages.
Development
Project layout follows the conventions in CLAUDE.md. Non-trivial changes start with a dated spec in docs/superpowers/specs/ followed by a plan in docs/superpowers/plans/. Each plan task lands as an atomic commit.
Roadmap
- Plan 7 — recurrence definition via AppleScript wrapper (the JSON URL scheme can't set recurrence rules).
- Plan 9 — stdio path consolidation and feature parity sweep.
See docs/superpowers/plans/ for the active plan and follow-ons.