SAQ runs a Model Context Protocol (MCP) server so that AI assistants can read and change tickets, log time, and read client timesheets with the same permissions as the token they hold. This page covers the transport, client setup, and every tool.
Endpoint and transport
- Endpoint:
POST https://saq.no/mcp(Streamable HTTP, JSON-RPC 2.0).GETandDELETEanswer405. - One JSON-RPC message per request. A JSON array (batch) is refused with
400. - Methods:
initialize,ping,tools/list,tools/call. Other methods answer JSON-RPC error-32601. Notifications (messages without anid) are acknowledged with202and no body. - Protocol versions:
2024-11-05,2025-03-26(default), and2025-06-18.initializereportsserverInfo.name: "saq"and thetoolscapability only; there are no resources or prompts. - Authentication:
Authorization: Bearer saq_YOUR_TOKEN. Cookies are ignored. See Authenticate with API tokens. - Browser clients: if an
Originheader is sent it must behttps://saq.no, and theHostheader must match. Native clients (Claude Code, Cursor, the MCP inspector's proxy) send noOriginand need nothing extra.
Set up Claude Code or Cursor
An Owner or Admin can copy ready-made snippets from Settings → Channels → MCP setup; the same text is returned by GET /api/w/acme/mcp in a browser session. Replace saq_YOUR_TOKEN with a token you minted.
Claude Code (.mcp.json or your user settings):
{
"mcpServers": {
"saq": {
"type": "http",
"url": "https://saq.no/mcp",
"headers": {
"Authorization": "Bearer saq_YOUR_TOKEN"
}
}
}
}
Cursor (.cursor/mcp.json):
{
"mcpServers": {
"saq": {
"url": "https://saq.no/mcp",
"headers": {
"Authorization": "Bearer saq_YOUR_TOKEN"
}
}
}
}
Aliases
Tool arguments accept human references instead of UUIDs; every alias resolves only within the token's workspace:
| Argument | Accepts |
|---|---|
ticket, source, target |
ACME-184 or a ticket UUID |
project |
project key (website) or UUID |
kind, state |
workflow key (bug, in_progress) or UUID |
assignee, viewer |
a person's email in this workspace, a UUID, me, or none |
An unknown project answers not_found; an unknown kind, state, or person answers invalid.
Tools
| Tool | Scope | Arguments | Returns |
|---|---|---|---|
search_tickets |
read | q, project, kind, state, stateCategory (open/closed), assignee, viewer, queue (inbox), billingClientId (UUID or none), billingPolicy, labelIds [UUID], priorityIds [UUID], due (overdue/week/none), staleDays (1–36500), blocked (boolean), unbilledOnClose (boolean), cursor, limit (≤50) |
{ items, nextCursor } of ticket summaries, newest activity first |
get_ticket |
read | ticket |
The ticket page: fields, viewers, comments, events, attachments, links, GitHub links, time, permissions. comments is omitted when no audience is readable; time is omitted for client users |
list_boards |
read | none | { boards: [{ id, name, key }] }: the workspace board plus each visible project |
get_board |
read | Same filters as search_tickets without cursor and limit |
{ columns: [{ state, total, tickets, nextCursor }] } |
create_ticket |
write (triage when project is omitted) |
title (required), description, project, kind, state, assignee, priorityId, billingClientId, labelIds, startDate, dueDate, estimateMinutes, parentId |
The new ticket summary; without project it lands in the Inbox |
update_ticket |
write | ticket plus any of title, description, kind, state, assignee, priorityId, labelIds, startDate, dueDate, billingClientId, billingPolicy, billingMode, billingDescription, estimateMinutes |
The updated ticket page; each field is checked against the owner's permissions |
move_ticket |
write (triage when leaving the Inbox) | ticket, project, kind, assignee, billingClientId, confirmReset |
The ticket page after the move |
add_comment |
write | ticket, body (≤50 000 characters), audience (shared/internal/staff), mentions [UUID, ≤20], attachmentIds [UUID, ≤20] |
The created comment; mentions become viewer grants |
link_tickets |
write | source, target, kind (blocks/relates/duplicates) |
{ id } of the link |
log_time |
write | ticket, spentOn (YYYY-MM-DD), minutes (≥1), startedAt (HH:MM), note, billable (default true) |
{ id } of the entry, logged as the token's owner |
close_period |
– | none | Always refused: forbidden, "Period close is a UI-only action in v1" |
create_billing_run |
– | none | Always refused: forbidden, "Billing runs are a UI-only action in v1" |
get_bank_balances |
read | none | { banks, projectUsage }; balances need client-wide billing document access, otherwise only projectUsage |
get_timesheet |
read | documentId or clientId |
With documentId: the document's metadata and content (encoding: "base64" for PDF, "utf-8" for HTML and CSV). Without: the list of documents the owner may download, optionally for one client |
get_inbox |
read | review (boolean) |
{ items }: the shared Inbox (triage scope, unrestricted token) or, with review: true, the restricted review items the owner may read |
get_context_pack |
read | none | The context pack described below |
link_github |
write | ticket, repo (owner/name), url, sha (7–40 hex) and/or pr (integer) |
The stored GitHub link |
search_docs |
any | query (required), audience (user/admin/integration-developer), section, feature, limit (≤20) |
{ hits }: matching documentation sections with url, title, heading, text, audience, roles, and permissions |
get_doc |
any | page (a path such as time/register-time, /docs/time/register-time/, or the full URL) |
One documentation page with its sections |
list_docs |
any | none | The documentation table of contents |
The three documentation tools read this public documentation and hold no workspace data, so any token may call them regardless of scope or project restriction.
Results and errors
A successful tools/call returns one text content block whose text is the JSON result:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{\"id\":\"01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7e\"}"
}
]
}
}
Domain errors (validation, permission, not found, read-only) are returned as a tool result with isError: true and the same { error, message, details } body the REST API uses:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"isError": true,
"content": [
{
"type": "text",
"text": "{\"error\":\"forbidden\",\"message\":\"This token does not include that scope\",\"details\":{\"scope\":\"write\"}}"
}
]
}
}
Invalid arguments answer error: "invalid" with details.issues. An unknown tool name answers not_found. Authentication failures and rate limits are HTTP-level (401, 403, 429) with the REST error body, not JSON-RPC results. In a read-only workspace, read tools keep working and write tools answer read_only.
Rate limits
MCP shares the REST budgets: 600 requests per minute per address, 120 per token, and the workspace's plan budget shared by every token. Each request is one JSON-RPC message, so each tool call costs one request. See API conventions.
The context pack
get_context_pack returns a read-only snapshot sized for a model's context window. Every section is permission-filtered, capped at 25 items, and carries truncated: true when more exist; page the rest with search_tickets filters.
| Section | Content |
|---|---|
mine |
Open tickets assigned to the owner |
overdue |
Open tickets past their due date |
dueSoon |
Open tickets due within 7 days |
inbox |
{ count, oldestCreatedAt } for the shared Inbox, or null when the token cannot see it |
blocked |
Open tickets with unresolved blockers, each with its blocker list (own truncation flag) |
stale |
Open tickets with no activity for 14 days (staleDays: 14 in search_tickets) |
time |
The current period: periodStart, periodEnd, loggedMinutes, estimateMinutes |
unbilledOnClose |
Open deliverable tickets (billed when closed) with unbilled time |
banks, bankProjectUsage |
Time bank balances with projected overage, and per-project usage |
github |
GitHub links on the tickets above |
Attribution
Tickets and comments created through MCP are stored with source: "mcp". With a personal token, the actor is the person and the record notes that a token acted on their behalf; with an agent key, the actor is the agent account.