feat(control-plane): Add standalone MCP server role - #591
feat(control-plane): Add standalone MCP server role#591leghadjeu-christian wants to merge 15 commits into
Conversation
- Adds new mcp module exposing an rmcp StreamableHttpService - Registers mcp as an independent role alongside a2a and serve - Preserves A2A code unchanged for microservice-style scaling
✅ AI Governance check passedThis PR declares AI usage, references a source of truth, and provides verification evidence. Thank you. |
… SOLID principles
|
@lightbridge-assistant review |
There was a problem hiding this comment.
Lightbridge review
This PR adds an MCP server role (mcp) to the control-plane: an rmcp Streamable-HTTP service wired behind a new OIDC Bearer middleware, with a single vector_search tool. The scaffolding is sound — the middleware is correctly layered over the root-nested service so every MCP request is authenticated, and the existing JwtValidator (signature/issuer/audience/expiry) is reused correctly. Two non-blocking security notes: the tool performs no tenant/org authorization and relies on caller-supplied platform/org/repo (an IDOR the moment the pgvector search is implemented — currently it only echoes args, so no data exposure yet), and disable_allowed_hosts() removes rmcp's DNS-rebinding/Host guard on the 0.0.0.0 listener. Cargo.toml (reviewed) is a clean, consistent feature addition enabling the server; Cargo.lock was NOT reviewed (generated lockfile). The tool is an explicit placeholder; no test coverage exists yet for the new auth boundary or the caller-context recovery path — worth adding before the search is implemented.
🤖 AI-generated review — treat it as untrusted, verify before acting; a human owns the final decision (AI governance).
08980b9 to
46ea954
Compare
This is intentional and safe in our architecture. The |
|
@lightbridge-assistant review |
There was a problem hiding this comment.
Lightbridge review
Re-verified and kept all prior findings after actively hunting for disconfirmation, and reviewed the remaining files. The P1 IDOR survives a broad cross-layer search: every other ingress face in this repo enforces caller authorization (A2A checks caller.perms + per-identity quota, HTTP admin/queue calls caller.require("review:read"), web checks repo:configure), but the MCP tools never consult McpCallerContext.sub — there is no middleware or transport interceptor injecting authorization for them (auth.rs authenticates only). get_review_status/list_recent_reviews/get_repository_settings discard the caller and serve real data for caller-supplied org/repo, so any valid OIDC user can read any connected repo's review findings/task metadata. P2 disable_allowed_hosts on an 0.0.0.0:8080 default also re-confirmed. New P2: start_review has no per-identity quota (A2A's equivalent face explicitly has one to stop unbounded deep-run spend), so any caller can spam deep reviews. Files just opened for depth: Cargo.toml (clean, consistent rmcp server-feature addition), main.rs (clean mcp role dispatch), auth.rs and tools.rs (support the findings). Security assessed: auth enforced, authorization entirely absent on the MCP surface plus a cost-abuse gap on start_review. Cargo.lock NOT reviewed (generated lockfile).
🤖 AI-generated review — treat it as untrusted, verify before acting; a human owns the final decision (AI governance).
|
@lightbridge-assistant please review In the latest commit, the following findings were addressed:
Regarding the finding on Traefik and |
There was a problem hiding this comment.
Lightbridge review
Opened and genuinely reviewed the two previously-unverified files: Cargo.toml (adds server/transport-streamable-http-server/macros features to the existing rmcp dependency — coherent, no new crate or security surface) and main.rs (mod mcp; + "mcp" => mcp::run(state) role arm through the standard AppState::from_env() path — minimal and correct; mcp::run fail-closes on missing DB/OIDC). No findings on either. Full review stands across all 10 changed files: P1 get_review_status exposes review findings under repo:read rather than the review:read the HTTP face requires (handler.rs:174); P2 quota TOCTOU race (handler.rs:146); P2 unbounded graph_search limit (handler.rs:222). auth.rs, mod.rs, preset.rs, main.rs, Cargo.toml clean. SAST clean; Cargo.lock NOT reviewed (generated lockfile). In-scope changes are sound and ship-ready after the P1 permission mismatch is addressed.
🤖 AI-generated review — treat it as untrusted, verify before acting; a human owns the final decision (AI governance).
…ty quota Two follow-ups from bot review round 3, agreed as worth fixing before merge: - start_review required only `repo:read` (a read-tier permission also granted for reading repo config/settings) to trigger an expensive `deep` review. Now requires a dedicated `review:trigger`, mirroring A2A's own `a2a:review` rather than any read scope for its equivalent submit_review action. Matters more once `a2a` and `mcp` share one OIDC audience (ai-helm-values #185): a token minted for read-only use must not also trigger paid runner work. - The per-identity quota was count-then-insert across two DB round trips (count_recent_mcp_runs, then record_delivery inside tools::start_review) — a TOCTOU race where concurrent calls from the same identity could all observe the same under-quota count and all proceed. Replaced with db::reserve_mcp_run_slot, which does the count and the reservation INSERT atomically in one transaction serialized per-caller by a transaction-scoped Postgres advisory lock (pg_advisory_xact_lock, auto-released on commit or rollback). count_recent_mcp_runs is removed; nothing else called it. Added test coverage for both the auth boundary and the fix: - mcp::auth — missing/invalid bearer, JWKS outage, OIDC-disabled, and the success path proving a client-supplied x-lb-mcp-caller header can never override the validated identity (mirrors a2a's auth test shape). - db::tasks::mcp_quota_tests — quota grant/deny, per-identity isolation, and the load-bearing regression: 10 concurrent reservations against max=3 assert exactly 3 succeed (fails deterministically against the old count-then-insert code, passes only with the advisory-lock fix). - mcp::tests — quota_from_env/bind_addr_from defaulting and clamping. Verified: cargo check/clippy/fmt clean; full `cargo test -p control-plane` against a local pgvector/pg17 container — 454 passed (the one pre-existing failure, a2a::handler::tests::subscribe_then_tail_delivers_a_live_transition, is an unrelated streaming-capacity flake that passes in isolation and is untouched by this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live-testing the mcp role end-to-end (real Keycloak + Postgres + Neo4j,
driven with `npx @modelcontextprotocol/inspector`) found it panics on
startup:
thread 'main' panicked at services/control-plane/src/mcp/mod.rs:80:10:
Nesting at the root is no longer supported. Use fallback_service instead.
axum 0.8 (this workspace's version) rejects `Router::nest_service("/", ...)`
at the root — `fallback_service` is the documented replacement for mounting
a service there. Nothing in the existing test suite calls `mcp::run()`
itself (unit tests build routers by hand, bypassing this line), so the
panic was invisible to CI; the role would have crash-looped on first real
deploy.
Verified end-to-end after the fix, against a live stack (docker compose
Postgres+Neo4j+Keycloak, a real signed token from Keycloak's token
endpoint, MCP Inspector as the client):
- `tools/list` returns all 6 tools with correct schemas.
- No/invalid bearer token -> 401 before reaching any tool.
- A token WITH `review:trigger` clears start_review's permission gate and
reaches business logic ("Repository not found or not connected").
- The SAME identity WITHOUT `review:trigger` is denied
("Missing required permission: review:trigger") while still succeeding
on get_repository_settings (repo:read only) with the identical token —
proving the permission-tier fix from the earlier commit is scoped
exactly to the trigger action, not a blanket denial.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…search
Design review (live-testing with MCP Inspector against a real cluster
surfaced this): start_review returned a bare UUID string with no field
name and no mention in its description that the value was a task_id to
capture — an external LLM client had no principled way to know to save
and reuse it. More broadly, none of the 6 tools declared an `outputSchema`
at all (rmcp/MCP spec support this via `Json<T>`, deriving it from a
typed, JsonSchema-annotated return value) — every result was an untyped
stringified JSON blob a client had to parse blind.
- All 5 remaining tools now return `rmcp::Json<T>` for a proper,
typed, self-describing result with a real `outputSchema`:
StartReviewResult (task_id + status + a message spelling out what to
do with it), GetReviewStatusResult, GraphSearchResult (now wraps
Vec<SymbolHit> in a named object per MCP's structuredContent
guidance, rather than a bare top-level array), GetRepositorySettingsResult,
ListRecentReviewsResult. SymbolHit (integrations/neo4j.rs) gained a
JsonSchema derive to support this; task_id fields are String, not Uuid,
since Uuid has no JsonSchema impl in this workspace's dependency set,
matching how the tools already take task_id as a String on input.
- start_review/graph_search's head_sha/commit_sha argument docs now say
explicitly that this server has no tool to look those up — a caller
needs another source (e.g. a GitHub MCP server) for them.
- Dropped `vector_search` entirely (handler.rs + tools.rs). Its
"implementation" was always a formatted-string mock. Real semantic
search needs a query embedding, and the control plane deliberately
holds no embeddings-gateway credentials — only the ephemeral per-task
runner does (see http/internal.rs's own comment on the
POST /internal/tasks/{id}/search body: "the vector MCP server embeds
the text with the runner's embeddings key; the control plane holds
none"). Shipping a mocked tool that silently returns fake results to
an external client is worse than not shipping it; giving the
long-lived, externally-reachable mcp role its own copy of that
credential is a real trust-boundary expansion, not a small addition —
worth its own follow-up decision, not folded into this PR. Confirmed
no references to it exist in the ai-helm/ai-helm-values PRs (#922/#185).
Verified: cargo check/clippy/fmt clean; full `cargo test -p control-plane`
against real Postgres — 454 passed, 1 failed (the same pre-existing
a2a::handler::tests::subscribe_then_tail_delivers_a_live_transition
streaming-capacity flake noted earlier in this PR, confirmed to pass in
isolation, untouched by this change). Also live-verified end-to-end
against a real cluster (minikube + Keycloak + Postgres + Neo4j) with
MCP Inspector: tools/list now shows 5 tools with output schemas, and
start_review's response is a proper {"task_id": ..., "status": "queued",
"message": ...} object.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. Summary
This PR changes:
mcpmodule incontrol-plane./mcpendpoints to the unified application router (Global Server Pattern).mcp_auth) interceptingAuthorization: Bearerand injectingx-lb-mcp-caller.rmcptransport skeleton for external AI client connections.start_reviewnow requires a dedicatedreview:triggerpermission instead ofrepo:read(mirroring A2A'sa2a:review, since triggering an expensivedeepreview is a write/spend action, not a read); the per-identity quota check is now atomic (db::reserve_mcp_run_slot, a single transaction under a Postgres advisory lock) instead of a racy count-then-insert; added test coverage for the auth middleware and the quota fix (including a concurrency regression test).Router::nest_service("/", …)isn't valid at the root in axum 0.8, so themcprole never actually started; swapped tofallback_service. All 5 tools now returnrmcp::Json<T>for a proper, typed, self-describing result with a realoutputSchema(previously every result was an untyped stringified JSON blob, andstart_reviewreturned a bare UUID string with no field name or documentation telling a caller it was thetask_idto poll on). Droppedvector_searchentirely — see Scope below.It solves:
2. Intent
The intent of this PR is:
3. Scope
In Scope
/mcpand/mcp/message).a2apatterns.rmcpfeature updates inCargo.toml.start_reviewgated by a dedicatedreview:triggerpermission (notrepo:read) — matters oncea2a/mcpshare one OIDC audience (ai-helm-values feat(review): M1 feedback memory — don't re-raise rejected findings (#177, ADR-0044) #185), since a read-scoped token must not also trigger paid runner work.db::reserve_mcp_run_slot) closing a TOCTOU race in the original count-then-insert check.outputSchemafor every tool (rmcp::Json<T>) — each result is now a typed, self-describing object instead of a stringified blob;start_review's response spells out thattask_idis what to pass toget_review_status.mcp::auth(unauthenticated/invalid-token/JWKS-outage/spoofed-header),db::tasks::mcp_quota_tests(grant/deny, per-identity isolation, concurrent-reservation regression),mcp::tests(quota/bind-addr defaulting).Out of Scope
vector_searchremoved, not implemented. Its only "implementation" was a formatted-string mock. Real semantic search needs a query embedding, and the control plane deliberately holds no embeddings-gateway credentials — only the ephemeral per-task runner does (http/internal.rs's own comment onPOST /internal/tasks/{id}/search: "the vector MCP server embeds the text with the runner's embeddings key; the control plane holds none"). Giving the long-lived, externally-reachablemcprole its own copy of that credential is a real trust-boundary expansion — worth its own follow-up decision, not folded into this PR. Shipping a tool that silently returns fake results to an external client was worse than not shipping it.Fully Implemented Tools
Note: All tools enforce the global
repo:read/review:readauthorization permission as appropriate;start_reviewrequiresreview:trigger.start_reviewalso strictly enforces theMCP_QUOTA_MAXper-identity submission limit, atomically.This PR implements and wires the following stateless tools directly to the control plane databases (reusing internal logic):
start_review: Trigger a deep code review on a PR. Returns{task_id, status, message}.get_review_status: Check the status and findings of a review.graph_search: Query the Neo4j structural code graph (find_symbol,get_callers).get_repository_settings: Fetch repo configurations.list_recent_reviews: Retrieve recently reviewed tasks.4. Verification
I verified this change by:
Commands run:
cargo check -p control-plane --tests cargo clippy -p control-plane --all-targets --locked -- -D warnings cargo fmt --all -- --check DATABASE_URL=postgres://lightbridge:lightbridge@127.0.0.1:5432/lightbridge cargo test -p control-plane --lockedResults:
Manual end-to-end verification against a real, live-deployed cluster (minikube: Postgres, Neo4j,
Keycloak, and the actual
mcpDeployment built from this branch — not mocks), driven withnpx @modelcontextprotocol/inspectorover real StreamableHTTP:5. Screenshots / Evidence
Add evidence here:
6. Risk Assessment
Risk level:
Potential risks:
disable_allowed_hosts()on the0.0.0.0listener has no NetworkPolicy backstop yet inai-helm— tracked as a follow-up in ai-helm#922, not blocking here since the mcp role shipsenabled: falseby default.Mitigation:
start_reviewnow requires the dedicatedreview:triggerpermission and an atomic per-identity quota (this update).7. AI Usage Declaration
AI was used for:
Human verification:
8. Reviewer Focus
Please focus your review on: