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.
{token}is the full plaintext bot token (tkb_...). It IS the credential.- The MCP server's tool catalog,
tools/list, andtools/callendpoints 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.
- Project allowlist enforced in the service layer. A
list_tickets(project_id=X)call withXnot in the bot's allowlist returnsauth.forbidden. - Read/write mode enforced at dispatch. The MCP server's tool registry knows each tool's scope; a
bot_mode=readactor calling a write tool fails before the service layer is reached. - 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 ofTicketRead(the same schemaGET /api/v1/tickets/{id}returns), content typeapplication/json.wiki://page/: returns the JSON serialization ofWikiPageRead, content typeapplication/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 toresource.not_found(no info leak).wiki://page/global pages: visible to any authenticated bot.wiki://page/project-scoped pages: enforced against the allowlist.bot_modedoes not gate reads — read-only bots can read resources just like they can callget_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
cursorandlimit. Defaults:limit=50, max100(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/listis the full set of tools — not a filtered subset based onbot_mode. The bot SDK / LLM is expected to honor its own scope; the dispatch-layer check enforces it regardless. tools/listis 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?(defaultfalse; bots cannot settrue). - 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_ticketfor 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_ticketsthat binds the assignee filter to the bot's own identity — noassignee_user_idparameter is accepted. Omittingstate_categoryreturns 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?(defaultmedium),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.
link_tickets#
- 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.
unlink_tickets#
- 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 withauth.forbiddenif 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#
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 overTicketHit | 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.
list_backlinks#
- 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_idset. - 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_selfbut cannot enumerate others.
Tool-invocation logging#
Every tool invocation produces two rows in two tables (see ADR-0008):
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.audit_event— a lightweight pointer row:event_type='bot.tool_invoked',category='audit_only',tool_invocation_log_idFK 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 inevent_type.request_idcorrelates with the MCP request, so multiple events emitted by one tool call group together.category=activityfor user-visible actions;audit_onlyfor the per-callbot.tool_invokedpointer 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#
- REST API — the second transport over the same service layer.
- Bots — creating bots, rotating tokens, scoping them, and configuring MCP clients.
- Audit log — reading the tool-invocation log and correlating with audit events.
- Auth and identity — the actor resolution model.
- ADR-0002: Per-bot MCP URL with token-in-path.
- ADR-0008: Separate
tool_invocation_logtable.