Imported from Doist/todoist-mcp (
AGENTS.md). Install upstream withnpx skills add Doist/todoist-mcp. Copyright stays with the author.
Todoist MCP Server - Development Guidelines
Tool Schema Design Rules
Removing/Clearing Optional Fields
When you need to support clearing an optional field:
-
Use a special string value (not
null- avoids LLM provider compatibility issues, with Gemini in particular)- For assignments: use
"unassign" - For other fields: use
"remove"or similar descriptive string
- For assignments: use
-
Handle both legacy and new patterns in runtime logic for backward compatibility:
if (fieldValue === null || fieldValue === 'remove') { // Convert to null for API call updateArgs = { ...updateArgs, fieldName: null } } -
Update schema description to document the special string value
Examples from Codebase
- PR #181: Fixed
responsibleUserfield - changed from.nullable()to using"unassign"string - Latest commit: Fixed
deadlineDatefield - changed from.nullable()to using"remove"string
Why This Matters
- Ensures compatibility with all LLM providers (OpenAI, Anthropic, Gemini, etc.)
- Maintains backward compatibility through dual handling
- Creates self-documenting APIs with explicit action strings
Describing output schema fields
Every tool's outputSchema is sent to clients on every tools/list, and the shared schemas in src/utils/output-schemas.ts are inlined once per tool that uses them — so a description on TaskSchema.id is paid eight times over.
Describe an output field only where the schema cannot express the thing itself:
- units or formats —
'ISO 8601.','Bytes.','e.g. "2h30m".' - conventions that are not derivable —
priority: 'p1 is highest, p4 lowest.' - unions —
recurring: 'False when not recurring, otherwise the recurrence string.' - conditional presence —
workspaceId: 'Undefined for personal projects.' - non-obvious meaning —
isUncompletable: 'An organizational header, not a real task.'
Leave it off when the description would restate the field name (id, projectId, name), or list values an enum already carries. Note this is the output side only: input field descriptions are how a model learns to call the tool correctly and should stay as full as they need to be.
Adding a New Tool
src/tool-registry.ts is the single source of truth for the tool surface. src/mcp-server.ts, the tools export in src/index.ts, scripts/run-tool.ts, scripts/validate-schemas.ts and src/token-footprint.test.ts all derive from it, so adding a tool there wires it up everywhere.
src/utils/tool-names.ts— add the tool name constantsrc/tools/<tool-name>.ts— create the tool definitionsrc/tool-registry.ts— add it toregisteredTools, in the section it belongs tosrc/index.ts— add it to thetoolsobject and the named exports (public API)src/tools/<tool-name>.test.ts— create the test filesrc/tools/tool-annotations.test.ts— add the annotation expectation entrysrc/mcp-server.ts— add to theinstructionsstring only if the tool needs cross-tool routing guidance; per-tool detail belongs in the tool's own description
src/tool-registry.test.ts enforces that steps 1, 3 and 4 agree with what the server actually registers, so a partial registration fails CI instead of silently degrading lint:schemas coverage or leaving the tool unreachable from run-tool.ts.
If a new tool pushes the combined fixed cost over TOKEN_BUDGET in src/token-footprint.test.ts, raise the budget in the same PR and call it out in the description.
Testing Requirements
When adding new tool parameters:
- Add comprehensive test coverage for new fields
- Test setting values
- Test clearing values (if applicable)
- Verify build and type checking pass
- Run full test suite (all 333+ tests must pass)
Documentation Requirements
When adding new tool features:
- Update tool schema descriptions in the source file
- Update
src/mcp-server.tstool usage guidelines - Add tests demonstrating the feature
- Include examples in descriptions where helpful
Running Tools Directly
Use scripts/run-tool.ts to execute any tool without the MCP server:
npx tsx scripts/run-tool.ts <tool-name> '<json-args>'
npx tsx scripts/run-tool.ts --list # list all tools
Examples:
npx tsx scripts/run-tool.ts add-tasks '{"tasks":[{"content":"Test task"}]}'
npx tsx scripts/run-tool.ts find-tasks '{"searchText":"meeting"}'
npx tsx scripts/run-tool.ts get-overview '{}'
Requires TODOIST_API_KEY in .env (and optionally TODOIST_BASE_URL).
Measuring a change to the tool surface
src/token-footprint.test.ts tells you what the surface costs. It cannot tell you whether a model can still use it. When you change tool descriptions, input field descriptions, or the instructions block, measure the behaviour rather than reasoning about it:
npm run eval -- --label before
# apply your change
npm run eval -- --label after
Each run prints a pass rate per scenario per model and writes tmp/eval/<label>.json; diff the two. Narrow while iterating with --scenario <id>, --repeats N, --models a,b.
Nothing is executed against Todoist, so it touches no Todoist data and needs no TODOIST_API_KEY. It does call real models, so it is deliberately not part of npm test — it costs money and is non-deterministic.
Each attempt is judged on one call. Usually that is the first, with two exceptions, both there because a model doing the right thing was being scored as a failure:
- Context tools (
CONTEXT_TOOLS, currently justuser-info) are answered with a canned result and judging moves to the next call. A model cannot turn "last week" into a date range without knowing today's date and the user's timezone, so asking first is correct — but anexpectallowlist naming the tool under test cannot also name every reasonable lookup that precedes it. - A turn carrying several calls is judged on the first one that is not a context tool, since the order between "ask for the date" and "query the log" is arbitrary. A forbidden call outranks that, being the whole point of a forbid rule.
Two things this has already caught that review and reading did not:
- Trimming the instructions block turned out to fix a destructive bug, not merely be safe: asked to delete a workspace project, Haiku 4.5 called
delete-objectdirectly under the longer instructions and archived first under the shorter ones (0/10 vs 10/10). - A wording change made while addressing review feedback appeared to regress a scenario by 30 points. It was an artefact of the scenario prompt, not the change — but nothing else would have surfaced the question.
Authenticating
The harness uses the Anthropic SDK's standard credential chain, so either of these works:
-
ANTHROPIC_API_KEYin your environment — a key from the Anthropic Console. Create it in a workspace you can put a spend limit on. -
An OAuth profile, which avoids managing a key at all.
antis Anthropic's CLI (anthropics/anthropic-cli);ant auth loginopens a browser and stores a profile under~/.config/anthropic/that the SDK picks up automatically.brew install anthropics/tap/ant # macOS go install github.com/anthropics/anthropic-cli/cmd/ant@latest # or from source (Go 1.22+) # Linux/WSL: grab the tarball for your arch from the releases page above ant auth loginOn a machine with no browser,
ant auth login --no-browserprints a URL and takes the code back in the terminal.
Note the two do not compose: a set ANTHROPIC_API_KEY silently overrides any OAuth profile, so requests go to whichever org that key belongs to. ant auth status shows which credential source won.
Adding a scenario
Scenarios live at the top of scripts/eval-instructions.ts. Two shapes, and picking the wrong one produces noise that looks like signal:
expect— an allowlist, for a rule that names the tool to reach for.forbid— for a rule of the form "don't do X". Prefer this whenever the rule is prohibitive. An allowlist then has to enumerate every legitimate opener, and it will miss some: a model looking a project up withfetch-objectbefore deleting it is behaving correctly, and an allowlist that forgotfetch-objectscores it as a failure.
Make sure a scenario can actually fail. One early scenario listed the destructive tool in its own expect and checked an argument condition that was true by construction — it scored 100% while measuring nothing.
The opposite mistake is just as expensive: completed-via-activity reported 0% on Sonnet 5 for months while the model was answering it perfectly, because it opened with user-info and the allowlist only named find-activity. If a scenario sits at 0% on a capable model, check what it is actually calling before rewriting any tool description — a failure with a plausible-looking first call is worth a moment's suspicion. Adding the lookup to expect is not the fix; the judge would then pass on the lookup alone and check nothing. Add it to CONTEXT_TOOLS with a stub result instead.