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
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build install lint test cover fmt tidy clean release-snapshot release-check test-install
.PHONY: build install lint test test-integration cover fmt tidy clean release-snapshot release-check test-install

BINARY := things

Expand All @@ -14,6 +14,11 @@ lint:
test:
go test -race ./...

# Integration tests (build tag `integration`) build the binary and exercise it
# end-to-end, e.g. the MCP stdio round-trip. Kept out of the default `test`.
test-integration:
go test -tags integration -race ./...

cover:
go test -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
Expand Down
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,75 @@ The skill body is [`internal/skill/SKILL.md`](internal/skill/SKILL.md),
embedded in the binary — so a plain `things` upgrade refreshes it; re-run
`skill install` to pick up the new version.

## 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.

Tools (results are the same JSON the CLI emits with `--json`):

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

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.

### Add it to Claude Desktop

Claude Desktop can't run shell commands, so the MCP server is how it reaches your
tasks. There's no point-and-click installer for a self-built binary yet, but the
in-app config editor keeps setup to a copy-paste:

1. **Install `things`** if you haven't (see [Install](#install)).
2. **Find its full path** — in Terminal, run `which things` and copy the line it
prints (e.g. `/opt/homebrew/bin/things`). The full path matters: Claude
Desktop doesn't inherit your Terminal's `PATH`, so a bare `things` won't work.
3. In Claude Desktop, open **Settings** (the `Claude` menu, top-left of the
screen) → **Developer** → **Edit Config**. A text file opens.
4. Paste the block below, swapping in the path from step 2:
```json
{
"mcpServers": {
"things": { "command": "/opt/homebrew/bin/things", "args": ["mcp"] }
}
}
```
If the file already lists other servers, add just the `"things": { … }` entry
inside the existing `"mcpServers"`.
5. **Save**, then **fully quit** Claude Desktop with **⌘Q** (closing the window
isn't enough — the config is only read on launch) and reopen it.
6. Click the **+ / tools (🔨)** icon in the chat box; **things** should be
listed. Try *"what's on my list today?"*

**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
`~/Library/Logs/Claude/mcp-server-things.log`. Note: the **"Add custom
connector"** button is for remote/HTTP servers only and won't add a local one
like this.

### Other hosts

- **Cursor** — add the same block to `~/.cursor/mcp.json` (global) or a project's
`.cursor/mcp.json`.
- **Claude Code** — `claude mcp add things -- things mcp` (or the same JSON).
- **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.

## How it works

- **Reads** go through `modernc.org/sqlite` (pure Go, no cgo) with
Expand Down
39 changes: 39 additions & 0 deletions cmd/things/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@ package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"

"github.com/alecthomas/kong"
"github.com/mattn/go-isatty"
"github.com/willabides/kongplete"

"github.com/ryanlewis/things-cli/internal/cache"
"github.com/ryanlewis/things-cli/internal/db"
"github.com/ryanlewis/things-cli/internal/mcpserver"
"github.com/ryanlewis/things-cli/internal/model"
"github.com/ryanlewis/things-cli/internal/output"
"github.com/ryanlewis/things-cli/internal/skill"
Expand Down Expand Up @@ -50,6 +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.)."`
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 @@ -494,6 +499,40 @@ 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{}

func (c *MCPCmd) Run(d *Deps) error {
path := d.DBPath
if path == "" {
p, err := db.FindDBPath()
if err != nil {
return err
}
path = p
}
// Fail fast: confirm the database is reachable before serving, so a missing
// or unreadable DB surfaces as a clear startup error instead of failing
// every tool call once a client has connected.
probe, err := db.Open(path)
if err != nil {
return err
}
_ = probe.Close()

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

return mcpserver.Serve(ctx, mcpserver.Config{
Version: version,
Open: func() (mcpserver.Backend, error) {
return db.Open(path)
},
})
}

type SkillCmd struct {
Install SkillInstallCmd `cmd:"" help:"Install the bundled skill for an AI coding agent."`
Uninstall SkillUninstallCmd `cmd:"" help:"Remove the bundled skill for an AI coding agent."`
Expand Down
86 changes: 86 additions & 0 deletions cmd/things/mcp_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//go:build integration

package main

import (
"context"
"encoding/json"
"os/exec"
"path/filepath"
"testing"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/ryanlewis/things-cli/internal/db/dbtest"
"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`).
func TestMCPStdioRoundTrip(t *testing.T) {
dbPath, sqlDB := dbtest.NewFileSQL(t)
if _, err := sqlDB.Exec(
`INSERT INTO TMTask (uuid, title, type, status, trashed, start, "index")
VALUES ('task-1', 'Buy milk', 0, 0, 0, 0, 0)`,
); err != nil {
t.Fatalf("seed: %v", err)
}

bin := filepath.Join(t.TempDir(), "things")
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("build binary: %v\n%s", err, out)
}

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

transport := &mcp.CommandTransport{Command: exec.Command(bin, "--db", dbPath, "mcp")}
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)
}
defer func() { _ = cs.Close() }()

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)
}
}
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/alecthomas/kong v1.15.0
github.com/charmbracelet/colorprofile v0.4.3
github.com/mattn/go-isatty v0.0.22
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/willabides/kongplete v0.4.0
golang.org/x/term v0.43.0
modernc.org/sqlite v1.50.1
Expand All @@ -21,6 +22,7 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
Expand All @@ -32,7 +34,11 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.72.3 // indirect
Expand Down
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand All @@ -47,6 +53,8 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
Expand All @@ -61,6 +69,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY=
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
Expand All @@ -69,10 +81,14 @@ github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gs
github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
Expand Down
3 changes: 2 additions & 1 deletion internal/db/areas.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ func (d *DB) ListAreas() ([]model.Area, error) {
}
defer rows.Close()

var areas []model.Area
// Empty (not nil) so a zero-row result renders as JSON `[]`, not `null`.
areas := []model.Area{}
for rows.Next() {
var a model.Area
var visible int
Expand Down
20 changes: 20 additions & 0 deletions internal/db/dbtest/dbtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package dbtest
import (
"database/sql"
_ "embed"
"path/filepath"
"testing"

_ "modernc.org/sqlite"
Expand All @@ -27,3 +28,22 @@ func NewSQL(t *testing.T) *sql.DB {
}
return sqlDB
}

// NewFileSQL is a file-backed sibling of NewSQL: it creates a temp-file SQLite
// with the Things3 schema applied and returns the file path alongside an open
// handle (closed via t.Cleanup). Use it when a separate process must open the
// same database — e.g. spawning the built binary with `--db <path>`.
func NewFileSQL(t *testing.T) (path string, sqlDB *sql.DB) {
t.Helper()
path = filepath.Join(t.TempDir(), "main.sqlite")
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
if _, err := sqlDB.Exec(schema); err != nil {
t.Fatalf("apply schema: %v", err)
}
return path, sqlDB
}
3 changes: 2 additions & 1 deletion internal/db/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ func (d *DB) ListProjects(areaFilter string, includeCompleted bool) ([]model.Pro
}
defer rows.Close()

var projects []model.Project
// Empty (not nil) so a zero-row result renders as JSON `[]`, not `null`.
projects := []model.Project{}
for rows.Next() {
var p model.Project
var tagsStr string
Expand Down
3 changes: 2 additions & 1 deletion internal/db/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ func (d *DB) ListTags() ([]model.Tag, error) {
}
defer rows.Close()

var tags []model.Tag
// Empty (not nil) so a zero-row result renders as JSON `[]`, not `null`.
tags := []model.Tag{}
for rows.Next() {
var t model.Tag
if err := rows.Scan(&t.UUID, &t.Title, &t.Shortcut, &t.ParentUUID); err != nil {
Expand Down
Loading