Skip to content

ADR-0018: CodeLotse as an MCP server with personal access tokens ​

Status: accepted (2026-10-15). Amends ADR-0007 (bearer tokens on /mcp only) and ADR-0006 (auth.session covers access-token validation). Builds on generic OIDC (ADR-0013) and the persistent code index (ADR-0017).

Context ​

Developers want their coding agent (Claude Code, Cursor, VS Code) to read CodeLotse reviews, findings, rules and indexed code while they fix a pull request. The Model Context Protocol (MCP) is the common interface for that. Authorization is optional for MCP HTTP servers; when it is used, the 2026-07-28 revision asks for OAuth 2.1 with protected resource metadata, audience binding and client ID metadata documents. CodeLotse signs users in through the GitHub App user flow (ADR-0007) or as an OIDC client (ADR-0013); neither makes it an authorization server.

Decision ​

Authentication: personal access tokens, OAuth later ​

  • Members create personal access tokens per tenant on the MCP access page. A token is clt_ followed by 32 random bytes in base64url (43 characters). Only the SHA-256 hash and the last four characters are stored in mcp_tokens (tenant table, forced RLS); the secret is shown once. Expiry is 1–90 days (default 30), at most 10 active tokens per person, scope mcp:read only.
  • Gitleaks, the publisher secret scan (findSecrets) and redact detect the clt_ format.
  • Tokens are accepted only in the Authorization: Bearer header and only on routes that declare config.auth = 'bearer', which today is POST /mcp. Such routes ignore cookies, need no CSRF token, refuse a foreign Origin with 403 and answer 401 with WWW-Authenticate: Bearer realm="codelotse", error="invalid_token". A bearer route must declare the permission authenticated; access to the tenant and to each repository is checked inside the route. Cookie routes never see a token principal.
  • The token authenticator reads the token under withSystem('auth.session') and requires: not revoked, not expired, the user still exists, MCP switched on for the tenant (tenant_settings.settings.mcp.enabled, off by default) and, for OIDC tokens, an enabled identity provider. It records last_used_at at most once a minute.
  • OAuth 2.1 with CodeLotse as authorization server stays a later option, only if clients that support nothing but OAuth matter.

Grants ​

  • A token principal is bound to its tenant: grants for any other tenant are empty. Instance-admin and break-glass rights are never carried over; break-glass sessions cannot create tokens.
  • GitHub users: the authorizer reads the cached tenant_memberships and repo_memberships and never refreshes them, because a token has no GitHub user token behind it. If the tenant membership is missing or older than MCP_TOKEN_GRANT_MAX_AGE_MS (24 hours, one constant in @quavon/shared-types), the token gets no grants and /mcp answers 401 with error_description="sign in to CodeLotse to refresh access". A web sign-in refreshes the cache.
  • OIDC users: the token stores the identity provider and a snapshot of the IdP groups taken from the creating session. Every sign-in through that provider replaces the snapshot on the user's active tokens. Grants are computed per request from the snapshot and the current mappings, so removed mappings and disabled providers apply at once.
  • Repository roles are capped at reader. Every tool needs only repo:view; tenant admins see every repository as in the web interface.

Endpoint ​

  • POST /mcp in apps/api builds a new McpServer per request from @modelcontextprotocol/server 2.3.1 through createMcpHandler(factory, { responseMode: 'json' }) and handler.fetch. The principal is captured in the factory closure. Only accept, content-type, last-event-id and mcp-* headers are passed to the SDK; the Authorization header and cookies are not. @modelcontextprotocol/node is not needed.
  • The endpoint is stateless and serves the 2026-07-28 protocol (server/discover, per-request envelope) and 2025-era clients through the SDK's stateless legacy fallback (initialize). GET and DELETE /mcp answer 405.
  • Tools, all with readOnlyHint: true and openWorldHint: false: list_repositories, get_pull_request_review, search_findings, get_rules, get_fix_prompt and search_code. Repository arguments take an id or a full name inside the token tenant; unknown and forbidden repositories both answer "Not found.". search_code uses the lexical search of the code index; semantic search would need a query embedding, which is an outbound model call, so the MCP endpoint does not offer it.
  • Results are JSON, passed through redact, capped at 32 KiB and wrapped in untrusted(…). The fix prompt frames the findings (rendered with renderFindings) as data to verify before any change.
  • No tool writes, calls another service or forwards the token. Reads are not audited (as for GET routes); last_used_at and the metrics quavon.mcp.server.requests and quavon.mcp.server.duration record use without tenant or person attributes. Creating and revoking tokens and switching the tenant setting are audited.
  • Rate limits: bucket mcp, 120 requests a minute per token and 600 per IP. The reverse proxies route /mcp to the API without response buffering.

Consequences ​

  • Any client that can send a static header works today. Clients that only speak OAuth cannot connect.
  • A GitHub user removed from an organisation keeps read access through existing tokens for at most 24 hours; OIDC group changes reach tokens at the next sign-in. Handling the subscribed member, membership and team webhooks would close the GitHub window and is not part of this decision.
  • The 24-hour bound means GitHub users have to sign in to the web interface once a day to keep their tokens working.

The open core is released under the Apache License 2.0.