things-mcp 0.2.3

Local-first MCP server bridging Claude to Things 3 on macOS — 29 tools for read, search, write, and tag CRUD.
Documentation

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

cargo install things-mcp                            # from crates.io
claude mcp add things-mcp $(which things-mcp)       # register with Claude Code (stdio)

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
claude mcp add things-mcp $(which things-mcp) --env THINGS_AUTH_TOKEN=<token>

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:

cargo install --path crates/things-mcp --force
# 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_todo per line, or things_bulk_json for speed.
  • Project kickoff. "Create project 'X' under area Work, then add these 6 to-dos under it."things_add_project then things_add_todo × 6 (with list_id set to the new project's id).
  • Tag tree spring-clean. "Show me my tag tree. Where do you see duplicates or orphans?"things_list_tags returns the tree; Claude proposes merges; you approve each. Then things_merge_tags per 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_tag then per-item context. Optionally things_unassign_tag to clean up, or things_delete_tag once 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:

things-mcp 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:

things-mcp status              # launchd + HTTP listener + Funnel + Things 3 + oauth.toml
things-mcp show-credentials    # reprint the connector values

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
[things]
auth_token = "<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::StreamableHttpService bound to 127.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 "/":
tailscale serve --bg --https=443 --set-path=/things-mcp http://localhost:7892
tailscale funnel --bg --https=443 <port-of-the-server-at-root>   # re-enable Funnel

Then edit the issuer in ~/Library/Application Support/dev.things-mcp.things-mcp/oauth.toml to include the path:

issuer = "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:

[things]
db_path = "/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)

[backup]
retain = 10
# directory = "..."  # optional; defaults to <config_dir>/backups

[writer]
poll_timeout_ms = 3000   # max time to confirm a write landed
poll_interval_ms = 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

cargo test                    # full suite (≈204 tests)
cargo test -- --ignored       # opt-in smoke tests that touch /usr/bin/osascript
cargo build --release         # optimized binary at target/release/things-mcp

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.