Threat analysis: MCP servers as review context ​
Status: awaiting sign-off. Extends threat-model.md. MC1–MC12 await the maintainer's sign-off; until then MCP_CONTEXT_SIGNED_OFF is false: the API refuses configuration and discovery (422 not_signed_off), the worker never opens MCP clients, the UI shows the notice.
Design record: ADR-0019. User documentation: mcp-context.md.
1. Scope and capabilities ​
| Component | Trigger | What it may do | What it may never do |
|---|---|---|---|
| Server registration (API) | A tenant admin (tenant:manage) on Organisation → MCP servers | Store a name, an https URL and an optional static credential (bearer token or one x-* header), encrypted; record the data-sharing acknowledgement | Run a local command, use stdio, store a URL with credentials, a query or a fragment, reach a private address (unless the self-hosted instance lists the host) |
| Discovery (API) | Discover or Save of the tool allowlist | Connect over Streamable HTTP, list the tools and their annotations, compute a SHA-256 per tool | Call a tool, follow a redirect, accept a response above 1 MiB; Discover runs at most once per 10 s per server (an atomic claim), Save is not throttled but audited and admin-only |
| Tool allowlist (API) | A tenant admin selects at most 20 tools | Store a snapshot of each tool from a live discovery (name, admin-reviewed description, input schema, hash, annotations) | Take schemas or hashes from the client, allow a write-capable tool without confirmWrite, allow a tool whose input schema is not a flat object of scalars |
| Repository policy (API) | A repository maintainer (repo:manage) | Turn MCP context on for up to 5 of the tenant's servers with 1–20 calls per review | Reference servers of another tenant; turn it on before sign-off or when the instance disables it |
| Review context (worker) | A review run on a private or internal repository whose policy, the sign-off and the instance allow it | Offer the allowlisted tools to the finder step as mcp_<server>_<tool>, send short search arguments, receive text | Offer tools to the verifier or the summary, run on public repositories, send secrets or personal data, store arguments or results, keep a client after the run |
2. Actors and assets ​
- MCP server operators are semi-trusted: a tenant admin chose them and acknowledged that search terms derived from pull requests reach them. Their tool output is untrusted data, like pull request text: an operator, or anyone who can write into the system behind the server (a wiki page, a ticket), can place instructions in it.
- Pull request authors are untrusted. On private repositories they are usually members of the organisation, but their diff and description still steer what the finder searches for.
- The model is treated as attacker-controlled once it has read untrusted text; every control below assumes it may try to misuse the tools.
- Tenant admins register servers and allowlist tools (audited); repository maintainers select servers in the repository policy (audited through the policy).
- Instance operators decide with
QUAVON_MCP_CONTEXTwhether the feature exists at all and, on self-hosted instances, which private hosts may be reached (QUAVON_MCP_PRIVATE_HOSTS). - Assets: the MCP credentials, the repository's code and diff, findings and summaries published on the code host, other tenants' data, the internal network of the instance, model budget.
3. Design ​
- Transport.
@modelcontextprotocol/client2.3.1 withStreamableHTTPClientTransportonly;versionNegotiation: { mode: 'auto' }speaks the 2026-07-28 protocol (server/discover) and falls back to the 2025initializehandshake. The client advertises no capabilities (no sampling, elicitation or roots) and registers no request handlers, so server-initiated requests are answered with "method not found". ESLint bans every@modelcontextprotocol/**/*stdio*import. - Egress. The transport receives an injected fetch:
createMcpFetch(the endpoint guard: addresses checked inside the socket lookup,redirect: 'error', https only) in every edition, wrapped bycappedFetch, which- refuses any request to another origin than the configured URL,
- adds the credential header only on those requests,
- aborts each request after
MCP_CALL_TIMEOUT_MS(10 s), - fails a response above the byte cap while it streams (1 MiB per HTTP response during discovery, 256 KiB during a review; tool results are additionally capped at 64 KiB). On self-hosted instances a hostname listed in
QUAVON_MCP_PRIVATE_HOSTSmay resolve to a private address; link-local, multicast,0.0.0.0/8and the IPv6 metadata addressfd00:ec2::254stay blocked. The enterprise edition ignores the list.
- Registration checks. The URL schema allows https only, without userinfo, query or fragment. Save checks the address with
assertPublicEndpointunless the host is listed; discovery repeats the check in the socket lookup. Moving a server with a credential to another host requires the credential again. Every URL change requires the acknowledgement again and resets the tool allowlist; the credential check reads the row locked inside the update transaction. - Credentials. Stored as JSON in
mcp_servers.encrypted_credentialswith AES-256-GCM and the contextmcp_servers:<id>(covered bykeys rotate); the API returns onlycredentialHint(scheme or header name and the last four characters). - Pinning. At allowlist time the API discovers live and stores, per tool, the SHA-256 over name, description, canonical input schema and annotations, so a tool that later reports itself as write-capable is dropped too. Each review lists the tools again; a tool whose live hash differs is dropped for that run, recorded as
blockedand the server'slast_errorbecomestool_changed. The model sees the stored description written or reviewed by the admin (500 characters, invisible characters removed), prefixed with "External read-only context from <name>." - Schemas. Only
type: objectwith at most 20 properties named[A-Za-z0-9_], eachstring(capped at 500 characters),number,integer,booleanor a stringenum(≤ 50 values). Property descriptions are not passed to the model. Anything else is "unsupported schema" at discovery and cannot be allowlisted. - Execution (
mcpReviewTools), in order: run budget (calls ≤maxCalls; each call reserves 64 KiB of the 256 KiB run budget before it is sent and keeps only the accepted bytes afterwards, so concurrent calls cannot exceed it);findSecretson the serialised arguments and the redaction rules (secrets, credentials in URLs, e-mail addresses), a hit isblockedand nothing is sent;tools/call; keep onlytextitems andstructuredContentas JSON (images, audio, resources and links are dropped); results above 64 KiB are refused (too_large); results in whichfindSecretsfinds something are dropped (blocked);redact, cap at 8,000 characters, wrap inuntrusted(…, 'mcp:<server>/<tool>');isErrorbecomes{ error: <untrusted text> }; record the call. - Records.
mcp_tool_callskeeps tool name, status, duration, request and response size and the SHA-256 of the arguments; never the arguments or the output. The run stats keepmcp: { prompt, calls, bytes, blocked }. Both follow the tenant data retention. - Pipeline. Only the finder receives the MCP tools. The finder prompt gets
MCP_TOOLS_SECTION(mcp-v1) only when at least one tool is offered, so reviews without MCP keep a byte-identical prompt (review-v1.5). The verifier and the summary run with the repository tools only, so every finding has to be provable from the diff or repository files. - Gates.
effectiveMcpContext(policy, visibility, MCP_CONTEXT_SIGNED_OFF, QUAVON_MCP_CONTEXT === 'allowed')isononly when signed off, allowed by the instance,mode: on, with at least one server, and not public. The API answers 422not_signed_offorinstance_disabledon every change except deleting a server; the policy API refusesmode: onunder the same conditions; the worker returns before touching the database or the network. - Failures. A server that cannot be reached, times out or answers with a protocol error is skipped with a warning and the metric
quavon.mcp.context.calls{outcome="unavailable"}; the review continues without it. Clients are created per run and closed in the review'sfinally.
4. Threats and controls ​
| # | Threat | Control | Test |
|---|---|---|---|
| MC1 | SSRF and DNS rebinding through the server URL | https only; no userinfo, query or fragment; assertPublicEndpoint at save; the guarded fetch checks the address inside the socket lookup on every request in every edition; redirects refused; requests to another origin refused; private hosts only through the instance list, link-local, multicast, 0.0.0.0/8 and the IPv6 metadata address fd00:ec2::254 stay blocked | mcp-context.test.ts (shared-types: URL schema), mcp.test.ts (llm: "blocks private addresses unless the instance lists the host", "adds the credential only for the configured origin and refuses redirects"), mcp-servers.int.test.ts ("checks URLs per edition …", "reports unreachable servers without leaking the URL") |
| MC2 | Credential theft or misdirection | Encrypted with the per-server context; only a hint in views and logs; header names restricted to x-* (no authorization, cookie or host overrides); sent only to the configured origin; no redirects; a move to another host needs the credential again; errors carry fixed codes, never URLs or headers | mcp.test.ts ("encrypts per server and shows only a hint", origin test), mcp-servers.int.test.ts ("stores credentials encrypted and exposes only a hint"), review-mcp.int.test.ts (decrypts for the run) |
| MC3 | Prompt injection through tool output | untrusted wrapper with a random boundary; text-only content; redaction; results with secrets dropped; tools for the finder only; verifier without MCP tools; findings need repository evidence; the publisher sanitiser and secret scan on all output; red-team items | mcp.test.ts ("… wraps text-only, redacted results as untrusted"), pipeline.test.ts ("offers external MCP tools to the finder only …"), review.int.test.ts (worker), red-team items 106, 107, 110, 111 |
| MC4 | Tool poisoning or rug pull (description or schema changes after approval) | Snapshot from a live discovery, never from the client; SHA-256 pinning checked on every run; admin-owned description shown to the model; property descriptions never shown; flat scalar schemas only | mcp.test.ts ("drops tools whose live definition changed …", "rejects nested, array and odd schemas"), review-mcp.int.test.ts ("drops a tool whose definition changed and marks the server"), mcp-servers.int.test.ts ("… allowlists from the live result"), red-team item 114 |
| MC5 | Exfiltration through tool arguments | Flat schema with 500-character strings; findSecrets and the redaction rules on the arguments (secrets and e-mail addresses are never sent); call budget; admin data-sharing acknowledgement; public repositories excluded; red-team mcp_egress check on every outbound argument | mcp.test.ts ("never sends secrets or personal data and enforces the call budget"), review-mcp.int.test.ts, red-team items 108, 113 (mcpEgressViolations) |
| MC6 | Data from the MCP server republished on a public pull request | MCP context never runs on public repositories (effectiveMcpContext, checked in the worker). Residual: findings on private repositories may paraphrase external context | mcp-context.test.ts, review-mcp.int.test.ts ("never runs on public repositories …"), red-team item 113 |
| MC7 | Side-effecting tools | Explicit allowlist; annotations shown but treated as untrusted hints and pinned with the tool; a tool without readOnlyHint: true or with destructiveHint: true needs confirmWrite; arguments are search terms only. Residual: a server may describe a writing tool as read-only | mcp-servers.int.test.ts (write_tool), red-team item 109 |
| MC8 | Cost or denial of service | maxCalls ≤ 20 (default 5) per run; 10 s per request; 1 MiB per HTTP response for discovery and 256 KiB during reviews, 64 KiB per tool result, 256 KiB per run; 8,000 characters per result in the prompt; the finder's 8 steps and 240 s wall clock; at most 20 servers per tenant, 5 per repository, 20 tools per server, 200 listed tools; discovery once per 10 s per server | mcp.test.ts ("caps the response size …", "aborts slow servers …", "stops after the run byte budget …"), mcp-servers.int.test.ts (409), red-team item 112 |
| MC9 | Cross-tenant use | mcp_servers and mcp_tool_calls with forced RLS and withTenant; the policy accepts only servers of the tenant; clients are created per run, nothing is cached across runs; not-found for foreign ids | rls.int.test.ts (both tables), mcp-servers.int.test.ts ("hides servers of other tenants …"), idor.int.test.ts |
| MC10 | Unwanted egress on air-gapped and regulated installs | QUAVON_MCP_CONTEXT defaults to disabled; the Helm air-gap profile sets it to disabled; network policies without internet egress; the instance switch also blocks the API | config.test.ts, render.sh (air-gap profile), mcp-servers.int.test.ts ("… when the instance disables MCP context"), review-mcp.int.test.ts |
| MC11 | Protocol abuse (stdio, local commands, server-initiated requests) | Streamable HTTP only, no command field anywhere; stdio imports lint-banned; no client capabilities advertised; server requests rejected; strict create schema | ESLint rule in eslint.config.js, mcp.test.ts ("discovers and calls tools" in both protocol eras, no capabilities in the requests), mcp-servers.int.test.ts (command refused) |
| MC12 | Output stored or leaked | Arguments and output are never stored or logged; only counts, sizes, durations and an argument hash are kept. Exception (D13): when a tenant enables debugPrompts, the encrypted, redacted prompt logs include MCP tool results of the finder steps for the configured ttlHours (at most 168 h) | review-mcp.int.test.ts ("… records only counts …"), tenant.int.test.ts (llm: prompt logs encrypted and redacted) |
5. Conditions under which a tool call happens ​
All must hold for a call to leave CodeLotse:
MCP_CONTEXT_SIGNED_OFFistrueand the instance setsQUAVON_MCP_CONTEXT=allowed.- The repository is private or internal; its policy has
mcp.mode: onand lists the server; the policy parses. - The server belongs to the tenant, is enabled and has an acknowledgement.
- The tool is on the server's allowlist, its live hash equals the stored hash and its stored schema is a supported flat schema.
- The connection to the server succeeded through the guarded fetch (address check in the socket lookup, no redirects, the configured origin only).
- The finder step calls the tool; the verifier and the summary never can.
- The run has calls and bytes left in its budget.
- The arguments match the schema (strings ≤ 500 characters), and neither
findSecretsnor the redaction rules find anything in them.
6. Residual risks ​
- Findings on private repositories may paraphrase external context, which then appears in the pull request on the code host. Anyone who can read the pull request can read that paraphrase (MC6).
- A server can lie about tool annotations; a tool described as read-only may change data when called with a search term (MC7).
- Search terms derived from a private diff (identifiers, ticket numbers, short phrases) reach the server by design; the admin acknowledges this when registering it. Secret patterns and e-mail addresses are blocked, other personal data in free text is not reliably detectable.
- A compromised or prompt-injected model can still choose misleading search terms or ignore the external context; the verifier without MCP tools limits the effect to candidates that the repository confirms.
- With
debugPromptsenabled, MCP output is stored encrypted for the configured time (MC12). - Static credentials only; OAuth for MCP servers is a later decision (D8).