AI assistants (MCP)
OpenTodo can let an AI assistant read your projects and tasks through the Model Context Protocol (MCP), so you can ask it "what is on my plate today?" or "summarise the Garden project". The endpoint is:
On this page
- Off by default. The server owner turns it on; until then
/mcpanswers 404. - Read-only. There are no write tools. An assistant cannot create, complete, edit or delete anything.
- Yours only. Each request acts as the account whose API token it carries and sees exactly what that account sees in the app: its personal projects and tasks plus the shared projects it is a member of. Deleted items are never returned.
- Passive. The assistant connects to your server. OpenTodo never contacts a model provider, a registry or anything else on its own.
1. Enable it (owner)#
Settings → AI assistants → Allow AI assistants (MCP endpoint). The change applies at once, without a restart, and is recorded in the audit log as settings.changed (mcp.enabled).
For deployments managed as code, the optional OPENTODO_MCP=true presets the toggle on the first start. After that the value in Settings wins, so the owner can still turn it off. Members see the status in the same section but no toggle.
2. Create a read-only token#
In Settings → AI assistants, choose Create read-only token, or in Settings → API tokens pick the Read-only scope. Copy the token: it is shown once. A read-only token is refused on every route that writes (see API → Authentication), so even if it leaks from an assistant's configuration it cannot change your data. Revoke it in Settings → API tokens at any time.
Full-access tokens also work with /mcp, but there is no reason to give one to an assistant.
3. Connect an assistant#
Use the endpoint URL shown in Settings (https://todo.example.com/mcp) as a remote MCP server with the streamable HTTP transport, and send the token as a header:
Authorization: Bearer ot_…
Clients that take a JSON server entry (Settings has a copy button that fills in the URL and your new token):
{
"mcpServers": {
"opentodo": {
"type": "http",
"url": "https://todo.example.com/mcp",
"headers": { "Authorization": "Bearer ot_…" }
}
}
}
Menus differ between assistants; look for "add custom connector", "remote MCP server" or "HTTP server". Clients that only speak stdio need a bridge that forwards to an HTTP endpoint with a header; OpenTodo does not ship one yet.
To try the endpoint by hand:
curl -sS https://todo.example.com/mcp \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"today_summary","arguments":{"timezone":"Europe/Rome"}}}'
Time zones. Due dates in OpenTodo are floating calendar dates ("2026-03-11", optionally with a time) unless they name a zone. What counts as "today" or "overdue" therefore depends on where you are: every date-dependent tool takes a timezone (an IANA name such as Europe/Rome) and uses UTC when none is given. Tell your assistant your time zone, or ask it to pass it. Every result also carries server_time (UTC), so the assistant can say how fresh the data is.
Offline edits. The server only knows what your devices have synced. A task added on a phone in airplane mode appears once the phone syncs.
Tools#
All tools are annotated readOnlyHint: true, destructiveHint: false, openWorldHint: false. Results come back both as structuredContent and as the same JSON in a text block. Timestamps in results are ISO 8601 in UTC; priorities are 1 (highest) to 4 (default). Lists hold at most 200 items; when there are more, the result has a next_cursor to pass back as cursor.
| Tool | Arguments | Result |
|---|---|---|
list_projects |
include_archived (default false) |
{projects:[{id,name,color,archived,parent_id?,shared,role?,open_count}], inbox_open_count, server_time} |
list_tasks |
view (all default, inbox, today, upcoming), project_id, label (name or id), priority (1–4), due_from, due_to (YYYY-MM-DD, inclusive), include_completed (default false), timezone, limit (≤200), cursor |
{tasks:[…], next_cursor, timezone, today, server_time} |
get_task |
id (required), timezone |
{task, subtasks:[…], comments:[{id,parent_id?,author:{id,name},body,created_at,edited_at?}], timezone, today, server_time} |
search_tasks |
query (required), include_completed, timezone, limit, cursor |
like list_tasks; matches title or description, case-insensitive |
today_summary |
timezone |
{overdue:[…], due_today:[…], counts:{overdue,due_today,inbox,per_project:[{id,name,open}]}, timezone, today, server_time} |
today lists open tasks due today or earlier, overdue first; upcoming lists open tasks due after today. Tasks are ordered by due date and time (undated last), then priority. Tasks in archived projects are left out unless project_id names that project.
A task in a result looks like this (description, the note as Markdown text, only in get_task and search_tasks; the opaque rich-text document notes_doc is never returned):
{
"id": "0b6f…", "title": "Water the tomatoes", "description": "…",
"project_id": "e34e…", "project": "Garden", "section_id": "…", "section": "Beds",
"parent_id": null, "priority": 2,
"due": { "date": "2026-03-11", "time": "01:00", "tz": "Asia/Tokyo", "local_date": "2026-03-10", "local_time": "12:00" },
"overdue": true, "labels": ["outside"], "assignee": { "id": "…", "name": "Sam" },
"recurrence": "every week", "estimate_minutes": 30,
"completed": false, "created_at": "2026-03-01T08:00:00Z"
}
local_date and local_time appear for due dates pinned to a time zone and give the wall time in the requested timezone. Optional fields are left out when empty; project is "Inbox" for tasks without a project.
Errors: a missing or malformed argument (for example priority: 7) is a JSON-RPC -32602 error whose message names the argument. get_task for an id that does not exist, is deleted or belongs to someone else returns a result with isError: true and the text task not found; the three cases are indistinguishable on purpose.
Resources#
| URI | Content |
|---|---|
opentodo://projects |
same as list_projects |
opentodo://views/inbox |
open tasks without a project |
opentodo://views/today |
as list_tasks with view: today |
opentodo://views/upcoming |
as list_tasks with view: upcoming |
opentodo://projects/{id} |
the project, its sections and its open tasks |
opentodo://tasks/{id} |
same as get_task |
Contents are application/json. Views use UTC; append ?tz=Europe/Rome to the URI for another zone. A resource holds at most 200 tasks and says "truncated": true when there were more (use list_tasks to page). Reading a deleted or foreign project or task is a -32002 "resource not found" error, the same for both.
End-to-end encryption#
The server holds no key. For an account with end-to-end encryption, encrypted titles, descriptions and project, label and section names are reported as "Encrypted item" with "encrypted": true on the task; search_tasks never matches encrypted text. Due dates, priorities, completion and structure are still reported because they are stored in plaintext.
Protocol details#
- JSON-RPC 2.0 over
POST /mcp(streamable HTTP, stateless). Supported protocol versions:2025-11-25,2025-06-18and2025-03-26;initializeechoes the client's version when supported and answers the newest otherwise. Capabilities:toolsandresources; no prompts, sampling, subscriptions or list-change notifications. - Every request gets one
application/jsonresponse; notifications get202 Acceptedwith no body. The server opens no event streams:GET /mcpis 405, and anAcceptheader that admits onlytext/event-streamis 406. NoMcp-Session-Idis issued. AnMCP-Protocol-Versionheader naming an unsupported version is 400. Batches (JSON arrays) are accepted. - Methods:
initialize,ping,tools/list,tools/call,resources/list,resources/templates/list,resources/read. Anything else is-32601; malformed JSON is-32700(HTTP 400).
Limits and security#
- Authentication:
Authorization: Bearer <API token>only. The session cookie is ignored on/mcp, so a web page you visit cannot use your browser session to read your tasks. A missing, unknown or revoked token, or the token of a deactivated account, is 401 withWWW-Authenticate: Bearer realm="OpenTodo MCP". Failed attempts count toward the per-address sign-in limit (10 per minute), after which requests get 429. - Origin: a request that carries an
Originheader other than this server's own (OPENTODO_BASE_URL, or the request's host) is refused with 403bad_origin, as the MCP transport requires against DNS rebinding. Desktop and command-line clients send noOrigin. - Size: request bodies over 1 MB are refused with 413; responses hold at most 200 items per list.
- Logging: tool calls are logged at debug level with the JSON-RPC method and duration only, never with arguments or results.
- Network: the MCP server makes no outbound requests. A reverse proxy in front of OpenTodo should not log
Authorizationheaders. - Disable: turn the toggle off in Settings (immediate), or revoke the assistant's token.