Skip to content

feat(mcp): read-only MCP server baked into the CLI (things mcp) - #98

Open
ryanlewis wants to merge 3 commits into
mainfrom
feat/issue-82-mcp
Open

feat(mcp): read-only MCP server baked into the CLI (things mcp)#98
ryanlewis wants to merge 3 commits into
mainfrom
feat/issue-82-mcp

Conversation

@ryanlewis

@ryanlewis ryanlewis commented May 30, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a things mcp subcommand that runs a read-only Model Context Protocol server over stdio, exposing the CLI's read surface as six typed MCP tools. Aimed at hosts that can't shell out (Claude Desktop) and a typed alternative for those that can (Cursor, Claude Code). Implements #82.

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

Tool Mirrors Args
things_list things <view> view, project, area, tag, on, from, to
things_show things show task (UUID or title)
things_search things search query
things_projects things projects area, completed
things_areas things areas
things_tags things tags

Design

  • One binary. The server lives in internal/mcpserver; a thin things mcp Kong command starts it. internal/mcpserver is the ready seam for a standalone cmd/things-mcp later if ever wanted, at ~10 lines.
  • Reuse, not re-derive. Handlers call the existing internal/db queries and render via output.Print(buf, v, asJSON=true) — the ANSI-free JSON path — so output is byte-identical to --json.
  • Open-per-call DB (db.Open → query → Close), mirroring CLI semantics and sidestepping WAL/snapshot concerns vs. Things3's writes. A Backend interface (satisfied by *db.DB) makes handlers unit-testable.
  • Fails fast at startup with a clear error if the Things3 DB is missing/unreachable; never starts a half-broken server.
  • Read-only. Writes (add/complete/cancel/edit), HTTP/SSE, and client-config editing are out of scope per the issue.
  • internal/skill/SKILL.md is intentionally unchanged — things mcp is a long-running server, not a one-shot an agent drives via Bash, so it doesn't belong in the Bash skill (the issue calls for the two to coexist independently).

Decisions (issue's open questions)

  • Library: official github.com/modelcontextprotocol/go-sdk v1.6.1 — v1.x stability/compat guarantee, reflection-derived typed schemas, pure Go, the ecosystem's convergence point (vs. pre-1.0 mark3labs/mcp-go).
  • Tool naming: things_-prefixed — collision-proof and self-documenting; settles naming before tag.
  • Scope: expanded from the issue's list/show/search to the full read-only surface (+ projects/areas/tags).

Tests

  • internal/mcpserver/server_test.go — drives the real server over the SDK's in-memory transport: tool registration, views, project/area/tag filters, on/from/to (incl. inverted range + deadlines view), unknown-view/date-combo errors, show by UUID/title/ambiguous/not-found, search, projects/areas/tags, an ANSI-free assertion, an open-failure guard, and the empty-result [] contract.
  • cmd/things/mcp_integration_test.go (//go:build integration) — builds the binary and runs a full stdio round-trip (initializetools/listtools/call) against a seeded temp DB; hermetic, no real Things3. make test-integration.

A bundled extra-high-recall multi-agent review pass also ran; its confirmed findings (empty list → null instead of []; date-filter error-message drift) are fixed in 691bea6.

Verification

make build · make test (-race, all packages) · make lint (clean) · make test-integration — all green. Manual: things mcp registers; fail-fast prints a clear error and exits 1.

Closes #82

ryanlewis added 2 commits May 30, 2026 19:40
Add a `things mcp` subcommand that runs a Model Context Protocol server
over stdio, exposing the read-only CLI surface as six typed tools
(things_list/show/search/projects/areas/tags). Aimed at MCP hosts that
can't shell out (Claude Desktop) and a typed alternative for those that
can (Cursor, Claude Code).

- internal/mcpserver: server + handlers on the official
  modelcontextprotocol/go-sdk v1.6.1. Each tool opens the DB per call
  (read-only), runs the existing db query, and returns the same JSON the
  CLI emits with --json (no ANSI). show reports ambiguous titles with
  their UUIDs; the server fails fast at startup if the DB is unreachable.
- cmd/things: wire the `mcp` command (fail-fast probe + signal handling).
- dbtest: add NewFileSQL for cross-process (binary-spawning) tests.
- tests: in-memory transport round-trip unit tests plus a build-tagged
  stdio integration test that spawns the binary; `make test-integration`.
- docs: README MCP section with Claude Desktop / Cursor / generic config.

Refs #82
Address findings from an extra-high-recall review pass:

- internal/db: list collectors (collectTasks, ListAreas, ListTags,
  ListProjects) initialized empty slices instead of nil, so a zero-row
  result renders as JSON `[]` rather than `null` for both --json and the
  MCP tools — honoring the "returns a JSON array" contract. Keeps MCP
  output identical to the CLI's --json (both now `[]`).
- internal/mcpserver: align applyDateFilters error messages to the CLI's
  `--on/--from/--to` form so the date-error surface is self-consistent
  (things.ParseListDate already emits `--`-prefixed errors) and 1:1 with
  the CLI.
- test: pin the empty-result `[]` contract via the MCP search tool.

Refs #82
@ryanlewis
ryanlewis force-pushed the feat/issue-82-mcp branch from 691bea6 to f9bd6e1 Compare May 30, 2026 18:43
Replace the terse config snippet with a step-by-step Claude Desktop guide
(find the binary path via `which things`, Settings → Developer → Edit
Config, ⌘Q to reload, verify via the tools menu) plus a troubleshooting
note. Fixes a misleading example: `"command": "things"` (bare) fails in
Claude Desktop because GUI apps don't inherit the shell PATH — the docs
now use the absolute path. Adds brief Cursor / Claude Code / generic host
notes and the --db override.

Refs #82
ryanlewis added a commit that referenced this pull request Sep 10, 2026
Closes #208

## What changed

`type` in JSON output is now the string `todo`, `project` or `heading`
instead of Things' raw TMTask integer. `status` already rendered as a
string, so the two fields were in two different styles on the same
object; they now share one.

The codec is `model.TaskType`, a named int that mirrors `model.Status`:
a `typeNames` map as the single source of truth for `String`,
`MarshalJSON` and `UnmarshalJSON`; an unrecognized raw code preserved as
its integer rather than collapsing to a lossy `"unknown"`;
`UnmarshalJSON` accepting the name or the legacy integer, and treating a
JSON null as a no-op. `Task.Type` changes from `int` to
`model.TaskType`, so comparisons against `model.TypeTask` /
`model.TypeProject` / `model.TypeHeading` compile unchanged.

Every JSON surface picks this up without its own change, because they
all marshal `model.Task` through the single encoder in
`internal/output`.

Plain text is unchanged, including the `(project)` marker on a project
row and the `Type: project` line in a project's detail block.
`model.Project` still has no `type` field.

One non-obvious knock-on: `notHeading` in `internal/db/tasks.go` builds
a SQL fragment with `fmt.Sprintf("... != %d", model.TypeHeading)`. That
constant is now a `fmt.Stringer`, so `%v` or `%s` would splice the word
`heading` into the SQL where `2` belongs. The constant is wrapped in
`int()` with a comment, so the fragment no longer depends on the format
verb.

## Why

A bare integer is an internal Things code. It reads badly for a human
and forces an agent or a jq filter to carry a lookup table that appears
nowhere in the output.

## How tested

`make test` and `make lint` both clean, on top of #206.

- `TestTaskTypeMarshalJSON`, `TestTaskTypeUnmarshalJSON`,
`TestTaskTypeRoundTripJSON` and `TestTaskTypeString` lock the three wire
names, the integer fallback, the null no-op and the rejection of unknown
names.
- `TestRunJSONRendersTypeAsString` walks the JSON surfaces end to end
and asserts the raw JSON text, not an unmarshalled `model.Task`. That
distinction matters: `UnmarshalJSON` still accepts the legacy integer,
so a decode-and-compare test would keep passing even if the encoder
regressed to emitting ints. It also fails on any `type` field holding a
bare number, matched by regex so it is independent of the encoder's
indentation and catches an unmapped code rather than only 0, 1 and 2.
- Both new tests were mutation-checked: reverting `MarshalJSON` to emit
the raw int fails them.
- Verified against the real Things database as well as the seeded one,
uuids not titles. `list today` returns `Vf6GPsvdnJYqTdD5f41H3A` as
`todo` and `X28bim63xxvGvWLncbfd7f` as `project`; `show` returns the
same two individually; `search` returns `BEFn6McBh2DFfU577KBaq7` as
`project`; `logbook` returns `L16wNJD3D84AEJwr14QWni` as `project`
across 1190 rows. `someday` currently holds no project, so only `todo`
appears there. `things projects` carries no `type` field at all, across
23 rows.

## Breaking change

| | Old | New |
|---|---|---|
| to-do | `"type": 0` | `"type": "todo"` |
| project | `"type": 1` | `"type": "project"` |
| heading | `"type": 2` | `"type": "heading"` |

Any caller matching on `.type==1` has to become `.type=="project"`.

Docs updated in `internal/skill/SKILL.md`, `docs/content/commands.md`,
`docs/content/agents.md` and `README.md`, including the worked `jq`
example in `agents.md` that filtered on `select(.type==0)`. Each page
describing JSON output carries a note naming the old integers, anchored
to v0.7.0 as the last release that emitted them.

Two things the docs now say that they did not before, both found in
review:

- `type` rides on task rows only, and headings are never returned by any
command, so `"heading"` never actually appears in output. It exists in
the codec because the codec has to be total over the three codes the
database uses.
- The value is not the vocabulary a `things import` payload takes. That
format is Things' own JSON URL scheme, which spells a to-do `"to-do"`.
An agent that copies `.type` from a listing into an import item gets an
item Things drops silently, since `validateImportJSON` only checks array
shape.

## For whoever cuts the release

The release body is read from `.github/releases/<Tag>.md` and
`.github/release.yml` has no breaking-change category, so a `feat!`
commit files under "Features" with no compatibility warning. The
`status` break in 8656bd8 hit the same gap. The release note wants the
table above, and a reminder to run `things skill install` so agents pick
up the new SKILL.md.

## Note for the open MCP branches

#98 and #99 serialise the same `model.Task` and will need a rebase onto
this. They pick up the string automatically; what needs checking is any
assertion or tool schema in those branches that describes `type` as an
integer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: bake MCP server into the CLI (things mcp)

1 participant