diff --git a/Makefile b/Makefile index 3efe491..199e956 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/README.md b/README.md index 69a05b7..eb15a03 100644 --- a/README.md +++ b/README.md @@ -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`, `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 diff --git a/cmd/things/main.go b/cmd/things/main.go index f17fdb6..1fa833a 100644 --- a/cmd/things/main.go +++ b/cmd/things/main.go @@ -3,13 +3,16 @@ 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" @@ -17,6 +20,7 @@ import ( "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" @@ -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)."` @@ -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."` diff --git a/cmd/things/mcp_integration_test.go b/cmd/things/mcp_integration_test.go new file mode 100644 index 0000000..7ff1f76 --- /dev/null +++ b/cmd/things/mcp_integration_test.go @@ -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 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) + } +} diff --git a/go.mod b/go.mod index 327c9bc..4f6f030 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 8d6df52..9467caa 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= @@ -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= diff --git a/internal/db/areas.go b/internal/db/areas.go index 0b3f3e0..c708fc0 100644 --- a/internal/db/areas.go +++ b/internal/db/areas.go @@ -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 diff --git a/internal/db/dbtest/dbtest.go b/internal/db/dbtest/dbtest.go index 83fe771..2d834f2 100644 --- a/internal/db/dbtest/dbtest.go +++ b/internal/db/dbtest/dbtest.go @@ -4,6 +4,7 @@ package dbtest import ( "database/sql" _ "embed" + "path/filepath" "testing" _ "modernc.org/sqlite" @@ -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 `. +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 +} diff --git a/internal/db/projects.go b/internal/db/projects.go index 5a80275..22a7ff6 100644 --- a/internal/db/projects.go +++ b/internal/db/projects.go @@ -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 diff --git a/internal/db/tags.go b/internal/db/tags.go index d4edc41..4df1a10 100644 --- a/internal/db/tags.go +++ b/internal/db/tags.go @@ -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 { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 0c04594..caaf813 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -266,7 +266,9 @@ func (d *DB) collectTasks(query string, args ...any) ([]model.Task, error) { } defer rows.Close() - var tasks []model.Task + // Initialize empty (not nil) so an empty result set renders as a JSON `[]` + // rather than `null` for --json / MCP consumers. + tasks := []model.Task{} for rows.Next() { t, err := scanTask(rows) if err != nil { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..6c59744 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,58 @@ +// Package mcpserver exposes the read-only side of things-cli as a Model Context +// Protocol (MCP) server over stdio. +// +// It mirrors the CLI's read commands 1:1 and renders every result as the same +// JSON the CLI emits with --json, so MCP hosts that cannot shell out (Claude +// Desktop) — and those that can (Cursor, Claude Code) — get a typed alternative +// to driving the binary via Bash. Writes are intentionally out of scope. +package mcpserver + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ryanlewis/things-cli/internal/db" + "github.com/ryanlewis/things-cli/internal/model" +) + +// Backend is the read-only slice of the Things3 database the tools need. +// *db.DB satisfies it. Config.Open hands each tool call a fresh Backend +// (open-per-call, mirroring the CLI) which the handler closes when done. +type Backend interface { + ListTasks(view string, opts db.TaskFilter) ([]model.Task, error) + GetTask(ref string) (*model.Task, error) + GetChecklistItems(taskUUID string) ([]model.ChecklistItem, error) + SearchTasks(query string) ([]model.Task, error) + ListProjects(area string, completed bool) ([]model.Project, error) + ListAreas() ([]model.Area, error) + ListTags() ([]model.Tag, error) + Close() error +} + +// Config configures the MCP server. +type Config struct { + // Open returns a fresh Backend for a single tool call. The handler that + // requested it is responsible for closing it. Required. + Open func() (Backend, error) + // Version is reported to clients as the server implementation version. + Version string +} + +// NewServer builds an *mcp.Server with the read-only tools registered. It is +// exported so tests can drive it over an in-memory transport. +func NewServer(cfg Config) *mcp.Server { + s := mcp.NewServer(&mcp.Implementation{Name: "things", Version: cfg.Version}, nil) + registerTools(s, toolset{open: cfg.Open}) + return s +} + +// Serve runs the MCP server over stdio until the client disconnects or ctx is +// cancelled. +func Serve(ctx context.Context, cfg Config) error { + if cfg.Open == nil { + return fmt.Errorf("mcpserver: Config.Open is required") + } + return NewServer(cfg).Run(ctx, &mcp.StdioTransport{}) +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..a80ca48 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,406 @@ +package mcpserver_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ryanlewis/things-cli/internal/db" + "github.com/ryanlewis/things-cli/internal/db/dbtest" + "github.com/ryanlewis/things-cli/internal/mcpserver" + "github.com/ryanlewis/things-cli/internal/model" +) + +// nopCloseBackend lets every tool call share one in-memory database: the +// open-per-call contract still runs (and Close is exercised) but it doesn't +// tear down the test fixture. +type nopCloseBackend struct{ mcpserver.Backend } + +func (nopCloseBackend) Close() error { return nil } + +// connect seeds a database, starts the MCP server over an in-memory transport, +// and returns a connected client session. +func connect(t *testing.T) *mcp.ClientSession { + t.Helper() + database := seedDB(t) + return session(t, func() (mcpserver.Backend, error) { + return nopCloseBackend{database}, nil + }) +} + +// session starts the MCP server with the given per-call opener over an +// in-memory transport and returns a connected client session. +func session(t *testing.T, open func() (mcpserver.Backend, error)) *mcp.ClientSession { + t.Helper() + srv := mcpserver.NewServer(mcpserver.Config{Version: "test", Open: open}) + + ctx := context.Background() + serverT, clientT := mcp.NewInMemoryTransports() + ss, err := srv.Connect(ctx, serverT, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + t.Cleanup(func() { _ = ss.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "test"}, nil) + cs, err := client.Connect(ctx, clientT, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +// call invokes a tool and returns its text content plus the IsError flag. A +// non-nil Go error means a protocol-level failure (not a tool error). +func call(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) (string, bool) { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("CallTool %s: %v", name, err) + } + var sb strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + sb.WriteString(tc.Text) + } + } + return sb.String(), res.IsError +} + +func TestListTools(t *testing.T) { + cs := connect(t) + res, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + got := make(map[string]bool) + for _, tool := range res.Tools { + got[tool.Name] = true + if tool.Description == "" { + t.Errorf("tool %s has no description", tool.Name) + } + } + for _, want := range []string{ + "things_list", "things_show", "things_search", + "things_projects", "things_areas", "things_tags", + } { + if !got[want] { + t.Errorf("missing tool %q (got %v)", want, got) + } + } + if len(res.Tools) != 6 { + t.Errorf("got %d tools, want 6", len(res.Tools)) + } +} + +func TestListTool(t *testing.T) { + cs := connect(t) + + t.Run("today default", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", nil) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + // The result must be the CLI's --json output: pure JSON, no ANSI. + if strings.Contains(text, "\x1b[") { + t.Errorf("result contains ANSI escape sequences: %q", text) + } + tasks := decodeTasks(t, text) + if !containsTitle(tasks, "Buy milk") { + t.Errorf("today view missing 'Buy milk': %s", text) + } + }) + + t.Run("inbox view", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{"view": "inbox"}) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + tasks := decodeTasks(t, text) + if !containsTitle(tasks, "Think") { + t.Errorf("inbox view missing 'Think': %s", text) + } + if containsTitle(tasks, "Buy milk") { + t.Errorf("inbox view should not contain today task: %s", text) + } + }) + + t.Run("tag filter", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{"view": "anytime", "tag": "urgent"}) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + tasks := decodeTasks(t, text) + if !containsTitle(tasks, "Buy milk") { + t.Errorf("tag filter missing 'Buy milk': %s", text) + } + }) + + t.Run("unknown view is a tool error", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{"view": "bogus"}) + if !isErr { + t.Fatalf("expected tool error for bogus view, got: %s", text) + } + if !strings.Contains(text, "unknown view") { + t.Errorf("error should mention unknown view: %s", text) + } + }) + + t.Run("date filters rejected on inbox", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{"view": "inbox", "on": "2026-05-30"}) + if !isErr { + t.Fatalf("expected tool error for date filter on inbox, got: %s", text) + } + }) + + t.Run("on cannot combine with from", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{"view": "anytime", "on": "2026-05-30", "from": "2026-05-01"}) + if !isErr { + t.Fatalf("expected tool error combining on/from, got: %s", text) + } + }) +} + +func TestListDateFilters(t *testing.T) { + cs := connect(t) + + t.Run("from/to range includes scheduled task", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{ + "view": "upcoming", "from": "2026-06-01", "to": "2026-06-30", + }) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + if !containsTitle(decodeTasks(t, text), "Upcoming review") { + t.Errorf("range 2026-06 missing 'Upcoming review': %s", text) + } + }) + + t.Run("from after the task excludes it", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{"view": "upcoming", "from": "2026-07-01"}) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + if containsTitle(decodeTasks(t, text), "Upcoming review") { + t.Errorf("from 2026-07-01 should exclude the 2026-06-15 task: %s", text) + } + }) + + t.Run("inverted range is a tool error", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{ + "view": "upcoming", "from": "2026-06-30", "to": "2026-06-01", + }) + if !isErr { + t.Fatalf("expected tool error for inverted range, got: %s", text) + } + if !strings.Contains(text, "is after") { + t.Errorf("error should explain the inverted range: %s", text) + } + }) + + t.Run("deadlines view filters by deadline date", func(t *testing.T) { + text, isErr := call(t, cs, "things_list", map[string]any{"view": "deadlines", "on": "2026-06-20"}) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + if !containsTitle(decodeTasks(t, text), "Ship release") { + t.Errorf("deadlines on 2026-06-20 missing 'Ship release': %s", text) + } + }) +} + +// TestBackendOpenError locks in the open-per-call error path: when the opener +// fails, the handler must surface a tool error rather than dereference a failed +// backend (the nil-interface trap). +func TestBackendOpenError(t *testing.T) { + cs := session(t, func() (mcpserver.Backend, error) { + return nil, errors.New("database unavailable") + }) + for _, tool := range []string{"things_list", "things_search", "things_areas"} { + args := map[string]any(nil) + if tool == "things_search" { + args = map[string]any{"query": "x"} + } + text, isErr := call(t, cs, tool, args) + if !isErr { + t.Errorf("%s: expected tool error when backend open fails, got: %s", tool, text) + } + if !strings.Contains(text, "database unavailable") { + t.Errorf("%s: error should surface the open failure: %s", tool, text) + } + } +} + +func TestShowTool(t *testing.T) { + cs := connect(t) + + t.Run("by uuid with checklist", func(t *testing.T) { + text, isErr := call(t, cs, "things_show", map[string]any{"task": "task-1"}) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + var got map[string]any + if err := json.Unmarshal([]byte(text), &got); err != nil { + t.Fatalf("unmarshal show: %v\n%s", err, text) + } + if got["title"] != "Buy milk" { + t.Errorf("title = %v, want Buy milk", got["title"]) + } + if got["checklist"] == nil { + t.Errorf("expected checklist in show output: %s", text) + } + }) + + t.Run("not found", func(t *testing.T) { + text, isErr := call(t, cs, "things_show", map[string]any{"task": "no-such-task"}) + if !isErr { + t.Fatalf("expected tool error for missing task, got: %s", text) + } + if !strings.Contains(text, "not found") { + t.Errorf("error should say not found: %s", text) + } + }) + + t.Run("ambiguous lists candidates", func(t *testing.T) { + text, isErr := call(t, cs, "things_show", map[string]any{"task": "Review PR"}) + if !isErr { + t.Fatalf("expected tool error for ambiguous task, got: %s", text) + } + if !strings.Contains(text, "ambiguous") || !strings.Contains(text, "task-3") || !strings.Contains(text, "task-4") { + t.Errorf("ambiguous error should list candidate UUIDs: %s", text) + } + }) +} + +func TestSearchTool(t *testing.T) { + cs := connect(t) + text, isErr := call(t, cs, "things_search", map[string]any{"query": "milk"}) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + tasks := decodeTasks(t, text) + if !containsTitle(tasks, "Buy milk") { + t.Errorf("search 'milk' missing 'Buy milk': %s", text) + } +} + +// TestEmptyResultIsJSONArray pins the array contract: a query matching nothing +// must serialize as `[]`, not `null`, so clients can iterate unconditionally. +func TestEmptyResultIsJSONArray(t *testing.T) { + cs := connect(t) + text, isErr := call(t, cs, "things_search", map[string]any{"query": "zzz-no-such-task-zzz"}) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + if got := strings.TrimSpace(text); got != "[]" { + t.Errorf("empty search result = %q, want %q", got, "[]") + } +} + +func TestProjectsAreasTags(t *testing.T) { + cs := connect(t) + + projText, isErr := call(t, cs, "things_projects", nil) + if isErr { + t.Fatalf("projects tool error: %s", projText) + } + var projects []model.Project + if err := json.Unmarshal([]byte(projText), &projects); err != nil { + t.Fatalf("unmarshal projects: %v\n%s", err, projText) + } + if len(projects) == 0 || projects[0].Title != "Chores" { + t.Errorf("projects = %+v, want one titled Chores", projects) + } + + areaText, isErr := call(t, cs, "things_areas", nil) + if isErr { + t.Fatalf("areas tool error: %s", areaText) + } + if !strings.Contains(areaText, "Home") { + t.Errorf("areas missing 'Home': %s", areaText) + } + + tagText, isErr := call(t, cs, "things_tags", nil) + if isErr { + t.Fatalf("tags tool error: %s", tagText) + } + if !strings.Contains(tagText, "urgent") { + t.Errorf("tags missing 'urgent': %s", tagText) + } +} + +// --- helpers --- + +func decodeTasks(t *testing.T, text string) []model.Task { + t.Helper() + var tasks []model.Task + if err := json.Unmarshal([]byte(text), &tasks); err != nil { + t.Fatalf("unmarshal tasks: %v\n%s", err, text) + } + return tasks +} + +func containsTitle(tasks []model.Task, title string) bool { + for _, task := range tasks { + if task.Title == title { + return true + } + } + return false +} + +func seedDB(t *testing.T) *db.DB { + t.Helper() + sqlDB := dbtest.NewSQL(t) + exec := func(query string, args ...any) { + t.Helper() + if _, err := sqlDB.Exec(query, args...); err != nil { + t.Fatalf("seed (%s): %v", query, err) + } + } + + exec(`INSERT INTO TMArea (uuid, title, visible, "index") VALUES ('area-1', 'Home', 1, 0)`) + exec(`INSERT INTO TMTask (uuid, title, type, status, trashed, area, "index") + VALUES ('proj-1', 'Chores', 1, 0, 0, 'area-1', 0)`) + exec(`INSERT INTO TMTag (uuid, title, "index") VALUES ('tag-1', 'urgent', 0)`) + + today := int64(model.ThingsDateFromTime(time.Now())) + exec(`INSERT INTO TMTask (uuid, title, type, status, trashed, start, startBucket, startDate, project, "index") + VALUES ('task-1', 'Buy milk', 0, 0, 0, 1, 0, ?, 'proj-1', 0)`, today) + exec(`INSERT INTO TMTaskTag (tasks, tags) VALUES ('task-1', 'tag-1')`) + exec(`INSERT INTO TMChecklistItem (uuid, title, status, "index", task) + VALUES ('cl-1', 'Lactose free', 0, 0, 'task-1')`) + + exec(`INSERT INTO TMTask (uuid, title, type, status, trashed, start, "index") + VALUES ('task-2', 'Think', 0, 0, 0, 0, 1)`) + + // Two open tasks sharing a title prefix but no exact match: a show by the + // shared substring "Review PR" must be reported as ambiguous. + exec(`INSERT INTO TMTask (uuid, title, type, status, trashed, start, "index") + VALUES ('task-3', 'Review PR alpha', 0, 0, 0, 1, 2)`) + exec(`INSERT INTO TMTask (uuid, title, type, status, trashed, start, "index") + VALUES ('task-4', 'Review PR beta', 0, 0, 0, 1, 3)`) + + // Scheduled (upcoming) task with a fixed startDate for from/to range tests. + exec(`INSERT INTO TMTask (uuid, title, type, status, trashed, start, startDate, "index") + VALUES ('task-5', 'Upcoming review', 0, 0, 0, 2, ?, 4)`, dateInt(scheduledDate)) + // Task with a fixed deadline for deadlines-view date-filter tests. + exec(`INSERT INTO TMTask (uuid, title, type, status, trashed, start, deadline, "index") + VALUES ('task-6', 'Ship release', 0, 0, 0, 1, ?, 5)`, dateInt(deadlineDate)) + + return db.NewFromSQL(sqlDB) +} + +// Fixed dates used by the date-filter tests (kept well clear of "today"). +var ( + scheduledDate = time.Date(2026, 6, 15, 0, 0, 0, 0, time.Local) + deadlineDate = time.Date(2026, 6, 20, 0, 0, 0, 0, time.Local) +) + +func dateInt(tm time.Time) int64 { return int64(model.ThingsDateFromTime(tm)) } diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go new file mode 100644 index 0000000..f8f759b --- /dev/null +++ b/internal/mcpserver/tools.go @@ -0,0 +1,235 @@ +package mcpserver + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ryanlewis/things-cli/internal/db" + "github.com/ryanlewis/things-cli/internal/model" + "github.com/ryanlewis/things-cli/internal/output" + "github.com/ryanlewis/things-cli/internal/things" +) + +// toolset binds the per-call backend opener that every tool handler shares. +type toolset struct { + open func() (Backend, error) +} + +// Tool descriptions are the entire UX surface for hosts without Bash or the +// agent skill (Claude Desktop), so they spell out behavior and return shape. +const ( + listDesc = "List Things3 to-dos from a built-in list/view as JSON. " + + "Views: today, inbox, upcoming, anytime, someday, logbook, trash, deadlines (default: today). " + + "Optionally filter by project, area, or tag, and by scheduled date (or deadline, on the deadlines view) " + + "with on / from / to. Read-only; returns the same JSON as `things --json`." + showDesc = "Show one Things3 to-do (or project) in full — including notes and checklist items — as JSON. " + + "Accepts a UUID (preferred) or a title; a title matching several to-dos returns the candidates so you can retry with a UUID. " + + "Read-only." + searchDesc = "Search Things3 to-dos by a case-insensitive substring of their title or notes, excluding trashed items. " + + "Returns a JSON array of matching tasks. Read-only." + projectsDesc = "List Things3 projects as JSON, optionally filtered by area and optionally including completed projects. Read-only." + areasDesc = "List Things3 areas as JSON. Read-only." + tagsDesc = "List Things3 tags as JSON. Read-only." +) + +// registerTools wires the six read-only tools onto the server. Input schemas +// are inferred from the struct types (jsonschema tags supply field +// descriptions; fields without `omitempty` are required). +func registerTools(s *mcp.Server, t toolset) { + mcp.AddTool(s, &mcp.Tool{Name: "things_list", Description: listDesc}, t.list) + mcp.AddTool(s, &mcp.Tool{Name: "things_show", Description: showDesc}, t.show) + mcp.AddTool(s, &mcp.Tool{Name: "things_search", Description: searchDesc}, t.search) + mcp.AddTool(s, &mcp.Tool{Name: "things_projects", Description: projectsDesc}, t.projects) + mcp.AddTool(s, &mcp.Tool{Name: "things_areas", Description: areasDesc}, t.areas) + mcp.AddTool(s, &mcp.Tool{Name: "things_tags", Description: tagsDesc}, t.tags) +} + +type listInput struct { + View string `json:"view,omitempty" jsonschema:"Built-in list to read: today, inbox, upcoming, anytime, someday, logbook, trash, or deadlines. Defaults to today (or to the given project's tasks when project is set)."` + Project string `json:"project,omitempty" jsonschema:"Filter by project name or UUID. When set without an explicit view, lists that project's tasks."` + Area string `json:"area,omitempty" jsonschema:"Filter by area name or UUID."` + Tag string `json:"tag,omitempty" jsonschema:"Filter by tag name."` + On string `json:"on,omitempty" jsonschema:"Only tasks scheduled on this date (YYYY-MM-DD or RFC3339); on the deadlines view, filters by deadline. Mutually exclusive with from/to."` + From string `json:"from,omitempty" jsonschema:"Only tasks scheduled on or after this date (YYYY-MM-DD or RFC3339); on the deadlines view, filters by deadline."` + To string `json:"to,omitempty" jsonschema:"Only tasks scheduled on or before this date (YYYY-MM-DD or RFC3339); on the deadlines view, filters by deadline."` +} + +type showInput struct { + Task string `json:"task" jsonschema:"Task title or UUID to show. Prefer a UUID; a title that matches several to-dos returns the candidates instead."` +} + +type searchInput struct { + Query string `json:"query" jsonschema:"Substring matched (case-insensitively) against task titles and notes. Trashed tasks are excluded."` +} + +type projectsInput struct { + Area string `json:"area,omitempty" jsonschema:"Filter by area name or UUID."` + Completed bool `json:"completed,omitempty" jsonschema:"Include completed projects (default false)."` +} + +// emptyInput is the object schema for tools that take no arguments. +type emptyInput struct{} + +func (t toolset) list(_ context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, any, error) { + view, filter, err := resolveListQuery(in) + if err != nil { + return nil, nil, err + } + return t.query(func(b Backend) (any, error) { return b.ListTasks(view, filter) }) +} + +func (t toolset) search(_ context.Context, _ *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, any, error) { + return t.query(func(b Backend) (any, error) { return b.SearchTasks(in.Query) }) +} + +func (t toolset) projects(_ context.Context, _ *mcp.CallToolRequest, in projectsInput) (*mcp.CallToolResult, any, error) { + return t.query(func(b Backend) (any, error) { return b.ListProjects(in.Area, in.Completed) }) +} + +func (t toolset) areas(_ context.Context, _ *mcp.CallToolRequest, _ emptyInput) (*mcp.CallToolResult, any, error) { + return t.query(func(b Backend) (any, error) { return b.ListAreas() }) +} + +func (t toolset) tags(_ context.Context, _ *mcp.CallToolRequest, _ emptyInput) (*mcp.CallToolResult, any, error) { + return t.query(func(b Backend) (any, error) { return b.ListTags() }) +} + +func (t toolset) show(_ context.Context, _ *mcp.CallToolRequest, in showInput) (*mcp.CallToolResult, any, error) { + b, err := t.open() + if err != nil { + return nil, nil, err + } + defer func() { _ = b.Close() }() + task, err := b.GetTask(in.Task) + if err != nil { + var ambig *db.AmbiguousTaskError + if errors.As(err, &ambig) { + return nil, nil, ambiguousError(ambig) + } + return nil, nil, err + } + items, err := b.GetChecklistItems(task.UUID) + if err != nil { + return nil, nil, err + } + var buf bytes.Buffer + if err := output.PrintTaskWithChecklist(&buf, task, items, true); err != nil { + return nil, nil, fmt.Errorf("rendering result: %w", err) + } + return textResult(buf.String()), nil, nil +} + +// query opens a fresh backend (open-per-call, mirroring the CLI), runs fetch, +// renders the result as the CLI's --json output, and closes the backend. +func (t toolset) query(fetch func(Backend) (any, error)) (*mcp.CallToolResult, any, error) { + b, err := t.open() + if err != nil { + return nil, nil, err + } + defer func() { _ = b.Close() }() + v, err := fetch(b) + if err != nil { + return nil, nil, err + } + return jsonResult(v) +} + +// resolveListQuery mirrors ListCmd.Run: default the view (to the project view +// when a project is given, else today), validate it, and apply date filters. +func resolveListQuery(in listInput) (string, db.TaskFilter, error) { + view := in.View + if view == "" { + if in.Project != "" { + view = "project" + } else { + view = "today" + } + } + if !db.ValidView(view) { + return "", db.TaskFilter{}, fmt.Errorf("unknown view %q (valid: today, inbox, upcoming, anytime, someday, logbook, trash, deadlines)", view) + } + filter := db.TaskFilter{Project: in.Project, Area: in.Area, Tag: in.Tag} + if err := applyDateFilters(&filter, view, in.On, in.From, in.To); err != nil { + return "", db.TaskFilter{}, err + } + return view, filter, nil +} + +// applyDateFilters mirrors the CLI's helper of the same name (cmd/things): it +// validates the on/from/to combination against the view, parses each into a +// ThingsDate, and rejects an inverted range. It is a deliberate copy — the +// CLI's version lives in package main and can't be imported, and hoisting it +// into internal/db would couple db to internal/things for date parsing. The +// behavior here is pinned by the date-filter tests in this package; keep the +// two in sync if either changes. +func applyDateFilters(filter *db.TaskFilter, view, on, from, to string) error { + if on == "" && from == "" && to == "" { + return nil + } + if !db.DateFilterableView(view) { + return fmt.Errorf("--on/--from/--to are not supported on the %q view", view) + } + if on != "" && (from != "" || to != "") { + return fmt.Errorf("--on cannot be combined with --from/--to") + } + + parse := func(field, raw string) (*model.ThingsDate, error) { + if raw == "" { + return nil, nil + } + tm, err := things.ParseListDate(field, raw) + if err != nil { + return nil, err + } + d := model.ThingsDateFromTime(tm) + return &d, nil + } + + var err error + if filter.On, err = parse("on", on); err != nil { + return err + } + if filter.From, err = parse("from", from); err != nil { + return err + } + if filter.To, err = parse("to", to); err != nil { + return err + } + if filter.From != nil && filter.To != nil && *filter.From > *filter.To { + return fmt.Errorf("--from %s is after --to %s", filter.From, filter.To) + } + return nil +} + +// jsonResult renders v as the CLI's --json output and wraps it as tool content. +func jsonResult(v any) (*mcp.CallToolResult, any, error) { + var buf bytes.Buffer + if err := output.Print(&buf, v, true); err != nil { + return nil, nil, fmt.Errorf("rendering result: %w", err) + } + return textResult(buf.String()), nil, nil +} + +func textResult(s string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: s}}} +} + +// ambiguousError turns a title that matches several to-dos into a tool error +// listing the candidates, so the caller can retry with a UUID. +func ambiguousError(e *db.AmbiguousTaskError) error { + var b strings.Builder + fmt.Fprintf(&b, "ambiguous task %q — matches %d tasks; retry with a UUID:", e.Query, len(e.Matches)) + for _, m := range e.Matches { + if m.ProjectTitle != "" { + fmt.Fprintf(&b, "\n - %s (%s) [%s]", m.Title, m.UUID, m.ProjectTitle) + } else { + fmt.Fprintf(&b, "\n - %s (%s)", m.Title, m.UUID) + } + } + return errors.New(b.String()) +}