things-mcp 0.2.2

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

```bash
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:

```bash
# 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:

```bash
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:

```bash
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:

```text
Server URL                  https://<your-machine>.<tailnet>.ts.net/mcp
Advanced ▸ Client ID        things-mcp-<random>
Advanced ▸ Client Secret    <generated>
```

Health-check after install:

```bash
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:

```toml
# ~/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`:

```bash
# 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:

```toml
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:

```toml
[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:

```text
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

```bash
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.