API and MCP internals
How the public REST API (/api/v1), the MCP server (/api/mcp) and the webhooks are built, and the checklists for extending them. For using them, see the user guide's REST API, MCP server, Webhooks and the generated API reference.
One logic layer, two transports
app/api/v1/**/route.ts ─┐ ├─► features/api-v1/{queries,mutations,options,views}.ts ─► db lib/mcp/tools.ts ───────┘REST routes and MCP tools are thin shells. The behaviour (what a request does, which
fields mean what, which permission it needs) lives in features/api-v1/:
| File | Content |
|---|---|
queries.ts | Reads, named *ForUser(userId, …): lists, single objects, comments, attachments, members. |
mutations.ts | Writes, also *ForUser, returning a MutationResult<T>: { ok: true, data } or { ok: false, status: 403 | 404 | 422, error? }. |
options.ts | Statuses, priorities, issue types. |
views.ts | Saved views, on top of features/views. |
richtext.ts | Markdown in, ProseMirror out (and @handle, #WEB-12, //date chips). |
Because both transports call the same *ForUser function, they cannot drift apart on
behaviour; only the packaging differs.
A REST route
Every route under app/api/v1/ follows the same four steps. Copy a neighbour, for
example app/api/v1/issues/[id]/route.ts:
const auth = await resolveApiUser(req); // 1. who is asking (API key)if (!auth) return unauthorized(); // 401const { ok, info } = await checkRateLimit(auth.keyId); // 2. 120 requests per minute per keyif (!ok) return rateLimited(info); // 429if (!hasScope(auth, "issues:write")) return insufficientScope(); // 3. the key's ceiling, 403// 4. parse the body, call the *ForUser function, map its result with the helpers in lib/api/respond.tsRules that keep the API consistent:
- Answers are
{ data }(andnextCursorfor pages). Wrap every response withwithRateLimitHeadersso the budget headers are always present. - Errors are
{ error: { code } }.mutationJson()maps aMutationResult: 404 to a barenullbody, 403 toforbidden, 422 toinvalid_bodyor the mutation’s own specific{ code, message }. A thing the caller may not see is a 404, never a 403. - Scopes are a ceiling.
hasScopesays whether the key may call the route; the*ForUserfunction still checks the owner’s own permissions (can(...)). - The public issue shape is
ApiIssue; trim fields withpickFields(item, request.fields); page withparseLimit/parseCursor/encodeCursor(lib/api/cursor.ts: an opaque base64url of the last id, limit 1 to 100, default 25). - An issue can be addressed by id or by reference (
WEB-42): resolve withresolveIssueId. - Write descriptions and comments as Markdown through
richtext.ts; never accept raw ProseMirror JSON from outside.
An MCP tool
lib/mcp/tools.ts registers one tool per REST route with server.registerTool(name, { title, description, inputSchema }, handler). The handler starts with
authorize(ctx, scope) (lib/mcp/context.ts), the analogue of the three-line route preamble:
token present, rate limit, scope. It returns a ready-made error result if any fails, otherwise
calls the same *ForUser function.
- The server is stateless Streamable HTTP: a fresh
McpServerper request, no sessions. lib/mcp/auth.tsaccepts an API key (bry_) or an OAuth access token (bryat_), picked by prefix, and passesuserIdandkeyIdalong inextra.- A 401 carries a
WWW-Authenticateheader pointing at the protected-resource metadata, which is how an app discovers the OAuth flow (Authentication). - Results are JSON as text; errors are results marked
isErrorwith the textcode: message. UsejsonResult,mutationResult,notFoundResult,forbiddenResultfromcontext.ts. get_attachmentcan return an image (up to 5 MB) or a text file (up to 512 KB) as content, so an assistant can look at a screenshot; links are never fetched by the server.- Tool input is a flat
z.object. Path parameters are folded in (issueId), and optional patch fields stay optional.
The in-app reference and the website
Three things describe the same API and must agree:
- the real routes and tools (the truth),
- the reference data the app shows on the API keys and MCP pages, written by hand in
features/account/components/AccountApiKeys/apiDocsData.ts(REST endpoints and webhook events),filterDocsData.tsandAccountMcp/mcpDocsData.ts(tool descriptions), in both languages, - the user guide’s API reference,
docs/guide/{en,de}/api-reference/, generated from (2) and from the MCP tools’ real input schemas bybun run docs:api(scripts/docs-api/generate.ts).
(2) is hand-written, so it is the place where drift can sneak in. Two guards exist: the
generator fails if a registered MCP tool has no description in mcpDocsData.ts (or the
other way round), and a unit test fails while the generated pages are out of date. Nothing
checks that an endpoint’s parameter table matches its route, so when you change a route, update
its entry by hand.
Checklists
Adding a REST endpoint
- Put the logic in
features/api-v1/as a*ForUserfunction (reuse an existing action’s rules where you can, so the app and the API cannot disagree). - Add
app/api/v1/.../route.tsfollowing the four steps above. Pick an existing scope or add one (lib/api/scopes.ts→API_SCOPES; a new scope also needs its one-sentence description for the key dialog, thescopeDescmap is typedRecord<ApiScope, string>and will not compile without it). - Add the matching MCP tool in
lib/mcp/tools.ts(same scope, same function). - Document both in the in-app data: the endpoint in
apiDocsData.ts(params, request and response example), the tool inmcpDocsData.ts. - Run
bun run docs:apiand commit the regenerated pages. - Tests: route behaviour in
tests/unit/api-keys/(seeroute.test.tsfor the pattern), the logic next to its domain. - If it changes what an integrator sees, adjust the guide pages
(
docs/guide/*/integrations/api.md).
Adding a scope
Add it to API_SCOPES in lib/api/scopes.ts (order matters: the settings grid renders in that
order, read and write of one resource side by side). It is a free string in the database, so no
migration. Then use it in the routes and tools, and describe it in the user guide’s scope table.
Adding a webhook event
- Add the name to
WEBHOOK_EVENTSinlib/webhooks/events.ts. - Call
fireWebhookEvent(workspaceId, "<event>", data)where the change happens, after it has succeeded, in every place that can cause it (a Server Function and the matching API/MCP mutation; issues fire from bothfeatures/issues/actions.tsandfeatures/api-v1/mutations.ts). Do notawaitit, and never let it throw into the caller. - Use the public shape for
data(ApiIssue, the comment shape, a view’s stored shape) so receivers see what the REST API shows. A deleted object cannot be re-read, so send a small snapshot instead (id, reference, title). - Add the event to the new-webhook dialog (labels in
messages/*, list infeatures/webhooks/components) and an example payload toAPI_DOC_WEBHOOK_EVENTSinapiDocsData.ts; runbun run docs:api. - Test delivery in
tests/unit/webhooks/.
A view event is sent only for shared views; a private view is nobody else’s news.
Changing a response shape
Existing integrations depend on it. Prefer adding fields over renaming or removing. The path
carries a version (/api/v1); a breaking change belongs in a new version, not in v1.