Skip to main content
Glama
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.