Skip to content

MCP server#

Ticket Board mounts a FastMCP application alongside the REST API in the same FastAPI process. Bots use it to drive the board from inside an LLM client (Claude Code, a custom agent, any MCP-aware tool).

The MCP layer is a second transport, not a second domain. Every tool calls a service function that the REST surface also calls; authorization lives in the service layer; audit attribution is identical regardless of surface.

For the rationale behind the URL shape, see ADR-0002.

Mount strategy#

The FastAPI process mounts a single FastMCP application at /mcp/bot/{token}. The token segment is captured by an ASGI middleware, which authenticates the bot and attaches the resolved current_actor to the MCP request context before any tool dispatches.

/mcp/bot/{token}/...  →  FastMCP server (JSON-RPC over HTTP)
  • {token} is the full plaintext bot token (tkb_...). It IS the credential.
  • The MCP server's tool catalog, tools/list, and tools/call endpoints all live under this prefix.
  • The URL is never logged verbatim. Logging middleware emits /mcp/bot/<redacted>; the bot's identity is recorded by user id, not by token.

There is one app, not one app per bot. Tool catalogs are identical across bots; authorization is enforced inside.

The URL is a credential

If the URL appears anywhere except the bot's own configured client — a log line, a screenshot, a browser history, a clipboard — treat the token as leaked and rotate immediately. The tkb_ prefix exists precisely so leaked tokens are easy to grep for.

Identity binding#

The pipeline from URL to tool body:

sequenceDiagram
    autonumber
    participant Client as MCP Client (Bot)
    participant ASGI as FastAPI ASGI
    participant MW as mcp/context.py middleware
    participant Auth as auth/tokens.py
    participant Tool as mcp/tools/tickets.py
    participant Svc as services/ticket_service.py

    Client->>ASGI: POST /mcp/bot/{token}/mcp (JSON-RPC tools/call)
    ASGI->>MW: scope (path includes token)
    MW->>Auth: resolve_bot_by_token(token)
    Auth->>Auth: parse prefix → lookup active row → argon2 verify
    Auth-->>MW: current_actor (bot)
    MW->>MW: attach actor to MCP context
    MW->>Tool: dispatch tool call (ctx, args)
    Tool->>Svc: ticket_service.create_ticket(current_actor, args)
    Svc-->>Tool: ticket | raise domain error
    Tool-->>Client: MCP result | MCP error

current_actor for MCP is identical to the one served by REST: same dataclass shape, same fields. Tools do not branch on transport; services do not know which transport called them.

Authorization#

Same model as REST.

  1. Project allowlist enforced in the service layer. A list_tickets(project_id=X) call with X not in the bot's allowlist returns auth.forbidden.
  2. Read/write mode enforced at dispatch. The MCP server's tool registry knows each tool's scope; a bot_mode=read actor calling a write tool fails before the service layer is reached.
  3. Workspace scope is implicit. No tool takes a workspace argument.

The dispatch-layer check is a defense-in-depth check. The catalog returned by tools/list is the same for every bot — read-only bots see write tools — but the dispatcher refuses to run them.

Error mapping#

Domain exceptions map to MCP error responses. FastMCP uses MCP's standard error structure; the response body carries the same error envelope under data as REST.

Domain exception MCP error code Envelope code Notes
NotFound -32602 Invalid params resource.not_found details = { resource, id }
Forbidden -32001 (custom: AccessDenied) auth.forbidden or auth.read_only_bot details.reason carries the specifics
Conflict -32602 Invalid params resource.conflict e.g. invariant violation
Validation (Pydantic) -32602 Invalid params validation.invalid_field details.errors[] echoes Pydantic errors
RateLimited -32002 (custom: RateLimited) rate_limit.exceeded details.retry_after_seconds
Auth failure at transport (token invalid/revoked) -32000 Unauthorized auth.invalid_credentials Returned before any tool dispatches
Unexpected -32603 Internal error server.internal Logged; details empty in production

Clients should switch on the envelope code. The JSON-RPC numeric code exists for transport compatibility. The custom codes -32000..-32099 are within the JSON-RPC "implementation-defined" range.

MCP resources#

MCP defines a notion of read-only "resources" addressed by URI which an LLM can fetch as context. v1 ships a minimal set so bots can pin a ticket or wiki page into context without burning a get_* tool slot.

URI schemes#

Scheme Example Resolves to
ticket://{display_id} ticket://PROJ-123 A single ticket. Display id only (not UUID); display ids are the handle bots and LLMs already reason about.
wiki://page/{slug} wiki://page/onboarding A single wiki page by slug. Global pages match first; project-scoped pages match next in the bot's allowlist order. Ambiguous slugs return validation.invalid_field (details.reason = "ambiguous_slug").

Read-only by design#

Resources are read-only. There is no resources/write semantic in MCP, and we do not invent one — bots use write tools (update_ticket, update_wiki_page) to mutate state. Mutating via a resource interface would split audit attribution across two write paths.

Content shape#

A successful resources/read:

  • ticket://: returns the JSON serialization of TicketRead (the same schema GET /api/v1/tickets/{id} returns), content type application/json.
  • wiki://page/: returns the JSON serialization of WikiPageRead, content type application/json.

No transformation, no embedding pre-rendering, no truncation. The LLM client decides how to chunk.

Listing#

resources/list returns live tickets in the bot's allowlisted projects. Wiki pages are addressable via wiki://page/{slug} on resources/read but are not enumerated by resources/list in v1 — point the LLM at a known slug rather than expecting it to discover one through the resource catalog.

500-entry cap on resources/list

The list is capped at 500 entries total — no cursor pagination on this call. If a bot needs more than 500 entities, that workflow belongs in list_tickets with cursor-based pagination, not resource enumeration. The cap protects LLM context windows.

Entries are ordered by updated_at DESC so the most-recently-active items survive the cap.

Authorization#

  • ticket:// enforces the bot's project allowlist. Out-of-allowlist tickets resolve to resource.not_found (no info leak).
  • wiki://page/ global pages: visible to any authenticated bot.
  • wiki://page/ project-scoped pages: enforced against the allowlist.
  • bot_mode does not gate reads — read-only bots can read resources just like they can call get_ticket.

Freshness#

Resources are pulled fresh on every resources/read. The server caches nothing. There is no subscription / change-notification mechanism in v1; bots that need to track changes poll list_tickets / list_wiki_pages with updated_since.

Audit#

Each resources/read emits an audit_event row (category='audit_only', event_type='bot.resource_read', payload { uri }). No tool_invocation_log row — this is not a tool call.

Pagination#

MCP has streaming via SSE or the Streamable HTTP transport, but tool calls in v1 are short-lived JSON-RPC: a list call returns one page synchronously.

  • Tools that return collections accept cursor and limit. Defaults: limit=50, max 100 (capped lower than REST's 200 to bound LLM context per call).
  • Response shape: { "data": [...], "page": { "next_cursor": "...|null", "has_more": bool } }.
  • Cursors are the same opaque base64 as REST. A cursor from REST can be passed to a tool call and vice versa.

Tool discovery#

FastMCP implements tools/list and tools/call per the MCP spec.

  • The catalog returned by tools/list is the full set of tools — not a filtered subset based on bot_mode. The bot SDK / LLM is expected to honor its own scope; the dispatch-layer check enforces it regardless.
  • tools/list is itself a privileged call: the URL token is required, so an unauthenticated client cannot enumerate the catalog.

The trade-off mirrors REST: routes are introspectable through OpenAPI regardless of caller scope, and the server enforces access. The LLM gets a stable mental model of available capabilities; the system decides who may use which.

Rate limiting#

MCP tool calls are not rate-limited in v1. Only the login endpoint enforces a quota today; per-actor REST/MCP limiting is planned (see REST rate limiting).

When the per-actor limiter ships the contract will be a single bucket per actor shared with the REST surface. The MCP transport has no header channel for limits — when 429s appear they will surface via the error envelope (rate_limit.exceeded, details.retry_after_seconds).

Tool catalog#

Twenty-nine tools across seven groups. Each tool has a name (snake_case), a one-sentence description the LLM reads, a typed parameter schema, a return schema, and a scope (read or write). All list tools support cursor and limit (1..100, default 50); both are omitted from each tool's params below.

All IDs are UUIDv7 unless explicitly noted as accepting a display id.

Projects and configuration#

list_projects#

  • Description. List projects the bot can access (filtered by its allowlist).
  • Params. include_deleted? (default false; bots cannot set true).
  • Returns. Paginated[ProjectSummary].
  • Scope. read.

get_project#

  • Description. Fetch full project detail by id.
  • Params. project_id: UUID.
  • Returns. Project.
  • Scope. read.

list_workflow_states#

  • Description. List the ordered workflow states for a project.
  • Params. project_id: UUID.
  • Returns. WorkflowState[] (unpaginated; the list is bounded).
  • Scope. read.

list_labels#

  • Description. List labels defined for a project.
  • Params. project_id: UUID.
  • Returns. Paginated[Label].
  • Scope. read.

Tickets#

list_tickets#

  • Description. List tickets across one or more projects with filters. Returns concise summaries; use get_ticket for full bodies and relations.
  • Params. project_id?, type? (epic/story/subtask), state_id?, state_category? (backlog/active/done), assignee_user_id?, parent_ticket_id?, label_id?, priority?, updated_since? (RFC 3339), q? (FTS over title/body/recent comments).
  • Returns. Paginated[TicketSummary].
  • Scope. read.

get_assigned_tickets#

  • Description. List tickets assigned to the calling bot. A lean variant of list_tickets that binds the assignee filter to the bot's own identity — no assignee_user_id parameter is accepted. Omitting state_category returns assignments in all states. Results respect the bot's project allowlist.
  • Params. project_id?, state_category? (backlog/active/done), priority?.
  • Returns. Paginated[TicketSummary].
  • Scope. read.
  • Example. { "state_category": "active" }.

get_ticket#

  • Description. Fetch a ticket by display id (PROJ-123) or UUID. Returns title, body, state, assignee, labels, parent, watchers, and relation summaries.
  • Params. ticket: string | UUID.
  • Returns. Ticket.
  • Scope. read.
  • Example. { "ticket": "PROJ-42" }.

create_ticket#

  • Description. Create a ticket. Subtasks must have a story parent; stories may have an epic parent; epics may not have a parent.
  • Params. project_id: UUID, type, title, body? (markdown), parent_ticket_id? (display id accepted), assignee_user_id?, state_id?, priority? (default medium), label_ids?, due_date?.
  • Returns. Ticket.
  • Scope. write.

update_ticket#

  • Description. Update a ticket's mutable fields. Excludes type, display id, and state (use transition_ticket).
  • Params. ticket: string | UUID, plus any of: title, body, priority, parent_ticket_id, assignee_user_id, due_date.
  • Returns. Ticket.
  • Scope. write.

transition_ticket#

  • Description. Move a ticket to a different workflow state. Optionally attach a comment explaining the move.
  • Params. ticket: string | UUID, to_state_id: UUID, comment_body?.
  • Returns. Ticket.
  • Scope. write.

assign_ticket#

  • Description. Set or clear the assignee of a ticket.
  • Params. ticket: string | UUID, assignee_user_id: UUID | null.
  • Returns. Ticket.
  • Scope. write.

add_watcher / remove_watcher#

  • Description. Add or remove a watcher. With no user_id, acts on the calling bot itself.
  • Params. ticket: string | UUID, user_id?.
  • Returns. Ticket (with updated watcher list).
  • Scope. write.

add_label / remove_label#

  • Description. Attach or detach a label.
  • Params. ticket: string | UUID, label_id: UUID.
  • Returns. Ticket.
  • Scope. write.
  • Description. Create a typed relation between two tickets. Relation types: blocks, relates_to, duplicates.
  • Params. source_ticket: string | UUID, target_ticket: string | UUID, relation_type.
  • Returns. TicketRelation.
  • Scope. write.
  • Description. Remove a previously-created relation by its id.
  • Params. relation_id: UUID.
  • Returns. { "deleted": true }.
  • Scope. write.

Comments#

add_comment#

  • Description. Add a markdown comment to a ticket.
  • Params. ticket: string | UUID, body: string.
  • Returns. Comment.
  • Scope. write.

edit_comment#

  • Description. Edit a comment authored by the calling bot. Previous body is appended to edit history; original timestamps preserved.
  • Params. comment_id: UUID, body: string.
  • Returns. Comment.
  • Scope. write. Errors with auth.forbidden if the bot is not the author.

delete_comment#

  • Description. Soft-delete a comment authored by the calling bot. The body and edit history are preserved in the audit event.
  • Params. comment_id: UUID.
  • Returns. { "deleted": true }.
  • Scope. write.

search#

  • Description. Full-text search across tickets and wiki pages visible to the bot. Returns mixed result types ranked by relevance.
  • Params. q: string (non-empty), types?: ("ticket" | "wiki_page")[] (default both), project_id?.
  • Returns. Paginated[SearchHit] (discriminated union over TicketHit | WikiPageHit).
  • Scope. read.

Wiki#

list_wiki_pages#

  • Description. List wiki pages. Global pages are always visible; project-scoped pages obey the allowlist.
  • Params. project_id?: UUID | "global" (omit for both), updated_since?.
  • Returns. Paginated[WikiPageSummary].
  • Scope. read.

get_wiki_page#

  • Description. Fetch a wiki page by id or by slug. When using slug, pass project_id (or "global") to disambiguate.
  • Params. page: { id: UUID } | { slug: string, project_id?: UUID | "global" }.
  • Returns. WikiPage.
  • Scope. read.

create_wiki_page#

  • Description. Create a wiki page. Parses [[wikilinks]] and resolves them; broken links are recorded but do not block creation.
  • Params. slug, title, body, project_id? (omit for global).
  • Returns. WikiPage.
  • Scope. write.

update_wiki_page#

  • Description. Edit a wiki page. Creates a new revision snapshot and re-resolves wikilinks.
  • Params. page_id: UUID, title?, body?.
  • Returns. WikiPage.
  • Scope. write.
  • Description. List pages that link to the given page via [[wikilink]].
  • Params. page_id: UUID.
  • Returns. Paginated[WikiPageSummary].
  • Scope. read.

Sticky notes#

list_sticky_notes#

  • Description. List project-scoped sticky notes. Bots cannot see another user's personal notes.
  • Params. project_id: UUID.
  • Returns. Paginated[StickyNote].
  • Scope. read.

create_sticky_note#

  • Description. Create a project-scoped sticky note. Bots cannot create personal notes.
  • Params. project_id: UUID, body, color?.
  • Returns. StickyNote.
  • Scope. write.

convert_sticky_to_ticket#

  • Description. Convert a project-scoped sticky note into a ticket. The sticky note remains, with converted_to_ticket_id set.
  • Params. sticky_note_id: UUID, type, title?, parent_ticket_id?, assignee_user_id?, priority?.
  • Returns. Ticket.
  • Scope. write.

No update_sticky_note or delete_sticky_note tool ships in v1 — sticky notes are append-only over MCP; humans manage edits via REST.

Self#

get_self#

  • Description. Return the calling bot's own identity and scope.
  • Params. (none).
  • Returns. { user_id, username, display_name, mode, project_allowlist: UUID[] }.
  • Scope. read.

Lets the LLM reason about its own constraints without inferring them from failed calls.

Excluded from the catalog#

The following live on REST for humans only and never appear as MCP tools:

  • Audit log access. An adversarial bot must not be able to scrub for evidence of its own actions.
  • Bot management (create_bot, rotate_bot_token, etc.). Bots cannot manage other bots.
  • Project, workflow-state, and label CRUD. Bots configure nothing; they operate within John's configuration.
  • Webhook management.
  • Personal sticky notes. Bots have no personal scope.
  • User listing. Bots can look up their own identity via get_self but cannot enumerate others.

Tool-invocation logging#

Every tool invocation produces two rows in two tables (see ADR-0008):

  1. tool_invocation_log — the per-call detail: bot_user_id, request_id, tool_name, arguments_json (redacted, 4 KiB cap), result_status, error_message, duration_ms.
  2. audit_event — a lightweight pointer row: event_type='bot.tool_invoked', category='audit_only', tool_invocation_log_id FK to the detail row.

In addition, the tool's normal domain event (e.g. ticket.created) is emitted as its own category='activity' row.

The audit-log UI shows the pointer row in the timeline; clicking opens the full detail. The dedicated /audit/tools endpoint serves the detail-heavy view directly.

Redaction#

Any JSON field whose key matches password, token, secret, api_key, or private_key (case-insensitive) is replaced with "<redacted>" before persistence. arguments_json is capped at 4 KiB; over-long strings are truncated with a "<truncated>" marker.

Tool results are not logged in full — they are derivable from the entity history. result_status records only the outcome (ok or error.<envelope_code>); a non-empty error_message captures the human-readable failure cause.

Retention#

tool_invocation_log rows are purged after TOOL_INVOCATION_LOG_RETENTION_DAYS (default 90). The pointer audit_event row survives forever; its FK is set NULL when the log row is purged.

Failure scenarios#

Scenario What happens
Invalid token in URL auth/middleware.py returns auth.invalid_credentials; MCP transport returns a -32000 error before any tool dispatches.
Revoked or inactive bot Same as above. "Wrong token" and "revoked token" are indistinguishable in the response (the audit log records the precise reason).
Token rotated Old token immediately fails (no grace period in v1). The bot must be reconfigured with the new URL.
Bot calls a tool with project_id outside its allowlist Service raises Forbidden(reason=project_not_in_allowlist); MCP returns the structured error. The attempt is audited.
Bot calls a write tool while bot_mode=read Dispatch-layer rejects with auth.read_only_bot before the tool body runs. Audited as auth.bot.write_attempt_in_read_mode.
Token leaked into logs Operational concern; the tkb_ prefix exists so leaked tokens are greppable. Rotate immediately.
Connection dropped mid-tool The service-layer call either completed (with audit) or did not; idempotency keys are not supported for MCP in v1, so a retry can produce a duplicate. For creates that must be deterministic, use Idempotency-Key over REST.

Audit attribution#

Every tool invocation produces an audit_event row emitted by the service layer.

  • actor_user_id = the bot's user id.
  • actor_kind = "bot".
  • event_type = the standard <entity>.<verb_past> code (ticket.created, comment.created, etc.) — identical to the event a human action would emit. The transport is not encoded in event_type.
  • request_id correlates with the MCP request, so multiple events emitted by one tool call group together.
  • category = activity for user-visible actions; audit_only for the per-call bot.tool_invoked pointer row.

Worked example: MCP call lifecycle#

A bot in write mode creating a ticket:

sequenceDiagram
    autonumber
    participant LLM as LLM Agent (Bot client)
    participant MCP as FastMCP server
    participant MW as auth/middleware
    participant Tool as mcp/tools/tickets.create_ticket
    participant Svc as services/ticket_service
    participant Audit as events/emitter
    participant DB as Postgres

    LLM->>MCP: POST /mcp/bot/tkb_abcd.../mcp<br/>{ "method": "tools/call", "params": { "name": "create_ticket", "arguments": { ... } } }
    MCP->>MW: resolve token from URL
    MW->>DB: SELECT bot_token by prefix
    DB-->>MW: row (active)
    MW->>MW: argon2 verify
    MW-->>MCP: current_actor (bot, write mode, allowlist=[P1, P2])
    MCP->>MCP: dispatch check (tool.scope=write, actor.bot_mode=write)
    MCP->>Tool: handler(ctx, args)
    Tool->>Svc: ticket_service.create_ticket(actor, args)
    Svc->>Svc: project P1 in allowlist?
    Svc->>DB: BEGIN; SELECT project FOR UPDATE; bump counter; INSERT ticket
    Svc->>Audit: emit(ticket.created, actor=bot, request_id)
    Svc->>Audit: emit(bot.tool_invoked, payload={tool, args_redacted, status, duration_ms})
    Svc->>DB: COMMIT
    Audit->>DB: after-commit: INSERT tool_invocation_log row, INSERT audit_event rows (pointer + domain)
    Svc-->>Tool: ticket
    Tool-->>MCP: result (Ticket)
    MCP-->>LLM: { "result": { "ticket": { ... } } }

Quick smoke test#

# Get a bot token (printed by `make seed-dev`, or visible once in /settings/bots).
BOT_TOKEN="tkb_devresearchtoken0000000000000001"

# List the tool catalog.
curl -s -X POST \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
  "http://localhost/mcp/bot/$BOT_TOKEN/mcp"

A successful response carries a JSON-RPC envelope whose result.tools array contains entries like list_projects, get_self, search, etc.

The proxy access log will show /mcp/bot/<redacted>/mcp — never the literal token. If you see the raw token in any log line, that is a bug; please file it.

Configuring a client#

For step-by-step client setup (Claude Desktop JSON snippet, generic MCP endpoint, REST curl example), see Bots → Configuring your MCP client. The token modal in the UI pre-fills those snippets with the actual token and host at create/rotate time.

Cross-references#