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 inmcp_tokens(tenant table, forced RLS); the secret is shown once. Expiry is 1–90 days (default 30), at most 10 active tokens per person, scopemcp:readonly. - Gitleaks, the publisher secret scan (
findSecrets) andredactdetect theclt_format. - Tokens are accepted only in the
Authorization: Bearerheader and only on routes that declareconfig.auth = 'bearer', which today isPOST /mcp. Such routes ignore cookies, need no CSRF token, refuse a foreignOriginwith 403 and answer 401 withWWW-Authenticate: Bearer realm="codelotse", error="invalid_token". A bearer route must declare the permissionauthenticated; 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 recordslast_used_atat 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_membershipsandrepo_membershipsand never refreshes them, because a token has no GitHub user token behind it. If the tenant membership is missing or older thanMCP_TOKEN_GRANT_MAX_AGE_MS(24 hours, one constant in@quavon/shared-types), the token gets no grants and/mcpanswers 401 witherror_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 onlyrepo:view; tenant admins see every repository as in the web interface.
Endpoint ​
POST /mcpinapps/apibuilds a newMcpServerper request from@modelcontextprotocol/server2.3.1 throughcreateMcpHandler(factory, { responseMode: 'json' })andhandler.fetch. The principal is captured in the factory closure. Onlyaccept,content-type,last-event-idandmcp-*headers are passed to the SDK; theAuthorizationheader and cookies are not.@modelcontextprotocol/nodeis 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).GETandDELETE /mcpanswer 405. - Tools, all with
readOnlyHint: trueandopenWorldHint: false:list_repositories,get_pull_request_review,search_findings,get_rules,get_fix_promptandsearch_code. Repository arguments take an id or a full name inside the token tenant; unknown and forbidden repositories both answer "Not found.".search_codeuses 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 inuntrusted(…). The fix prompt frames the findings (rendered withrenderFindings) as data to verify before any change. - No tool writes, calls another service or forwards the token. Reads are not audited (as for
GETroutes);last_used_atand the metricsquavon.mcp.server.requestsandquavon.mcp.server.durationrecord 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/mcpto 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,membershipandteamwebhooks 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.