@doist/automations-mcp
The MCP server for Todoist Automations: the tools that give an agent the same access to a user's
automations that the tda CLI gives, packaged for the hosted MCP server at ai.todoist.net. The
design and the rollout it is part of are in
the spec.
What it exports
getMcpServer({ token, baseUrl }) builds an McpServer over one user's Automations, bound to the
bearer token it is given: a delegated Todoist OAuth token with the automations audience, or a
Todoist personal token.
validateAutomationsToken(token, baseUrl) classifies a token before any tool runs, with one
status-only call to the automations list: valid, invalid (401) or forbidden (403 with an
insufficient_scope challenge). A 403 without that challenge counts as valid on purpose: it is a
refusal consent cannot lift, such as the waiting list, so each tool surfaces the backend's own
message instead of the client looping through OAuth. Any other status throws
TokenValidationError from @doist/mcp-core/host with the status on it, which the hosted server
treats as transient for 5xx. Both functions follow the contract every Doist MCP server shares, from
@doist/mcp-core/host.
That is the whole published surface. The tools themselves are typed against
@doist/automations-sdk, which is not published, so they are reached through the server rather than
exported, and the build refuses to emit a declaration that would leave a consumer with an import it
cannot resolve.
Chat turns
start-conversation and continue-conversation each run one durable server turn inside the tool
call, the way tda chat does in a process: the call blocks until the agent stops, which can take
minutes, and reports liveness every ten seconds through notifications/progress when the client
sent a progress token, or a debug log message otherwise. Cancelling the call aborts the upstream
turn; the partial reply is persisted and continue-conversation resumes it. The shared logic is
@doist/automations-sdk/chat-turn, so the CLI and this package cannot drift on how a pending
action is read or a connection resolved.
Draft tests
test-draft starts a test run and, unless waitMinutes is 0, polls it until it ends or the wait
runs out, reporting progress every ten seconds the same way. A run still going at the limit keeps
running and get-test-run reads it later; a cancelled or failed wait names the run for the same
reason. The start, the polling and the outcome mapping are the SDK's startDraftTest and
waitForDraftTest, shared with tda draft test.
Running it locally
Everything here can run against a real account without the hosted server. AUTOMATIONS_API_KEY is
a Todoist personal token or the output of tda auth token view; AUTOMATIONS_BASE_URL points at
staging or a local backend and defaults to production.
npm run build:core # once, from the monorepo root
npm run build:mcp # produces apps/mcp/dist
npm --workspace=apps/mcp run inspect # the MCP inspector over stdio
npm --workspace=apps/mcp run tool -- find-automations '{"limit": 5}'
npm --workspace=apps/mcp run tool -- start-conversation '{"message": "Draft an automation that..."}'
npm --workspace=apps/mcp run tool:list
An MCP client can point at the stdio build directly, with the same environment:
{
"mcpServers": {
"automations": {
"command": "node",
"args": ["/path/to/automations/apps/mcp/dist/main.js"],
"env": { "AUTOMATIONS_API_KEY": "..." }
}
}
}
Releases
@doist/automations-mcp is published from main by
.github/workflows/release-mcp.yml, which runs
semantic-release with the configuration in release.config.js. The version
comes from the conventional-commit messages of the commits that count towards this package, so
nothing is bumped by hand and the repository holds no version number of its own. The same commits
make up the release notes and the Comms announcement. Releases go to the latest dist tag:
the consumer is the hosted server, which pins an exact version. After publishing, the workflow sends
an automations-mcp-updated dispatch to Doist/todoist-ai-integrations, whose update workflow
installs the version, runs its checks, commits and redeploys, so a release reaches ai.todoist.net
without waiting for a dependency PR.
The workspace libraries the package uses are not published, so npm run build compiles them into
dist/ and only the packages under dependencies stay external. Any change here counts, except
tests, docs and package.json fields that don't change what gets installed. packages/sdk,
packages/core and packages/bitmapist are shared with the backend and the web app, so a change
confined to them counts only when its commit is scoped mcp or sdk. Any other change there still
ships, with the next release. See
Published packages for how to split
pull requests so each change lands in the right release.
Adding a tool
src/tool-registry.ts is the single source of truth for the tool surface; the server, run-tool
and the token-footprint baseline derive from it, and src/tool-registry.test.ts fails when the
views drift.
src/utils/tool-names.ts: add the name.src/tools/<tool-name>.ts: the definition,satisfies AutomationsTool<...>. Copyfind-automations.ts.src/tool-registry.ts: add it to the map, in the section it belongs to.src/tools/tool-annotations.test.ts: add its annotation expectation.src/tools/<tool-name>.test.ts: tests against a fakedAutomationsApi.src/mcp-server.ts: add toinstructionsonly when the tool needs cross-tool routing guidance; per-tool detail belongs in its own description.
Output schemas never use .nullable(): nulls are stripped before the SDK validates the output, so
a nullable field is a validation failure waiting to happen. Use .optional() and leave the key
out. src/tools/output-schema-nullability.test.ts enforces it.
src/token-footprint.test.ts prints what the tool surface costs a model on every tools/list. If a
change pushes the combined fixed cost over its budget, raise the budget in the same PR and say so.