Skip to content

feat(control-plane): Add standalone MCP server role - #591

Open
leghadjeu-christian wants to merge 15 commits into
mainfrom
feat/mcp-standalone-role
Open

feat(control-plane): Add standalone MCP server role#591
leghadjeu-christian wants to merge 15 commits into
mainfrom
feat/mcp-standalone-role

Conversation

@leghadjeu-christian

@leghadjeu-christian leghadjeu-christian commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

1. Summary

This PR changes:

  • Creates a new mcp module in control-plane.
  • Mounts /mcp endpoints to the unified application router (Global Server Pattern).
  • Adds an OIDC auth middleware (mcp_auth) intercepting Authorization: Bearer and injecting x-lb-mcp-caller.
  • Prepares the rmcp transport skeleton for external AI client connections.
  • Update (review round 4): start_review now requires a dedicated review:trigger permission instead of repo:read (mirroring A2A's a2a:review, since triggering an expensive deep review 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).
  • Update (review round 5, after live end-to-end testing against a real cluster with MCP Inspector): fixed a startup panic — Router::nest_service("/", …) isn't valid at the root in axum 0.8, so the mcp role never actually started; swapped to fallback_service. All 5 tools now return rmcp::Json<T> for a proper, typed, self-describing result with a real outputSchema (previously every result was an untyped stringified JSON blob, and start_review returned a bare UUID string with no field name or documentation telling a caller it was the task_id to poll on). Dropped vector_search entirely — see Scope below.

It solves:


2. Intent

The intent of this PR is:

To expose high-level project-intelligence tools (start_review, graph_search, etc.) over an MCP server hosted centrally in the control-plane. This uses the Global Server Pattern—exposing a single /mcp root endpoint, where tools require repository identifiers in their JSON Schema arguments. This allows external third-party clients (like Claude Desktop or Cursor) to interact directly with OpenCode without reusing low-level agent-runner tools like read_file or needing per-repository client configurations. Confirmed as the intended direction over #508's original "expose review-mcp verbatim" framing — this is a deliberate, accepted pivot to a narrower, control-plane-native tool surface.


3. Scope

In Scope

  • Global Server Pattern routing (/mcp and /mcp/message).
  • OIDC Authentication middleware matching a2a patterns.
  • Routing framework and rmcp feature updates in Cargo.toml.
  • start_review gated by a dedicated review:trigger permission (not repo:read) — matters once a2a/mcp share 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.
  • Atomic per-identity quota (db::reserve_mcp_run_slot) closing a TOCTOU race in the original count-then-insert check.
  • Structured outputSchema for 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 that task_id is what to pass to get_review_status.
  • Test coverage: 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_search removed, 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 on POST /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-reachable mcp role 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.
  • Full end-to-end tool-call integration tests over the live transport via an automated harness (this was instead verified manually end-to-end against a real cluster — see Verification) — wiring an rmcp streamable-HTTP test client into CI is a further follow-up.

Fully Implemented Tools

Note: All tools enforce the global repo:read/review:read authorization permission as appropriate; start_review requires review:trigger. start_review also strictly enforces the MCP_QUOTA_MAX per-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:

  • Running automated tests
  • Running manual tests
  • Checking logs
  • Checking metrics
  • Testing error cases
  • Testing permissions/security behavior
  • Testing rollback or failure behavior, if relevant

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 --locked

Results:

cargo check / clippy / fmt: clean, no warnings.

cargo test -p control-plane (against a real Postgres):
  454 passed; 1 failed; 2 ignored
  - The 1 failure (a2a::handler::tests::subscribe_then_tail_delivers_a_live_transition) is a
    pre-existing streaming-capacity flake under full-suite parallel load, unrelated to this
    change (a2a/handler/tests.rs is untouched) — passes when run in isolation.

Manual end-to-end verification against a real, live-deployed cluster (minikube: Postgres, Neo4j,
Keycloak, and the actual mcp Deployment built from this branch — not mocks), driven with
npx @modelcontextprotocol/inspector over real StreamableHTTP:

- No/invalid bearer token -> 401 before reaching any tool.
- tools/list -> 5 tools, each with a real inputSchema AND outputSchema.
- A real Keycloak-signed token WITH `review:trigger` clears start_review's permission gate and
  returns {"task_id": "...", "status": "queued", "message": "Poll get_review_status..."} for a
  real approved repo (creates/dedupes a real row in `tasks`).
- 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.
- get_review_status against a real historical task_id returns the real posted review (summary,
  3 findings, GitHub review URL) for an actual PR.
- graph_search executes a real Cypher query against Neo4j and returns an empty (not erroring)
  result set when nothing is indexed for that repo/commit yet — proves the query pipeline works
  end-to-end even with no data.

5. Screenshots / Evidence

Add evidence here:

  • N/A (Backend route scaffolding)

6. Risk Assessment

Risk level:

  • Low
  • Medium
  • High

Potential risks:

  • SSE connection scaling limits.
  • disable_allowed_hosts() on the 0.0.0.0 listener has no NetworkPolicy backstop yet in ai-helm — tracked as a follow-up in ai-helm#922, not blocking here since the mcp role ships enabled: false by default.

Mitigation:

  • Protected behind OIDC middleware, ensuring only authenticated traffic reaches the endpoints.
  • start_review now requires the dedicated review:trigger permission and an atomic per-identity quota (this update).

7. AI Usage Declaration

AI was used for:

  • Understanding existing code
  • Generating code
  • Refactoring
  • Generating tests
  • Drafting documentation
  • Reviewing the diff
  • Not used

Human verification:

  • I understand every meaningful change in this PR
  • I checked generated code manually
  • I checked generated tests manually
  • I removed unsupported AI assumptions
  • I accept responsibility for this PR

8. Reviewer Focus

Please focus your review on:

  • Architecture
  • Security (mcp_auth logic, review:trigger permission, atomic quota)
  • Tests

- 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
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

✅ AI Governance check passed

This PR declares AI usage, references a source of truth, and provides verification evidence. Thank you.

@leghadjeu-christian

Copy link
Copy Markdown
Contributor Author

@lightbridge-assistant review

@lightbridge-assistant lightbridge-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread services/control-plane/src/mcp/handler.rs Outdated
Comment thread services/control-plane/src/mcp/mod.rs
@leghadjeu-christian

Copy link
Copy Markdown
Contributor Author

and disable_allowed_hosts() removes rmcp's DNS-rebinding/Host guard on the 0.0.0.0 listener.

This is intentional and safe in our architecture. The control-plane runs in a Kubernetes cluster behind Traefik. Traefik acts as the ingress controller and strictly validates the Host header at the edge (e.g., matching the code-intelligence-api.ai.camer.digital routing rule) before the request ever reaches the pod. If we didn't disable the internal library's host check, the library would reject the requests because it wouldn't recognize the external domain as its own internal 0.0.0.0 bind address. We are securely delegating the Host validation to the infrastructure layer.

@leghadjeu-christian

Copy link
Copy Markdown
Contributor Author

@lightbridge-assistant review

@lightbridge-assistant lightbridge-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread services/control-plane/src/mcp/handler.rs Outdated
Comment thread services/control-plane/src/mcp/mod.rs
Comment thread services/control-plane/src/mcp/tools.rs
@leghadjeu-christian

Copy link
Copy Markdown
Contributor Author

@lightbridge-assistant please review

In the latest commit, the following findings were addressed:

  1. IDOR Vulnerability: All MCP tools now enforce the repo:read global authorization permission by verifying the Keycloak JWT claims inside the request context.
  2. Quota/DoS: The start_review tool now strictly enforces a per-identity quota by tracking recent requests against a configurable limit (MCP_QUOTA_MAX).

Regarding the finding on Traefik and disable_allowed_hosts():
No changes were made here intentionally. Traefik acts as our edge proxy and ingress controller, handling Host header validation and TLS termination at the Kubernetes cluster edge. The control-plane upstream service sits securely inside the cluster, so enforcing host headers at the application level would unnecessarily complicate internal routing. Furthermore, the MCP endpoints remain strictly protected by our OIDC JWT authentication middleware.

@lightbridge-assistant lightbridge-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread services/control-plane/src/mcp/handler.rs Outdated
Comment thread services/control-plane/src/mcp/handler.rs Outdated
Comment thread services/control-plane/src/mcp/handler.rs Outdated
leghadjeu-christian and others added 4 commits August 4, 2026 15:15
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant