Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 49 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,26 +414,50 @@ embedded in the binary — so a plain `things` upgrade refreshes it; re-run
## MCP server

`things mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io)
server over stdio, exposing the **read-only** side of the CLI as typed tools.
It's aimed at MCP hosts that can't shell out — chiefly **Claude Desktop** — but
hosts that can (Cursor, Claude Code) get a typed alternative to driving the CLI
via Bash. The agent skill above and the MCP server are independent; use whichever
your host supports.
server over stdio, exposing the CLI as typed tools. It's aimed at MCP hosts that
can't shell out — chiefly **Claude Desktop** — but hosts that can (Cursor, Claude
Code) get a typed alternative to driving the CLI via Bash. The agent skill above
and the MCP server are independent; use whichever your host supports.

Tools (results are the same JSON the CLI emits with `--json`):
The server is **read-only by default** and **fails fast** at startup with a clear
error if the Things3 database can't be found or opened. It requires Things3 on
macOS, like the rest of the CLI.

| Tool | Mirrors | Arguments |
| --- | --- | --- |
| `things_list` | `things <view>` | `view`, `project`, `area`, `tag`, `on`, `from`, `to` (all optional) |
| `things_show` | `things show` | `task` (UUID or title) |
| `things_search` | `things search` | `query` |
| `things_projects` | `things projects` | `area`, `completed` (optional) |
| `things_areas` | `things areas` | — |
| `things_tags` | `things tags` | — |
### Toolsets

Tools are grouped into **toolsets** you mount with `--toolsets` (comma-separated;
`all` is shorthand; defaults to all). Mount only what you use to keep the tool
list short. The read tools of a mounted toolset are always available; its write
tools appear only when you pass `--read-only=false` (see [Writes](#writes)).

The server is **read-only** (no add/complete/cancel/edit) and **fails fast** at
startup with a clear error if the Things3 database can't be found or opened. It
requires Things3 on macOS, like the rest of the CLI.
| Toolset | Read tools | Write tools (need `--read-only=false`) |
| --- | --- | --- |
| `tasks` | `things_list`, `things_show`, `things_search` | `things_add`, `things_edit`, `things_complete`, `things_cancel` |
| `projects` | `things_projects` | `things_add_project`, `things_edit_project` |
| `areas` | `things_areas` | — |
| `tags` | `things_tags` | — |
| `bulk` | — | `things_log`, `things_import` |

Each tool mirrors the CLI command of the same name (run `things <cmd> --help` for
its arguments); read results are the same JSON the CLI emits with `--json`.
`--toolsets` also reads from the `THINGS_TOOLSETS` environment variable. For
example, `--toolsets=tasks` mounts just `things_list`/`things_show`/`things_search`.

### Writes

Write tools are **off by default**; enable them by adding `--read-only=false`
(or `--no-read-only`) to the server's `args`. A few things to know:

- **`things_add` / `things_edit` (and the project and import variants) are
fire-and-forget**: they hand the change to the Things URL scheme and return
*before* Things has applied it, so the result reports *submitted*, not
*confirmed*, and a bad payload surfaces only as an in-app Things notification.
`things_complete` / `things_cancel` go through AppleScript and are synchronous.
- **`things_edit`, `things_edit_project`, and `things_import` need the Things URL
auth token.** Enable it once in **Things → Settings → General → Enable Things
URLs**; the server reads it from your database automatically.
- Most MCP hosts ask you to approve each tool call, so writes stay under your
control.

### Add it to Claude Desktop

Expand Down Expand Up @@ -462,6 +486,10 @@ in-app config editor keeps setup to a copy-paste:
6. Click the **+ / tools (🔨)** icon in the chat box; **things** should be
listed. Try *"what's on my list today?"*

To let Claude **create and change** to-dos, add `--read-only=false` to `args`
(`"args": ["mcp", "--read-only=false"]`) and read [Writes](#writes) first — it
enables the add/edit/complete/cancel tools.

**Not working?** ① the `command` must be the full absolute path from `which
things`; ② a JSON typo makes Claude ignore the whole file silently — recheck the
braces and commas; ③ errors are logged to
Expand All @@ -477,8 +505,10 @@ like this.
- **Generic** — `command: things` (use the absolute path if the host doesn't
inherit your `PATH`), `args: ["mcp"]`.

To pin a specific database, add `"--db", "/path/to/main.sqlite"` before `"mcp"`
in `args`; otherwise it auto-discovers your Things3 database.
All of these accept the same flags in `args`: append `"--read-only=false"` to
enable writes, `"--toolsets=tasks,projects"` to mount a subset, or
`"--db", "/path/to/main.sqlite"` (before `"mcp"`) to pin a database — otherwise
it auto-discovers your Things3 database.

## How it works

Expand Down
22 changes: 16 additions & 6 deletions cmd/things/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ type CLI struct {
Open OpenCmd `cmd:"" help:"Reveal a task, project, area, tag, or built-in list in Things3."`
Import ImportCmd `cmd:"" help:"Batch create/update via the Things JSON URL scheme. Reads JSON from stdin or --file."`
Skill SkillCmd `cmd:"" help:"Manage the bundled agent skill (Claude Code, etc.)."`
MCP MCPCmd `cmd:"" name:"mcp" help:"Run a read-only MCP server over stdio (for Claude Desktop, Cursor, etc.)."`
MCP MCPCmd `cmd:"" name:"mcp" help:"Run an MCP server over stdio (read-only by default; --read-only=false enables write tools). For Claude Desktop, Cursor, etc."`
Ver VersionCmd `cmd:"" name:"version" help:"Print version and exit."`

Completions CompletionsCmd `cmd:"" help:"Print a shell completion script (bash|zsh|fish)."`
Expand Down Expand Up @@ -499,12 +499,20 @@ func (c *LogCmd) Run(_ *Deps) error {
return things.LogCompleted()
}

// MCPCmd runs a read-only Model Context Protocol server over stdio, exposing
// the CLI's read commands as MCP tools (see internal/mcpserver). Transport is
// stdio only; the global --db flag selects the database.
type MCPCmd struct{}
// MCPCmd runs a Model Context Protocol server over stdio, exposing the CLI's
// commands as MCP tools (see internal/mcpserver). Tools are grouped into
// toolsets the operator mounts à la carte; the server is read-only unless
// --read-only=false. Transport is stdio only; the global --db flag selects the
// database.
type MCPCmd struct {
Toolsets []string `help:"Toolsets to expose (comma-separated): tasks, projects, areas, tags, bulk, or all. Defaults to all." env:"THINGS_TOOLSETS"`
ReadOnly bool `help:"Expose only read tools. Pass --read-only=false (or --no-read-only) to enable write tools." default:"true" negatable:""`
}

func (c *MCPCmd) Run(d *Deps) error {
if err := mcpserver.ValidateToolsets(c.Toolsets); err != nil {
return err
}
path := d.DBPath
if path == "" {
p, err := db.FindDBPath()
Expand All @@ -526,7 +534,9 @@ func (c *MCPCmd) Run(d *Deps) error {
defer stop()

return mcpserver.Serve(ctx, mcpserver.Config{
Version: version,
Version: version,
Toolsets: c.Toolsets,
EnableWrites: !c.ReadOnly,
Open: func() (mcpserver.Backend, error) {
return db.Open(path)
},
Expand Down
132 changes: 91 additions & 41 deletions cmd/things/mcp_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import (
"github.com/ryanlewis/things-cli/internal/model"
)

// TestMCPStdioRoundTrip builds the binary, runs `things --db <seeded> mcp`, and
// drives a full MCP session over its stdio (spawn → initialize → tools/list →
// tools/call). It is hermetic: no real Things3 database is required. Gated
// behind the `integration` build tag (run via `make test-integration`).
// TestMCPStdioRoundTrip builds the binary and drives full MCP sessions over its
// stdio (spawn → initialize → tools/list → tools/call) under several flag
// combinations. It is hermetic: a seeded temp DB stands in for real Things3, and
// write tools are only listed, never called (calling them would shell out to
// open/osascript). Gated behind the `integration` build tag (run via
// `make test-integration`).
func TestMCPStdioRoundTrip(t *testing.T) {
dbPath, sqlDB := dbtest.NewFileSQL(t)
if _, err := sqlDB.Exec(
Expand All @@ -34,53 +36,101 @@ func TestMCPStdioRoundTrip(t *testing.T) {
t.Fatalf("build binary: %v\n%s", err, out)
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

transport := &mcp.CommandTransport{Command: exec.Command(bin, "--db", dbPath, "mcp")}
t.Run("default is read-only with six tools and serves a read", func(t *testing.T) {
cs := connectSpawned(t, ctx, bin, dbPath)
if names := listToolNames(t, ctx, cs); len(names) != 6 {
t.Fatalf("default tools/list = %d tools %v, want 6", len(names), names)
}

res, err := cs.CallTool(ctx, &mcp.CallToolParams{
Name: "things_search",
Arguments: map[string]any{"query": "milk"},
})
if err != nil {
t.Fatalf("tools/call: %v", err)
}
if res.IsError {
t.Fatalf("things_search reported a tool error: %+v", res.Content)
}
var text string
for _, c := range res.Content {
if tc, ok := c.(*mcp.TextContent); ok {
text += tc.Text
}
}
var tasks []model.Task
if err := json.Unmarshal([]byte(text), &tasks); err != nil {
t.Fatalf("result is not the CLI's JSON: %v\n%s", err, text)
}
found := false
for _, task := range tasks {
if task.Title == "Buy milk" {
found = true
}
}
if !found {
t.Errorf("round-trip did not return the seeded task: %s", text)
}
})

t.Run("toolsets trims the read tool list", func(t *testing.T) {
cs := connectSpawned(t, ctx, bin, dbPath, "--toolsets=tasks")
names := listToolNames(t, ctx, cs)
if len(names) != 3 {
t.Fatalf("--toolsets=tasks = %d tools %v, want 3", len(names), names)
}
for _, want := range []string{"things_list", "things_show", "things_search"} {
if !names[want] {
t.Errorf("missing tool %q (got %v)", want, names)
}
}
})

t.Run("read-only=false exposes the write tools", func(t *testing.T) {
cs := connectSpawned(t, ctx, bin, dbPath, "--read-only=false")
names := listToolNames(t, ctx, cs)
if len(names) != 14 {
t.Fatalf("--read-only=false = %d tools %v, want 14", len(names), names)
}
for _, want := range []string{
"things_add", "things_edit", "things_complete", "things_cancel",
"things_add_project", "things_edit_project", "things_log", "things_import",
} {
if !names[want] {
t.Errorf("missing write tool %q (got %v)", want, names)
}
}
// Deliberately do not CALL any write tool here: that would shell out to
// open/osascript and mutate real Things. The recordingWriter unit tests
// cover write-handler behavior.
})
}

func connectSpawned(t *testing.T, ctx context.Context, bin, dbPath string, extraArgs ...string) *mcp.ClientSession {
t.Helper()
args := append([]string{"--db", dbPath, "mcp"}, extraArgs...)
transport := &mcp.CommandTransport{Command: exec.Command(bin, args...)}
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "test"}, nil)
cs, err := client.Connect(ctx, transport, nil)
if err != nil {
t.Fatalf("connect to spawned server: %v", err)
t.Fatalf("connect to spawned server (%v): %v", extraArgs, err)
}
defer func() { _ = cs.Close() }()
t.Cleanup(func() { _ = cs.Close() })
return cs
}

func listToolNames(t *testing.T, ctx context.Context, cs *mcp.ClientSession) map[string]bool {
t.Helper()
tools, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatalf("tools/list: %v", err)
}
if len(tools.Tools) != 6 {
t.Fatalf("tools/list returned %d tools, want 6", len(tools.Tools))
}

res, err := cs.CallTool(ctx, &mcp.CallToolParams{
Name: "things_search",
Arguments: map[string]any{"query": "milk"},
})
if err != nil {
t.Fatalf("tools/call: %v", err)
}
if res.IsError {
t.Fatalf("things_search reported a tool error: %+v", res.Content)
}

var text string
for _, c := range res.Content {
if tc, ok := c.(*mcp.TextContent); ok {
text += tc.Text
}
}
var tasks []model.Task
if err := json.Unmarshal([]byte(text), &tasks); err != nil {
t.Fatalf("result is not the CLI's JSON: %v\n%s", err, text)
}
found := false
for _, task := range tasks {
if task.Title == "Buy milk" {
found = true
}
}
if !found {
t.Errorf("round-trip did not return the seeded task: %s", text)
names := make(map[string]bool, len(tools.Tools))
for _, tool := range tools.Tools {
names[tool.Name] = true
}
return names
}
Loading