Skip to content

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.

TransportEndpointUse
Remote — Streamable HTTPPOST /mcp on the standalone mcp processhosted agents, server-to-server
Local — stdionpx 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-stream
ts
import { 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:

VarMeaning
KANBAN_API_URLBase URL of the hosted server. https required unless the host is localhost.
KANBAN_TOKENYour scoped board API token.

A Claude Desktop mcpServers entry:

json
{
  "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:

ScopeGrants
boards:readboard_read.*, card_read.*
boards:writeboard_write.*
members:writeboard_write.manage_member
cards:writecard_write.* (create/move/update/archive/delegate/set fields…)
comments:writecard_write.comment
sessions:read / sessions:writesession_read.* / session_write.*; my_work.delegated_to_me / sessions_awaiting_me
notifications:read / notifications:writemy_work.notifications / mark_read
automations:writeautomation_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:

jsonc
// { "name": "<tool>", "arguments": { "method": "<method>", "params": { … } } }
await client.callTool({ name: "card_write", arguments: { method: "move", params: { cardId, listId } } });
ToolMethods
board_readlist, get, snapshot (markdown), search, activity (with since), members, labels, fields
board_writecreate, update, create_list, update_list, archive_list, create_label, manage_member (admin)
card_readget (UUID or key TRK-42), activity, comments, attachments, checklist, links
card_writecreate, update, move, archive, comment, assign (humans only), delegate, label, set_field, set_priority, checklist_add, checklist_toggle, attach_url
session_readget, poll_events, list_board, list_mine
session_writeopen, update, progress, log, ask_human, answer, complete, fail
my_workassigned, delegated_to_me, sessions_awaiting_me, notifications, mark_readyour attention queue; the recommended loop entry point
automation_readlist, get, runs — admin-gated
automation_writecreate, 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:

URIContent
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.
ts
const board = await client.readResource({ uri: `kanban://board/${boardId}` });
// board.contents[0].text is markdown

board_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:

ts
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 catalog

Unset (the default) exposes only the consolidated catalog. Migrate flat calls to {method, params} (e.g. create_cardcard_write {method:"create"}, get_my_notificationsmy_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.

Released under the AGPL-3.0 license.