todo-mcp
by adrianhardy
README.md
# todo-mcp
An MCP server whose store is a `TODO.md` you can read, edit and diff by hand. Writes are
byte-range splices, so the file stays yours: hand-authored tables, tab indentation and any
prose outside a task are never re-serialised.
Speaks stdio for MCP clients and Streamable HTTP for everything else.
## Credit
Based on [CalamityAdam/mcp-todo](https://github.com/CalamityAdam/mcp-todo), which supplied
the original scaffold: the `createTodoMcpServer` factory shape, the Express Streamable HTTP
wrapper and the session handling.
Almost nothing else survives. That version kept todos as numbered records in a JSON blob at
`~/.mcp-todos.json` with three tools over `{ id, title, done }`. This one replaces the store
with a markdown document, swaps numeric ids for slugs, and grows the tool surface to seven
with statuses, areas, reference breadcrumbs, dated log notes, full-text query and
duplicate detection. The two projects no longer share an implementation.
Upstream ships no `LICENSE` file; its `package.json` declares ISC, which is what this repo
carries forward.
## Install
Run it straight from GitHub, no clone:
```bash
npx github:adrianhardy/todo-mcp
```
After pushing any changes, use `npx --ignore-existing
github:adrianhardy/todo-mcp` to pick them up.
For regular use, install once and forget about it:
```bash
npm i -g github:adrianhardy/todo-mcp
todo-mcp
```
Either route builds from source on install via the `prepare` script, so `dist/` is never
committed. Node 20 or newer.
## Usage
`todo-mcp` starts the **HTTP** server by default, because that is the useful thing to do
when a person runs it in a terminal. Set `MCP_STDIO=1` to speak stdio instead, which is
what an MCP client spawning it as a subprocess wants.
### With an MCP client
```json
{
"mcpServers": {
"todo": {
"command": "npx",
"args": ["-y", "github:adrianhardy/todo-mcp"],
"env": { "MCP_STDIO": "1" }
}
}
}
```
Installed globally, that becomes `"command": "todo-mcp"` with the same `env` block.
**The working directory decides which file you get.** `TODO_FILE` resolves against the
process's cwd and defaults to `TODO.md`, so a client launched in a project edits that
project's todo list. Set `TODO_FILE` to an absolute path if you want one shared list
regardless of where the server starts.
### Over HTTP
```bash
PORT=8080 TODO_MCP_TOKEN=$(openssl rand -hex 32) todo-mcp
```
- `POST /mcp` - JSON-RPC requests
- `GET /mcp` - SSE stream for server notifications
- `DELETE /mcp` - end the session
Setting `TODO_MCP_TOKEN` requires `Authorization: Bearer <token>` on all three. Leaving it
unset disables authentication, which is fine on localhost and nowhere else.
### Configuration
| variable | default | meaning |
|---|---|---|
| `TODO_FILE` | `TODO.md` | store path, resolved against cwd |
| `MCP_STDIO` | unset | `1` selects stdio instead of HTTP |
| `PORT` | `3000` | HTTP port |
| `TODO_MCP_TOKEN` | unset | bearer token; unset means no auth |
A `.env` file is read if present. See `.env.example`.
## Storage
`TODO.md` is the store, not a JSON blob. The file is the record: readable, editable by
hand, and diffable in git.
A task is a `## ` section. Fields the server owns live in a comment block directly under
the heading; everything below it is prose the human owns.
```markdown
## Feature Idea version two: the new widget which tracks things
<!-- todo
id: feature-idea-version-two
area: inventory
status: next
refs: [./src/do_stuff.ts, ClassName.Method, OtherClassName]
created: 2026-08-19
updated: 2026-08-22
-->
**Next step:** close the ledger. ClassName.Method uses 0.25 and it needs 17.2%.
**Already known:** ...
### Log
- 2026-08-22 Slab_Wall_1x3 not started; parade places 24 of those to every 6 of the 3x3.
```
Ids are slugs, not numbers, so they survive reordering and deletion. File order is
priority order, which is why there is no priority field.
Writes are **byte-range splices**: a mutation rewrites only the span it owns. Hand-authored
tables, tab indentation and any prose outside a task section are never re-serialised, so
they cannot be reflowed or lost. Handlers are serialised through a lock, because two
interleaved read-modify-write cycles would splice against offsets that no longer describe
the file.
## Design
- **list is abbreviated** `list_todos` returns a one-line index and never task bodies.
`get_todo` returns one whole section. `q` or `ref` is the expected path; listing
everything is the exception.
- **Capture takes one field.** Only `title` is required, and new tasks default to status
`captured`. A tool that demands an area and a next step at the moment something is noticed
does not get used, and the file only earns its keep if things get written down as they are
found. Triage moves `captured` to `open`/`next`/`parked`/`someday` later.
## Status values
| status | meaning |
|---|---|
| `captured` | raw, untriaged. The default for a new task. Hidden from unfiltered lists |
| `open` | real work, understood |
| `next` | up now |
| `parked` | deliberately deferred; the body says why |
| `someday` | aspirational |
| `done` | finished. Stays in the file for the record. Hidden from unfiltered lists |
## Tools
- `list_todos` - abbreviated index; filters `area`, `status`, `ref`, `q`, `limit`
- `get_todo` - full markdown of one task, body included
- `add_todo` - capture a task; only `title` required. Reports possible duplicates
- `update_todo` - change any field; only what is passed is rewritten
- `append_note` - add a dated bullet to a task's log
- `set_status` - move a task through triage
- `remove_todo` - delete a task and its prose. Prefer `set_status done`
## Resources
- `todos://list` - one-line index of open tasks
## Architecture
- `src/todo.ts` - the markdown store: parsing, byte-range patching, query, dedup
- `src/server.ts` - MCP tool surface. Pure factory, no side effects on import
- `src/http.ts` - Streamable HTTP transport, auth and session map
- `src/cli.ts` - the `todo-mcp` binary; picks a transport and starts it
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues