MCP server
Tracker exposes its domain layer as native MCP tools. The MCP surface is a thin adapter over the exact same domain functions REST uses — a contract test proves a tool call and the equivalent REST call produce identical DB state and identical activity. Agents authenticate with the same scoped Bearer token they use for REST.
There are two transports, both serving the identical tool catalog, plus an OAuth path for clients that speak the MCP authorization spec.
| Transport | Endpoint | Use |
|---|---|---|
| Remote — Streamable HTTP | POST /mcp on the standalone mcp process | hosted agents, server-to-server |
| Local — stdio | npx kanban-mcp (a thin proxy to the remote) | desktop agents (e.g. Claude Desktop) |
Connect — remote HTTP
The standalone MCP server (@kanban/mcp, deployable separately from the api) speaks MCP over Streamable HTTP at POST /mcp. Authenticate with the same Bearer token as REST:
POST /mcp
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json, text/event-streamimport { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(
new URL("https://tracker.example.com/mcp"),
{ requestInit: { headers: { Authorization: `Bearer ${process.env.KANBAN_TOKEN}` } } },
);
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);
const { tools } = await client.listTools();
const result = await client.callTool({
name: "board_read",
arguments: { method: "list", params: {} },
});The server runs stateless (no session id): each JSON-RPC request is self-contained, so it scales horizontally behind a load balancer. It shares Postgres + Redis with the api, so events fan out and rate-limit buckets are unified across both surfaces. Local dev: pnpm --filter @kanban/mcp dev on :3001. In Compose it's the mcp service (MCP_PORT, default 3001).
Connect — local stdio (npx kanban-mcp)
For desktop agents, kanban-mcp is a thin stdio↔HTTPS proxy: it speaks stdio MCP to the local agent and forwards to the hosted POST /mcp over HTTPS. It holds no database credentials — only the hosted URL and your token. It reads:
| Var | Meaning |
|---|---|
KANBAN_API_URL | Base URL of the hosted server. https required unless the host is localhost. |
KANBAN_TOKEN | Your scoped board API token. |
A Claude Desktop mcpServers entry:
{
"mcpServers": {
"kanban": {
"command": "npx",
"args": ["-y", "kanban-mcp"],
"env": {
"KANBAN_API_URL": "https://tracker.example.com",
"KANBAN_TOKEN": "kbt_..."
}
}
}
}Connect — OAuth 2.1
OAuth-capable MCP clients (claude.ai custom Connectors, MCP Inspector, …) connect with no pre-shared token — just the endpoint URL. See OAuth 2.1 for the full flow.
Auth & scopes
The MCP surface uses the same token model and scopes as REST. Broad scopes imply narrower ones; a board:<id> leash confines the token to one board. The scope → tool mapping:
| Scope | Grants |
|---|---|
boards:read | board_read.*, card_read.* |
boards:write | board_write.* |
members:write | board_write.manage_member |
cards:write | card_write.* (create/move/update/archive/delegate/set fields…) |
comments:write | card_write.comment |
sessions:read / sessions:write | session_read.* / session_write.*; my_work.delegated_to_me / sessions_awaiting_me |
notifications:read / notifications:write | my_work.notifications / mark_read |
automations:write | automation_write.* (admin-gated) |
board:<id> | board leash — restricts the above to one board |
A scope/authorization failure surfaces as a tool error (isError: true), not a transport error. See Reference → Scopes.
Tool catalog (MCP v2 — consolidated)
The catalog is method-parameterized: a handful of { method, params } tools, each a Zod discriminated union on method whose per-method params are composed from the same shared DTOs REST validates against. Call shape:
// { "name": "<tool>", "arguments": { "method": "<method>", "params": { … } } }
await client.callTool({ name: "card_write", arguments: { method: "move", params: { cardId, listId } } });| Tool | Methods |
|---|---|
board_read | list, get, snapshot (markdown), search, activity (with since), members, labels, fields |
board_write | create, update, create_list, update_list, archive_list, create_label, manage_member (admin) |
card_read | get (UUID or key TRK-42), activity, comments, attachments, checklist, links |
card_write | create, update, move, archive, comment, assign (humans only), delegate, label, set_field, set_priority, checklist_add, checklist_toggle, attach_url |
session_read | get, poll_events, list_board, list_mine |
session_write | open, update, progress, log, ask_human, answer, complete, fail |
my_work | assigned, delegated_to_me, sessions_awaiting_me, notifications, mark_read — your attention queue; the recommended loop entry point |
automation_read | list, get, runs — admin-gated |
automation_write | create, update, delete — admin-gated |
assign a human, delegate to an agent (an agent target on assign fails with delegation_required + the delegation payload). Read the server's instructions (sent in the MCP handshake) and each tool's per-method description for the authoritative parameter list.
Results
Every result is returned both as structuredContent (a JSON object) and as a text block containing the same JSON.
Resources
Two markdown resources let an agent pull just what it needs instead of the full JSON tree:
| URI | Content |
|---|---|
kanban://board/{id} | Compact board snapshot: lists → cards, one line each (key, title, assignee/delegate, session, priority, due, checklist, label initials). ≈ 15 tokens/card. |
kanban://card/{id} | Full card as markdown: description, meta, custom fields, checklist, session state, recent comments. {id} accepts a UUID or a card key. |
const board = await client.readResource({ uri: `kanban://board/${boardId}` });
// board.contents[0].text is markdownboard_read.snapshot returns the same board markdown as a tool call.
Server instructions
The server ships spec-level instructions in the MCP handshake (the SDK surfaces them to the model): the board/actor model, the delegation-vs-assignment rule, session-protocol etiquette (update state early, ask don't guess, complete with a summary), the ?since polling contract, and idempotency guidance — so you start legible without extra reads.
The poll pattern (?since)
MCP is request/response, so a live "subscription" is a cursor + poll loop over board_read.activity:
let cursor: string | undefined;
for (;;) {
const res = await client.callTool({
name: "board_read",
arguments: { method: "activity", params: { boardId, ...(cursor ? { since: cursor } : {}) } },
});
const events = (res.structuredContent as { activity: { id: string }[] }).activity;
for (const e of events) handle(e);
if (events.length > 0) cursor = events[events.length - 1].id; // advance the cursor
await sleep(2000);
}board_read.activity returns events ascending when since is set, so advancing cursor to the last id gives an exactly-once, gap-free forward scan. The same ?since contract governs card_read.activity and session_read.poll_events.
Rate limits
Requests are rate-limited per token on Redis buckets shared with the api, so a token's budget is consumed across both surfaces — an agent can't dodge its cap by splitting load between REST and MCP. Exceeding the bucket returns HTTP 429 with Retry-After. Tunable via MCP_RATE_LIMIT_TOKEN_MAX / MCP_RATE_LIMIT_WINDOW_MS (defaults: 120 requests / 60s).
Token budget & legacy tools
Consolidation cut the advertised tool-schema context by 44% (25 → 7 primary tools) at strictly larger coverage. Recompute anytime with pnpm mcp:token-budget.
The pre-v2 flat tools (create_card, move_card, get_my_notifications, …) remain for one release behind an env flag, then are removable:
KANBAN_MCP_LEGACY_TOOLS=1 # exposes the flat tools ALONGSIDE the consolidated catalogUnset (the default) exposes only the consolidated catalog. Migrate flat calls to {method, params} (e.g. create_card → card_write {method:"create"}, get_my_notifications → my_work {method:"notifications"}).
Typed error model
Domain errors (authorization, validation, not-found, conflict) surface as MCP tool errors, not transport exceptions: the result is { isError: true, content: [{ type: "text", text: "<code>: <message>" }] } (e.g. forbidden: Not a member of this board). Transport-level failures are returned by the HTTP boundary before the tool runs — 401 for a missing/invalid token, 429 for rate limits.