ms-to-do-mcp
README.md
# ms-to-do-mcp
An MCP server for **Microsoft To Do**, in two deployments sharing the same
eleven tools and the same Azure app registration:
- **Local Docker** (this README, Python) — streamable HTTP on
`http://127.0.0.1:8787/mcp`, unauthenticated, loopback only. Any local MCP
client — Claude Code, Codex, Claude Desktop — points at that one URL and
shares the same container and the same Microsoft sign-in.
- **Remote Cloudflare Worker** ([`worker/`](worker/README.md), TypeScript) —
for hosted clients like ChatGPT web that cannot reach `127.0.0.1`. Fronted by
OAuth 2.1 + PKCE, with Microsoft as the identity provider.
Both talk to Microsoft Graph as a **public client** against the `/consumers`
tenant, with the `Tasks.ReadWrite` scope. The local version signs in with the
device-code flow; the Worker with the auth-code flow.
## Tools
| Tool | |
|---|---|
| `list_task_lists` | every list, with its active-task count |
| `list_tasks` | tasks across one or all lists, sorted by due date; `include_completed`, `due_before`, `limit` |
| `get_task` | one task in full, including checklist steps |
| `search_tasks` | substring match over titles and notes, all lists |
| `list_checklist_items` | a task's steps |
| `create_task` | new task with due, reminder, notes, importance |
| `update_task` | change any field; empty string clears a due date or reminder |
| `complete_task` | mark done (recurring tasks roll forward) |
| `delete_task` | permanent — annotated `destructiveHint` |
| `create_task_list` | new list |
| `add_checklist_item` | add a step to a task |
Lists and tasks are addressed by **display name / title**, not by Graph's opaque
ids: exact match first, then a unique case-insensitive substring, and an error
naming the candidates when a reference is ambiguous. Raw ids are accepted too.
Dates use `YYYY-MM-DD` or `YYYY-MM-DD HH:MM` in `TODO_TIMEZONE`. To Do stores a
due date as a plain **date**, so a time passed in `due` is dropped by Microsoft —
use `reminder` for a time-of-day alert.
## Setup (local Docker)
```bash
colima start # or Docker Desktop — the daemon must be up
docker compose run --rm ms-todo auth # first run only — Microsoft sign-in (see below)
docker compose up -d
docker compose logs -f ms-todo
```
Everything else is in `compose.yaml`. The port is published as
`127.0.0.1:8787` on purpose: the MCP endpoint is **unauthenticated**, so anything
that can reach it can drive the To Do account. Never publish it as bare `8787`.
For the remote deployment, follow [`worker/README.md`](worker/README.md)
instead — Cloudflare login, two KV namespaces, an Azure redirect URI, and
`npx wrangler deploy`.
## The sign-in
The Microsoft token lives at `~/.ms-todo-mcp/token.json` (dir `700`, file `600`),
**outside this repo**, bind-mounted into the container at `/data`. The container
refreshes it in place and persists the rotated refresh token; never copy this file
elsewhere, since two copies race each other's rotation and both eventually break.
The same `auth` command handles both the first sign-in and the re-sign-in
Microsoft occasionally forces:
```bash
docker compose run --rm ms-todo auth
```
It prints a code to enter at `microsoft.com/devicelogin` on the host — no redirect
URI or in-container browser needed, which is what makes this containerise cleanly.
## Client registration
Claude Code:
```bash
claude mcp add --transport http --scope user ms-todo http://127.0.0.1:8787/mcp
```
Codex — in `~/.codex/config.toml`:
```toml
[mcp_servers.microsoft_todo]
url = "http://127.0.0.1:8787/mcp"
startup_timeout_sec = 20
tool_timeout_sec = 120
```
For the Worker, register `https://<worker>/mcp` instead — the client then runs
the OAuth flow on first use (details in [`worker/README.md`](worker/README.md)).
## Checking it works
```bash
# auth + Graph reachability, independent of MCP
docker compose run --rm --entrypoint python ms-todo -c \
"from ms_todo_mcp.graph import get_all; print(len(get_all('me/todo/lists')), 'lists')"
# MCP handshake
curl -sS http://127.0.0.1:8787/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
A `404` means `streamable_http_path` is wrong; a `421` means the `MCP_ALLOWED_HOSTS`
allowlist (DNS-rebinding protection) doesn't cover the Host header you sent.
## Layout
```
src/ms_todo_mcp/ the local Docker server (Python)
├── graph.py token lifecycle + Graph HTTP (stdlib urllib only, no deps)
├── auth.py device-code sign-in
├── server.py MCPServer + the 11 tools
└── __main__.py `serve` (default) | `auth`
worker/ the remote Cloudflare Worker (TypeScript) — see worker/README.md
```
Built against **mcp 2.x** (`MCPServer`, not the removed 1.x `FastMCP`).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues