toodledo-mcp
by iamrestrepo
README.md
# toodledo-mcp
A small, open-source **remote MCP server** that connects Claude (claude.ai web, desktop, mobile and scheduled tasks) to your **Toodledo** account through the official [Toodledo API v3](https://api.toodledo.com/3/).
- Runs on **Cloudflare Workers** (free tier is plenty for one person).
- **OAuth on both sides**: Claude → this server (OAuth 2.1 + PKCE, dynamic client registration), and this server → Toodledo (OAuth2 authorization code). No authless mode.
- Toodledo tokens are **encrypted at rest** (AES-256-GCM) in a per-user Durable Object that **serialises refreshes**, so Toodledo's single-use refresh tokens are never used twice.
- Human-friendly I/O: `YYYY-MM-DD` dates, `HH:MM` times, and folder/context/goal **names** instead of numeric IDs.
- Five small runtime dependencies (the Cloudflare `agents` MCP helpers need both MCP SDK packages), no UI framework. MIT licensed.
You deploy your own instance and connect your own Toodledo account. Nothing is shared with the author.
---
## Architecture
```
┌──────────────┐ OAuth 2.1 (PKCE, DCR) ┌───────────────────────── Cloudflare Worker ─────────────────────────┐
│ claude.ai │ ───────────────────────► │ OAuthProvider (@cloudflare/workers-oauth-provider) │
│ web/desktop/ │ Bearer token │ /authorize /token /register ── grants & tokens ──► KV (OAUTH_KV) │
│ mobile/sched │ ───────────────────────► │ /mcp ──► ToodledoMCP (McpAgent Durable Object, Streamable HTTP) │
└──────────────┘ │ │ tools (zod) ─► ToodledoClient ── retries/backoff ──┐ │
│ │ name lookups cached 5 min │ │
browser (once) ──► consent page ──►│ /callback ◄─┘ │ │
│ │ │ exchange code │ │
▼ │ ▼ │ │
┌────────────────────┐ OAuth2 code │ ToodledoTokenStore (Durable Object, one per Toodledo user) │ │
│ Toodledo sign-in │ ◄────────────────│ AES-GCM encrypted {access, refresh, expiry, call count} │ │
└────────────────────┘ │ single in-flight refresh · persists rotated refresh token first │ │
└────────────────────────────────────────────────────────────────────┼─┘
▼
https://api.toodledo.com/3/*
```
```
src/
core/ platform-agnostic (no Cloudflare imports)
dates.ts ISO <-> Toodledo timestamps (GMT-noon dates, floating times)
token-manager.ts refresh/rotation/serialisation/encryption logic
toodledo-oauth.ts authorize URL + token endpoint
toodledo-client.ts API calls, batching (50), pagination (1000), retry/backoff
lookups.ts folder/context/goal/location cache + name resolution
schemas.ts zod input schemas
format.ts raw task <-> tool I/O
tools.ts MCP tool definitions
worker/ Cloudflare glue
auth-handler.ts /authorize, /callback, consent page
token-store.ts ToodledoTokenStore Durable Object
mcp-agent.ts ToodledoMCP (McpAgent)
index.ts Worker entry (OAuthProvider)
test/ vitest unit tests (Toodledo is mocked)
scripts/smoke-test.mjs end-to-end test against a deployed instance
```
The core only uses `fetch` and WebCrypto, so it can be reused on another runtime by swapping `src/worker/`.
---
## Self-hosting (≈30 minutes)
### Prerequisites
- Node.js 20+ and npm
- A free [Cloudflare account](https://dash.cloudflare.com/sign-up)
- A Toodledo account
- A Claude plan that supports custom connectors
### 1. Get the code
```bash
git clone https://github.com/<you>/toodledo-mcp.git
cd toodledo-mcp
npm install
npm run check # typecheck + lint + tests
```
### 2. Log in to Cloudflare and create the KV namespace
```bash
npx wrangler login
npx wrangler kv namespace create OAUTH_KV
```
Copy the printed `id` into `wrangler.toml` (replace `REPLACE_WITH_YOUR_KV_NAMESPACE_ID`). The KV id is not a secret.
Durable Objects need no manual setup: the `[[migrations]]` block in `wrangler.toml` creates them on first deploy (SQLite-backed, available on the free plan).
### 3. First deploy (to learn your URL)
```bash
npx wrangler deploy
```
Note the URL, e.g. `https://toodledo-mcp.<your-subdomain>.workers.dev`. Visiting it shows a small "toodledo-mcp is running" page. It can't authorize anyone yet because the secrets aren't set.
### 4. Register a Toodledo app
1. Sign in to Toodledo, then open <https://api.toodledo.com/3/account/doc_register.php>.
2. Create a new application:
- **Redirect URI**: `https://toodledo-mcp.<your-subdomain>.workers.dev/callback` (it must match exactly)
- Name/description/website: anything you like.
3. Keep the **Client ID** and **Client Secret** handy for the next step. Don't paste them anywhere else.
### 5. Set the secrets
Every secret goes in through Wrangler's interactive prompt or a pipe, so it never lands in a file or your shell history:
```bash
npx wrangler secret put TOODLEDO_CLIENT_ID # paste when prompted
npx wrangler secret put TOODLEDO_CLIENT_SECRET # paste when prompted
openssl rand -base64 32 | npx wrangler secret put TOKEN_ENCRYPTION_KEY
openssl rand -base64 32 | npx wrangler secret put COOKIE_ENCRYPTION_KEY
```
Optional: restrict the instance to your own Toodledo account (recommended). Leave this unset for the first connection. Afterwards, `toodledo_get_account` shows your `userid`:
```bash
npx wrangler secret put ALLOWED_TOODLEDO_USERIDS # e.g. "td1234567890abc"
```
> ⚠️ Don't rotate `TOKEN_ENCRYPTION_KEY` casually. Stored Toodledo tokens become unreadable and you'll have to reconnect.
### 6. Smoke test (optional, recommended)
```bash
npm run smoke -- https://toodledo-mcp.<your-subdomain>.workers.dev
```
This opens your browser for consent and Toodledo sign-in, then runs `list_folders`, `list_tasks`, `add_tasks`, `edit_tasks`, `get_task`, `complete_tasks` and `delete_tasks` on a throwaway task titled `[toodledo-mcp smoke test] …`. Add `--keep` to leave the task in Toodledo. See [Verification](#verification) for MCP Inspector instructions.
### 7. Add the connector in Claude
1. In claude.ai (web or desktop): **Settings → Connectors → Add custom connector**.
2. Name: `Toodledo`. URL: `https://toodledo-mcp.<your-subdomain>.workers.dev/mcp`.
3. Click **Add**, then **Connect**. Approve the consent page, then sign in to Toodledo and allow access. You only do this once.
4. In a chat, turn the connector on from the tools menu if it isn't already. The connector syncs to Claude on iPhone/Android and works in scheduled tasks.
Try: *"List my open Toodledo tasks due this week, grouped by folder."*
### Local development
```bash
cp .dev.vars.example .dev.vars # fill in values (never commit .dev.vars)
npm run dev # http://localhost:8787
```
For a local Toodledo login you'd need a second Toodledo app whose redirect URI is `http://localhost:8787/callback`.
---
## Tool reference
All dates are `YYYY-MM-DD` and all times are `HH:MM` (24h). Folder, context, goal and location inputs accept a **name** (case-insensitive) or an **id**. `0` or `"none"` clears them. Outputs include `{ id, name }` objects.
| Tool | Kind | What it does |
|---|---|---|
| `toodledo_get_account` | read | Alias, plan (subtasks need a paid plan), date format, last-edit timestamps |
| `toodledo_list_tasks` | read | Filters: `completed` (`false` default / `true` / `"any"`), `modified_after`, `folder`, `context`, `goal`, `location`, `due_before`, `due_after` (inclusive), `star`, `priority`, `min_priority`, `status` (one or list), `search` (title/tags/note); `sort` (`due`, `priority`, `modified`, `title`); `fields`; `limit`/`offset`; `max_scan` |
| `toodledo_get_task` | read | One task by id, all fields |
| `toodledo_add_tasks` | write | Batch (≤200): `title`, `note`, `folder`, `context`, `goal`, `location`, `duedate`, `duetime`, `duedatemod`, `startdate`, `starttime`, `priority`, `status`, `star`, `tag` (string or list), `repeat` (RRULE), `length` (min), `remind`, `parent` |
| `toodledo_edit_tasks` | write | Batch partial update by `id`. `null` clears dates/times. `completed: "YYYY-MM-DD"` or `null` (reopen) |
| `toodledo_complete_tasks` | write | Batch complete. Repeating tasks roll to the next occurrence |
| `toodledo_delete_tasks` | **destructive** | Batch **permanent** delete. Requires `confirm: true` |
| `toodledo_list_folders` | read | `include_archived` |
| `toodledo_list_contexts` | read | |
| `toodledo_list_goals` | read | Level (`lifetime`/`long_term`/`short_term`), `contributes_to` relation |
| `toodledo_list_locations` | read | |
| `toodledo_add_folder` / `toodledo_add_context` | write | Create by name |
| `toodledo_add_goal` | write | `name`, `level`, `contributes_to`, `note` |
Enum values: **priority** `negative, low, medium, high, top`. **status** `none, next_action, active, planning, delegated, waiting, hold, postponed, someday, canceled, reference`. **duedatemod** `due_by, due_on, due_after, optionally`.
Read tools have `readOnlyHint: true`. Delete has `destructiveHint: true`.
### Toodledo date conventions (how conversion works)
- **Dates** (`duedate`, `startdate`, `completed`, `added`) are stored by Toodledo as a unix timestamp at **12:00 GMT** of that day. Noon keeps the calendar day the same in nearly every timezone. This server converts `2026-11-08` ⇄ `1794139200` and ignores the time part on the way back.
- **Times** (`duetime`, `starttime`) are **floating**: the GMT clock time *is* the time you chose (`18:30` stays `18:30` anywhere). When you change only the time on an existing task, the server reads the task's current date first so the time stays on the right day.
- **`modified`** is a real instant, returned as ISO 8601 UTC.
- `toodledo_complete_tasks` defaults to **today in UTC**. Claude is told to pass your local date when it knows it.
---
## Security notes
- **No authless mode.** `/mcp` requires an OAuth access token issued by this server. Tokens are issued only after a person approves the consent page and signs in to Toodledo.
- **Consent page and CSRF.** Dynamic client registration is open, as MCP clients expect. The consent page shows the client name and redirect host, so an unknown app can't silently get a token. The flow uses a signed CSRF cookie, and the Toodledo `state` is bound to the browser with a signed cookie.
- **Restrict accounts.** Set `ALLOWED_TOODLEDO_USERIDS` so only your Toodledo account can use your instance and your Workers quota.
- **Encryption at rest.** Toodledo access and refresh tokens are encrypted with AES-256-GCM using `TOKEN_ENCRYPTION_KEY`. The ciphertext is bound to the user id, so blobs can't be swapped between users. Claude-side OAuth grants are handled by `workers-oauth-provider`, which stores only hashes of its tokens.
- **Secrets** only live in Cloudflare (`wrangler secret put`). `.dev.vars` is git-ignored. CI uses no secrets.
- **Scopes.** The server requests the Toodledo scopes `basic tasks write`, and nothing for notes, outlines, lists or sharing.
- **Revoking access.** Disconnect the connector in Claude, revoke the app in Toodledo's account settings, or delete the worker.
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
## Token lifecycle (why it survives > 2 hours)
| Toodledo rule | How it's handled |
|---|---|
| Access token lasts 2h | Refreshed automatically 5 minutes before expiry |
| ~100 API calls per access token | Calls are counted per token. A refresh happens at 90, or on a rate-limit error after heavy use |
| Refresh token is **single-use** | Every refresh goes through one Durable Object per user with a single in-flight promise. The new refresh token is persisted before the access token is handed out |
| Refresh token dies after 30 days unused | Tools return: *"Toodledo authorization has expired… Reconnect the Toodledo connector in Claude…"* |
| ~10 token requests per hour | If the token endpoint is rate limited, the current access token keeps being used while it's still valid |
| Errors 1/2 (invalid token) | One forced refresh and retry, then a re-authorize error |
| Error 3 / HTTP 429, 5xx, network | Exponential backoff (0.5s, 1s, 2s) |
Batch writes go to Toodledo in chunks of **50** (the API maximum). Task listing reads pages of **1000**.
## Limitations
- **Filtering is client-side** for everything except completion and `modified_after`, because Toodledo's `tasks/get` has no folder or due-date filters. `max_scan` (default 2000, max 5000) caps how many tasks are read per call, and each 1000 tasks costs one API call.
- If you don't use the connector for **30 days**, the Toodledo refresh token expires and you'll need to reconnect once.
- Subtasks (`parent`) need a paid Toodledo plan.
- Not in v1: notes, outlines, lists, habits, collaboration/sharing, webhooks, editing or deleting folders, contexts and goals.
- "Today" defaults to UTC when completing tasks without a `date`.
---
## Verification
**Option A: script.** `npm run smoke -- <your worker URL>` (see step 6).
**Option B: MCP Inspector.**
```bash
npx @modelcontextprotocol/inspector
```
1. Transport **Streamable HTTP**, URL `https://toodledo-mcp.<your-subdomain>.workers.dev/mcp`.
2. Open **Auth settings → Quick OAuth flow** (or **Connect**) and complete the consent page and Toodledo sign-in.
3. **Tools → List tools**, then run:
- `toodledo_list_folders` with `{}`
- `toodledo_list_tasks` with `{"limit": 5, "sort": "due"}`
- `toodledo_add_tasks` with `{"tasks":[{"title":"Inspector test","duedate":"2026-11-15","priority":"high"}]}` (note the returned `id`)
- `toodledo_edit_tasks` with `{"tasks":[{"id": <id>, "title":"Inspector test (edited)","duetime":"18:30"}]}`
- `toodledo_complete_tasks` with `{"ids":[<id>]}`
**Token refresh > 2h.** Make one call, wait more than 2 hours, then make another. It should succeed with no re-login. To watch rotations, run `npx wrangler tail` (tokens are never logged).
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). `npm run check` must pass.
## License
[MIT](LICENSE). Not affiliated with Toodledo or Anthropic.