donetick-mcp
by jaredglaser
README.md
# donetick-mcp
A stdio MCP server that lets an AI assistant read and manage a self-hosted [Donetick](https://donetick.com) instance: chores, recurrence, assignment, completions, activity history, circle members, and projects.
## What this is
`donetick-mcp` runs as a local process, speaks the Model Context Protocol over stdio, and calls your Donetick instance's HTTP API on your behalf. It targets MCP protocol revision `2026-07-28` using the v2 `@modelcontextprotocol/server` package, and its `serveStdio` transport defaults to `legacy: 'serve'`, so a client still speaking the older 2025-era `initialize` handshake works without any extra configuration.
The server exposes twenty tools: five that read, eight that write, and seven that act on a chore's lifecycle. Deleting is the only one that asks the user to confirm before it proceeds. Every tool carries MCP annotations, so a client can tell `list_chores` from `delete_chore` before calling either: the five readers are `readOnlyHint`, and `edit_chore`, `delete_chore`, `reassign_chore`, `complete_chore`, `skip_chore`, `undo_chore` and `reject_chore` are `destructiveHint`, with `openWorldHint` false throughout since every tool talks to one known instance.
## Requirements
- [Bun](https://bun.sh) (version pinned in `.bun-version`)
- A running Donetick instance you can reach over HTTP or HTTPS
- A Donetick API token
- Docker, for `bun run verify:live` only. The server itself does not need it.
## Setup
1. Install dependencies:
```
bun install
```
2. Copy the example environment file and fill it in:
```
cp .env.example .env
```
3. Get an API token from Donetick: open the Donetick web UI, go to Settings, then Access Token, then Generate New Token. Paste it into `DONETICK_TOKEN`. This token is not your password; you can revoke it from the same screen.
4. Set `DONETICK_URL` to your instance's origin, with no trailing path, for example `https://donetick.example.com`. Plain `http` is allowed for LAN addresses.
5. Run the server directly to confirm it starts and can reach Donetick:
```
bun src/index.ts
```
A successful connection is logged to stderr as `donetick-mcp connected to <url>, N chores visible`. All diagnostic output goes to stderr; stdout is reserved for the JSON-RPC transport.
Bun reads `.env` from the working directory, not from the script's directory, so this works from the repo root and not from elsewhere. An MCP client launches the process with its own working directory, which is why the configuration below passes the variables inline instead.
## Configuration
| Variable | Required | Default | Purpose |
| --- | --- | --- | --- |
| `DONETICK_URL` | yes | none | Donetick instance origin. Must be an http or https URL with no path, query string, fragment, or credentials. |
| `DONETICK_TOKEN` | yes | none | API token, sent as the `secretkey` header on every request. |
| `DONETICK_TZ` | no | the system's local IANA zone | Used when a chore carries no timezone of its own. |
| `DONETICK_CACHE_TTL_MS` | no | `10000` | How long the chore list, and only the chore list, is cached in memory. `0` disables caching. |
| `DONETICK_MEMBER_CACHE_TTL_MS` | no | `300000` | How long circle members and projects are cached in memory, governing how stale a point standing can be. `0` disables caching. Independent of `DONETICK_CACHE_TTL_MS`. |
| `DONETICK_TIMEOUT_MS` | no | `15000` | Per-request timeout in milliseconds. |
See `src/config.ts` for the exact validation rules; invalid values fail at startup with a specific message rather than a stack trace.
## Using it with Claude Code
Add an entry to your MCP configuration, using an absolute path to `src/index.ts`:
```json
{
"mcpServers": {
"donetick": {
"command": "bun",
"args": ["/absolute/path/to/donetick/src/index.ts"],
"env": {
"DONETICK_URL": "https://donetick.example.com",
"DONETICK_TOKEN": "your-api-token"
}
}
}
}
```
## Tools
### Reading
| Tool | What it does |
| --- | --- |
| `list_chores` | List chores with filters for scope (all, overdue, due today, due this week, due within N days, unscheduled, archived), project, priority, label, assignee, status, a name search, sorting, and a result limit. |
| `get_chore` | Fetch one chore in full, by id or by name, including subtasks and last-completion detail. |
| `list_activity` | Recent chore completions across the circle, defaulting to the last 7 days and capped at 90. Days are calendar days in your timezone, the same unit `list_chores` uses. Returns `{activity, total, truncated}`, capped by `limit` (default 200, max 1000), the same shape and pattern `list_chores` uses for its own `{chores, total, truncated}`. |
| `list_members` | Circle members with their roles and point totals. |
| `list_projects` | Projects used to group chores. |
### Writing
| Tool | What it does |
| --- | --- |
| `create_chore` | Create a chore, with recurrence, due date, assignees, priority, points, subtasks, and notification settings. |
| `edit_chore` | Change any subset of a chore's fields. Everything not passed is preserved. |
| `delete_chore` | Permanently remove a chore and its history, after confirming with the user. Works on archived chores too. |
| `reschedule_chore` | Move a chore's due date, or clear it. |
| `reassign_chore` | Change who a chore is assigned to. |
| `set_priority` | Set or clear a chore's priority. |
| `archive_chore` | Take a chore out of active lists while keeping its history. |
| `unarchive_chore` | Put an archived chore back. |
### Acting
| Tool | What it does |
| --- | --- |
| `complete_chore` | Mark a chore done, optionally backdated or on someone else's behalf. Reports a chore that needs approval as pending rather than done. |
| `skip_chore` | Skip this occurrence and move to the next. Refuses a chore with a running or paused timer, which Donetick answers 200 to and then does nothing about, and a chore with a completion awaiting sign-off, which it would discard along with that person's points. |
| `undo_chore` | Reverse your own completion. Fails on any instance whose clock is behind UTC: see Known limitations. |
| `approve_chore` | Approve a completion that is waiting on sign-off. |
| `reject_chore` | Reject one. |
| `nudge_chore` | Remind whoever the chore is assigned to. |
| `set_subtask_completed` | Tick or untick one subtask. |
## Donetick's data model
Two conventions the tool descriptions above rely on without re-explaining.
Priority is stored as `0..4`, and this server treats it as inverted: P1 is the most urgent, P4 is the least, and `0` means no priority is set. Donetick's Go source encodes no ordering anywhere; nothing sorts, compares, or filters priority as a scale, only as a plain integer. The P1-most-urgent convention rests on the naming itself and on Donetick's own project README, which lists its five levels in that order, not on anything the wire enforces. This server always accepts and returns the label, never the bare number, so the convention only decides behavior in one place: `list_chores`'s `sort: priority`, which ranks P1 first and unset last.
Chore status has four values: no status, in progress, paused, and pending approval (`3`). A completion recorded at status `3` is a submission waiting on a circle admin's sign-off, not a finished chore. `complete_chore` reports one as `pending_approval` rather than `completed`, `skip_chore` refuses to touch one so it cannot discard someone else's pending points, and `approve_chore` / `reject_chore` are what act on it.
## Which Donetick API this uses, and why
This server calls Donetick's internal `/api/v1` routes rather than the documented `/eapi/v1` external API. The external API's create endpoint accepts a five-field `ChoreLiteReq` body and hardcodes `frequencyType` to `once`, so it has no way to create a recurring chore. The internal routes accept the same `secretkey` API token through Donetick's `MultiAuthMiddleware`, so no additional credential is needed.
The tradeoff is not that `/api/v1` has no documentation: a swaggo spec ships in `docs/swagger.json`, with `basePath` set to `/api/v1`, and its Swagger UI is mounted by default on a self-hosted instance. The tradeoff is that the spec is stale and wrong in ways that would have produced a server built against the wrong contract. It omits `includeSubtasks`, `syncVersion`, `actionOptions` and `draftId` entirely; it marks `isPrivate` required when Donetick accepts its absence; and it states a 0..5 bound on `PUT /chores`'s priority field while missing the 0..4 bound Donetick actually enforces on `PUT /chores/:id/priority`. It carries no behavioral semantics at all, so nothing in it says a completion window is measured in hours or that a concurrency token has to be sent back verbatim. Following it would have gotten the merge base, the concurrency envelope, and the priority contract wrong, which is why every wire fact here comes from the Go source and from measuring a pinned container instead. Donetick is also on a beta version line where these routes can change release to release. This is mitigated by keeping every path this server calls in one place, `src/endpoints.ts`, and by a verification script that checks all of them against a Donetick container pinned to a known version.
All 20 tools verified against Donetick `v0.1.76` (commit `d4eca08`), on MCP protocol revision
`2026-07-28`. Check your own instance with `curl -s https://your-host/health`, which
returns the version without needing a token. Note that unmatched paths return the
frontend HTML with a 200, so a wrong `DONETICK_URL` fails by returning a web page
rather than an error. The startup probe checks the response shape for this reason.
## Checking the API contract
This server targets Donetick's internal API, whose committed swagger spec is stale
and carries no behavioral semantics, so the unit tests prove the code is
self-consistent, not that Donetick still behaves as it was read. One command covers
the rest:
```bash
bun run verify:live
```
It needs Docker and nothing else. It starts a Donetick container pinned to the tag
in `compose.verify.yaml`, signs a throwaway user up over plain HTTP, mints that
user's API token, and exercises 35 contract facts against it. A clean run reports
36 passed: the last is a cleanup assertion, not a contract fact. Scratch chores and
things carry a run-scoped name prefix and are deleted in a `finally`, so a mid-run
failure leaves nothing behind. It exits non-zero if any check fails, and distinguishes
a warning (something changed but nothing is broken) from a failure.
It never talks to a running instance, and reads no credentials from the
environment: `DONETICK_URL` and `DONETICK_TOKEN` in a populated `.env` cannot point
it at a real account. It does read `DONETICK_TZ`, this server's own documented
variable, to set the throwaway container's timezone, so a `.env` that sets it does
change which container comes up, and can flip which direction of the `undo_chore`
check below actually runs. Checking a newer Donetick means pointing it at a newer
container:
```bash
DONETICK_IMAGE_TAG=v0.1.77 bun run verify:live
```
The container holds its database in its own writable layer, so nothing persists
and nothing is written into the working tree. `bun run verify:up` brings it up and
bootstraps it without running any check, which is worth doing once if several runs
follow; `bun run verify:down` destroys it. `verify:live` reuses a container that is
already up rather than replacing it, and does not tear one down when it finishes, so
run `verify:down` when you are done with it. CI runs the type check, the unit suite,
and `verify:live` on every push and pull request.
The container's timezone is `America/New_York` rather than UTC on purpose. One
check asserts that `undo_chore` fails if and only if the server stores timestamps
behind UTC; on a UTC container it would pass for the opposite reason and stop
guarding the diagnosis the tool reports. The other half of that conditional is
measurable rather than assumed:
```bash
DONETICK_TZ=UTC bun run verify:live
```
Measured on 2026-08-08 against v0.1.76, undo succeeds there and `created_at` comes
back as `...Z`, which is what makes the offset the cause rather than a correlate.
`DONETICK_IMAGE_TAG` and `DONETICK_TZ` are both part of the container's recorded
identity, so changing either replaces a running container instead of reusing it.
## Known limitations
**`delete_chore` needs a client that supports elicitation.** It asks the user to
confirm through the protocol's multi-round-trip flow rather than trusting an input
flag. Before it elicits, this server checks whether the connected client declared
that capability; when it has not, `delete_chore` returns a plain error result
saying nothing was deleted and naming `archive_chore`, instead of leaving the call
to fail as a raw protocol error. A client that declares the capability but has its
user decline the prompt gets the same plain "nothing was deleted" result. Every
other tool works regardless. Archiving takes a chore out of active lists while
keeping its history, and asks for no confirmation, but it is available only to the
chore's own creator, the same restriction delete has (see below).
These were verified against a live Donetick instance, not assumed from its source.
- **A chore whose recurrence is `trigger` cannot be edited here.** Donetick drops a chore's Thing association on every edit and restores it only when the request names the Thing by id, trigger state and condition. `GET /chores/:id` returns exactly those three fields on its `thingChore`, so this server could rebuild that request; it refuses instead because doing so needs an extra read on every whole-chore write and still could not survive a Thing owned by a different circle member, not because the data is unreachable. `edit_chore` and every other tool that rewrites a whole chore refuse outright when the stored recurrence is `trigger`. They do not catch the wider case: a chore with an ordinary recurrence, like `daily`, that also has a Thing attached is undetectable here, because Donetick's chore-list query never returns that association, only `GET /chores/:id` does, and this server's whole-chore writes read the list. Editing such a chore through this server severs its Thing silently.
- **A completion window requires a due date**, and so does an adaptive chore. Donetick reads the due date without checking whether it is there, so a chore with a completion window and no due date can never be completed, and an adaptive one with no due date can never be skipped. A window of `0` is not "off": it means no early completion is allowed, refusing a completion strictly before the due instant and allowing one at or after it, the strictest point on the scale rather than a broken one. There is still no way to express "no window at all" with a number, so omit the field on `create_chore` for no window; `edit_chore` takes `completion_window: null` to remove an existing one. A rolling chore needs no due date; Donetick schedules the first occurrence from the first completion.
- **Only reminder offsets produce a notification.** `notify`'s `due_date`, `completion`, `predue` and `nagging` flags are stored and never read, so `notify` without `reminders` sends nothing. Offsets are written to the wire negated, because Donetick adds the value to the due date rather than subtracting it; a positive one would schedule an overdue nag instead of a reminder. At most five per chore.
- **`undo_chore` fails on any instance whose timezone is behind UTC.** Donetick answers "no recent action found" immediately after both a completion and a skip, well inside its own five-minute window. Its handler accepts either action, so the refusal is not skip-specific. The cause is a string comparison: `created_at` is written in the server's own UTC offset while the cutoff is built in UTC, and SQLite compares the two as text, so on a server behind UTC the stored value always sorts earlier and nothing is ever recent enough. An instance running at UTC or ahead of it is unaffected. `verify:live` asserts the conditional directly, failing if undo starts working without the offset changing, or stops working when it has not; its container runs at `America/New_York` so that the check is exercised in the failing direction.
- **A time of day applies to three recurrence types only.** Donetick's scheduler reads `frequency.time` for `interval`, `days_of_the_week` and `day_of_the_month`, and ignores it for the other eight. For an hourly interval, the clock resets to that time in UTC before the interval is added, so the chore does not actually run every N hours: when the stored UTC hour plus N is under 24 it freezes on the same instant every completion; when it is 24 or more it instead advances by whole days, silently swapping the requested cadence for a daily or multi-day one. Either way the chore never runs at the configured interval, so this server refuses an hourly interval combined with a time of day at build time, along with a time of day on any of the other eight types; set the hour through `due_date` instead, which every type honors.
- **Labels cannot be set or changed through this server, and it cannot list every label in the circle.** `/api/v1/labels` requires JWT session auth an API token cannot provide, so this server can never enumerate a label that exists but is attached to nothing. That auth boundary is not what blocks editing, though: attaching a label to a chore travels through the chore body, which this server's token can already write. `create_chore` and `edit_chore` simply expose no labels field to set one through: create always sends an empty list, and edit carries a chore's existing labels through unchanged. Labels already attached to a chore are readable and filterable through `list_chores` and `get_chore`, and their ids ride on every list row.
- **Removing a chore is creator-only, in every form Donetick offers.** Delete, archive and unarchive all compare the chore's `CreatedBy` field directly and never check edit permission, so a circle admin can rename, reschedule, reassign, prioritize and complete a chore they did not create, but cannot delete it, archive it, or bring it back from archive. This server cannot warn before trying: nothing in the API it calls returns which member its own token belongs to.
- **The chore list and chore detail views are not supersets of each other.** `GET /chores/` returns rows that alone carry `assignStrategy`, `assignees`, `frequency`, `frequencyMetadata`, `isRolling`, `isPrivate`, `labelsV2`, `notification`, `notificationMetadata`, `points`, and `requireApproval`. `GET /chores/:id/details` alone carries `lastCompletedDate`, `lastCompletedBy`, `totalCompletedCount`, `notes`, `duration`, `startTime`, and `timerUpdatedAt`. `get_chore` fetches both and merges them, so last-completion data and every list-only field are present either way. `totalCompletedCount`, `notes`, `duration`, `startTime` and `timerUpdatedAt` are fetched too but not returned by any tool yet; they sit on the same detail object `get_chore` already holds, so surfacing them is a projection change, not another request. `list_chores` reads the row alone and cannot report last-completion data at all: rather than a `null` that would read as "never completed" for a chore finished minutes ago, it omits `last_completed_at` and `last_completed_by` and carries `last_completed_unknown` in their place.
- **Activity history rows carry no chore name**, only a `choreId`. `list_activity` joins each row against the current chore list to recover a name. Deleting a chore in Donetick also deletes its history, and the history query filters out rows whose chore is gone, so a genuinely deleted chore's completions never reach this join. The `(deleted)` label this server prints when the join misses is reachable a different way: a chore completed within the chore list's cache window (10 seconds by default) can appear in the history response before the cached list picks it up, and gets the same label though the chore still exists. Widen or disable `DONETICK_CACHE_TTL_MS` if this label ever shows up on a chore you know is not deleted.
- **The concurrency token is the row's own stamp, never a clock reading.** The endpoints that take an `updatedAt` compare it against the stored row and refuse anything older, and `PUT /:id/assignee` writes the value it receives back into the row, so a machine running ahead of the server would stamp a chore with a version its own skew invented. `concurrencyToken` in `src/chore-request.ts` sends the stored string verbatim, because it carries nanosecond precision a `Date` round trip truncates downward.
- **Recurring chores drift by an hour across a daylight-saving boundary, for every type except `days_of_the_week` and `yearly`.** Donetick reschedules by adding elapsed time in UTC rather than by the chore's local calendar day, so `daily`, `weekly`, `monthly`, and an `interval` measured in days, weeks or months, all drift. A 9am daily chore becomes an 8am one from the first completion after the autumn change. `days_of_the_week` holds its time of day across the transition, because it is the one type Donetick reschedules in the chore's own timezone rather than in UTC; `yearly` does not drift because the same date a year later is almost always in the same DST state. This server reports that drift accurately; it does not cause it.
- **A monthly or months-interval chore whose due day exceeds the length of a later month is normalised forward, and holds the new day from then on.** Donetick adds a month with Go's date arithmetic, which overflows a short month into the next one: the 31st of August becomes the 1st of October, the 31st of January becomes the 3rd of March. Where it lands depends on how far the day overflows, so the result is the 1st, 2nd or 3rd, not always the 1st, and once it lands there it stays. The break can be several completions away rather than immediate: a chore due the 30th runs cleanly for six months before February catches it, and a 29th survives a leap year's February untouched. This applies to `monthly` and to an `interval` measured in months. `day_of_the_month` clamps to the last day of the month instead of overflowing, and is the right choice for a fixed calendar day.
## AI Disclosure
Built with Claude Code. I directed and reviewed the architecture and data flow. I've skimmed the code to confirm that my rules and direction was properly enforced.
Donetick's /api/v1 has a swagger spec, but it is stale and carries no behavioral semantics, so the wire contracts were worked out by a fleet of Claude Code agents from the Go source and by measuring a pinned container. `bun run verify:live` is used for verifying the contracts are still accurate.
Expect that there could be edge cases that are handled wrong. There is a chance for data loss. For example, Donetick has no partial update so `edit_chore` rewrites the whole chore. Any field this server gets wrong it overwrites. There are guards and tests for that, but covering every permutation and possibility is not realistic.
Please keep backups. I auto snapshot the LXC my Donetick runs in just in case I need to revert.
## Development
See `CLAUDE.md` for the operating rules this codebase follows and the full command list.
```bash
bun run typecheck # tsc --noEmit
bun test --isolate # the unit suite: no network, no wall clock, no sleeps
bun run verify:live # the wire contract, against a disposable container
```
## License
Apache-2.0. See `LICENSE`.
This project is not affiliated with or endorsed by Donetick.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues