feat(m7.3): MCP tenant-scoped bearer tokens #92

Open
sharang wants to merge 1 commits from feat/m7.3-mcp-tenant-tokens into main
Owner

Summary

LLM clients (Claude Desktop, Cursor, ChatGPT) can't run Keycloak OIDC, so the MCP server can't gate on JWTs. This PR introduces opaque static bearer tokens minted per-tenant via new agent endpoints, validated by the MCP server, used to route every incoming MCP request to the caller's per-tenant database.

After M7.2, the MCP server was the lone cross-tenant data leak — every tool returned data across all tenants. This closes it.

Design

Piece Decision
Token format mcpt_<43 url-safe random chars> (48 total). Opaque, never embeds tenant_id, never stored in plaintext.
Storage Cross-tenant <prefix>__admin.mcp_tokens collection, keyed by SHA-256 hash. Carries tenant_id, name, created_by, created_at, last_used_at, revoked.
Agent endpoints (tenant-scoped via TenantCtx) POST /api/v1/mcp-tokens → mint (returns raw token once); GET /api/v1/mcp-tokens → list metadata + 12-char prefix; DELETE /api/v1/mcp-tokens/{id} → soft revoke
MCP middleware Extract Authorization: Bearer mcpt_..., sniff prefix, SHA-256 → lookup in admin DB → reject if missing or revoked. last_used_at updates fire-and-forget. Sets tokio::task_local! TENANT_ID for the inner service call.
Tool handlers Each of the 12 read TENANT_ID, resolve to per-tenant DB via pool.for_tenant_id(...), call the tool fn unchanged.

Why task_local!: rmcp's macro-generated tool router gives the handlers &self and the tool params — no access to request extensions. TENANT_ID.scope(...) wraps next.run(req) so handlers downstream see the tenant_id without modifying their signatures.

Token UX

Raw token shown once at creation. User copies into their LLM client config. Dashboard UI for management is deferred to a follow-up; you can use curl in the meantime:

curl -X POST https://comp-dev.meghsakha.com/api/v1/mcp-tokens \
  -H "Authorization: Bearer $KC_JWT" \
  -H "Content-Type: application/json" \
  -d '{"name":"Claude Desktop"}'

Then in Claude Desktop's claude_desktop_config.json:

{
  "mcpServers": {
    "compliance-scanner": {
      "command": "...",
      "url": "https://comp-mcp-dev.meghsakha.com/mcp",
      "headers": { "Authorization": "Bearer mcpt_..." }
    }
  }
}

Test plan

  • cargo fmt --all -- --check clean
  • cargo clippy --workspace --exclude compliance-dashboard -- -D warnings clean
  • cargo test -p compliance-core --lib — 7 pass
  • cargo test -p compliance-agent --lib230 pass (+2 new for token generation + sha256 stability)
  • cargo test -p compliance-agent --test tenant_isolation — 6 pass
  • cargo test -p compliance-mcp34 pass (+1 new sha256 vector against a known python-computed hash)

What's deferred

  • Dashboard UI for managing tokens (page + create modal + list/revoke). Mechanical once the API is live.
  • Token expiry + per-tool scope (today every token grants all 12 tools for its tenant).
  • Lifting DatabasePool into compliance-core — duplicated for now in compliance-mcp to keep this PR focused. Lift if a third consumer appears.

Production notes

  • <prefix>__admin DB name (double underscore) won't collide with <prefix>_<sanitized_tenant_id> for current UUID-shaped tenant_ids. Flagged in the database.rs docstring so future tenant provisioning can reject _admin* ids proactively.
  • orca-infra mcp service block already has MONGODB_URI + MONGODB_DATABASE — no new env needed. No KC creds since MCP doesn't use Keycloak for its own auth (that's the whole point of this PR).
  • Breaking for existing MCP callers: any current users of https://comp-mcp-dev.meghsakha.com/mcp without a bearer will start getting 401. The MCP endpoint was effectively unauthenticated until now; this is the intended security change.

🤖 Generated with Claude Code

## Summary LLM clients (Claude Desktop, Cursor, ChatGPT) can't run Keycloak OIDC, so the MCP server can't gate on JWTs. This PR introduces **opaque static bearer tokens minted per-tenant** via new agent endpoints, validated by the MCP server, used to route every incoming MCP request to the caller's per-tenant database. After M7.2, the MCP server was the lone cross-tenant data leak — every tool returned data across all tenants. This closes it. ## Design | Piece | Decision | |---|---| | Token format | `mcpt_<43 url-safe random chars>` (48 total). Opaque, never embeds tenant_id, never stored in plaintext. | | Storage | Cross-tenant `<prefix>__admin.mcp_tokens` collection, keyed by SHA-256 hash. Carries `tenant_id`, `name`, `created_by`, `created_at`, `last_used_at`, `revoked`. | | Agent endpoints (tenant-scoped via `TenantCtx`) | `POST /api/v1/mcp-tokens` → mint (returns raw token **once**); `GET /api/v1/mcp-tokens` → list metadata + 12-char prefix; `DELETE /api/v1/mcp-tokens/{id}` → soft revoke | | MCP middleware | Extract `Authorization: Bearer mcpt_...`, sniff prefix, SHA-256 → lookup in admin DB → reject if missing or revoked. `last_used_at` updates fire-and-forget. Sets `tokio::task_local!` `TENANT_ID` for the inner service call. | | Tool handlers | Each of the 12 read `TENANT_ID`, resolve to per-tenant DB via `pool.for_tenant_id(...)`, call the tool fn unchanged. | Why `task_local!`: rmcp's macro-generated tool router gives the handlers `&self` and the tool params — no access to request extensions. `TENANT_ID.scope(...)` wraps `next.run(req)` so handlers downstream see the tenant_id without modifying their signatures. ## Token UX Raw token shown **once** at creation. User copies into their LLM client config. Dashboard UI for management is deferred to a follow-up; you can use curl in the meantime: ```bash curl -X POST https://comp-dev.meghsakha.com/api/v1/mcp-tokens \ -H "Authorization: Bearer $KC_JWT" \ -H "Content-Type: application/json" \ -d '{"name":"Claude Desktop"}' ``` Then in Claude Desktop's `claude_desktop_config.json`: ```json { "mcpServers": { "compliance-scanner": { "command": "...", "url": "https://comp-mcp-dev.meghsakha.com/mcp", "headers": { "Authorization": "Bearer mcpt_..." } } } } ``` ## Test plan - [x] `cargo fmt --all -- --check` clean - [x] `cargo clippy --workspace --exclude compliance-dashboard -- -D warnings` clean - [x] `cargo test -p compliance-core --lib` — 7 pass - [x] `cargo test -p compliance-agent --lib` — **230 pass** (+2 new for token generation + sha256 stability) - [x] `cargo test -p compliance-agent --test tenant_isolation` — 6 pass - [x] `cargo test -p compliance-mcp` — **34 pass** (+1 new sha256 vector against a known python-computed hash) ## What's deferred - Dashboard UI for managing tokens (page + create modal + list/revoke). Mechanical once the API is live. - Token expiry + per-tool scope (today every token grants all 12 tools for its tenant). - Lifting `DatabasePool` into `compliance-core` — duplicated for now in `compliance-mcp` to keep this PR focused. Lift if a third consumer appears. ## Production notes - `<prefix>__admin` DB name (double underscore) won't collide with `<prefix>_<sanitized_tenant_id>` for current UUID-shaped tenant_ids. Flagged in the database.rs docstring so future tenant provisioning can reject `_admin*` ids proactively. - orca-infra mcp service block already has `MONGODB_URI` + `MONGODB_DATABASE` — no new env needed. No KC creds since MCP doesn't use Keycloak for its own auth (that's the whole point of this PR). - **Breaking for existing MCP callers**: any current users of `https://comp-mcp-dev.meghsakha.com/mcp` without a bearer will start getting 401. The MCP endpoint was effectively unauthenticated until now; this is the intended security change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
sharang added 1 commit 2026-06-18 09:54:38 +00:00
feat(m7.3): MCP tenant-scoped bearer tokens
CI / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
628f346529
LLM clients (Claude Desktop, Cursor, ChatGPT) can't run a Keycloak
OIDC flow, so the MCP server can't use JWTs for auth. This PR
introduces opaque static bearer tokens minted per-tenant via new
agent endpoints, validated by the MCP server, and used to route
incoming MCP requests to the caller's per-tenant database.

Until now, the MCP server connected to a single shared MongoDB DB
with no auth and no tenant awareness — every tool (list_findings,
list_sbom_packages, etc.) returned data across all tenants. After
M7.2 made the agent per-tenant, MCP was the lone cross-tenant data
leak. This closes it.

Design summary
- Token format: `mcpt_<43 url-safe random chars>` (48 chars total).
  Opaque, never embeds tenant_id, never stored in plaintext.
- Storage: cross-tenant `<prefix>__admin.mcp_tokens` collection,
  keyed by SHA-256 hash. Each row carries the tenant_id, name,
  created_by, created_at, last_used_at, revoked flag.
- Agent endpoints (tenant-scoped via TenantCtx):
    POST   /api/v1/mcp-tokens    → mint (returns raw token ONCE)
    GET    /api/v1/mcp-tokens    → list (metadata + 12-char prefix,
                                   never the hash)
    DELETE /api/v1/mcp-tokens/id → soft revoke
- MCP middleware: extract `Authorization: Bearer mcpt_...`, sniff
  the prefix, SHA-256 → lookup in admin DB → reject if missing or
  revoked. Updates last_used_at fire-and-forget so it never blocks.
  Sets `tokio::task_local!` TENANT_ID for the inner service call;
  the rmcp tool handlers read it and resolve the per-tenant DB.
- task_local is scoped via TENANT_ID.scope(...) around next.run(req)
  so the rmcp tool handlers downstream see the tenant_id without
  modifying their (macro-generated) signatures.

Files
- compliance-core/src/models/mcp_token.rs (new) — McpToken +
  McpTokenView (public projection without the hash).
- compliance-agent/src/database.rs — DatabasePool::admin_db() +
  admin_db_name(): cross-tenant access for token storage.
- compliance-agent/src/api/handlers/mcp_tokens.rs (new) — three
  endpoints. Token generation: 32 random bytes → URL-safe base64,
  no padding. SHA-256 hex stored.
- compliance-mcp/src/database.rs — replaced single Database with
  DatabasePool. Tenant-scoped Database constructed per request.
  Same sanitization + 63-byte cap + hash fallback as the agent.
- compliance-mcp/src/auth.rs (new) — bearer middleware + task_local.
  Includes a SHA-256 round-trip test against a known vector.
- compliance-mcp/src/main.rs — HTTP transport: bearer middleware
  layered on /mcp (not /health, so orca's container probe still
  works). stdio transport: falls back to STDIO_TENANT_ID env (defaults
  to "dev") so local development still works; logged loudly as
  not-for-production.
- compliance-mcp/src/server.rs — each of the 12 tool handlers
  resolves the per-tenant DB via task_local before calling its tool
  fn. Tool fns themselves are unchanged.

Token UX
- Generated by the dashboard (or curl + KC JWT) — user sees raw
  token exactly once, copies it into their LLM client config.
- Dashboard UI for management is a follow-up; can use curl in the
  meantime:
    curl -X POST https://comp-dev.../api/v1/mcp-tokens \
      -H "Authorization: Bearer $KC_JWT" \
      -H "Content-Type: application/json" \
      -d '{"name":"Claude Desktop"}'

Test plan
- cargo fmt --all clean
- cargo clippy --workspace --exclude compliance-dashboard
  -- -D warnings clean
- cargo test -p compliance-core --lib — 7 pass
- cargo test -p compliance-agent --lib — 230 pass (+2 new for
  token generation + sha256 stability)
- cargo test -p compliance-agent --test tenant_isolation — 6 pass
- cargo test -p compliance-mcp — 34 pass (+1 new sha256 vector)

What's deferred
- Dashboard UI for managing tokens (page + create modal + list/
  revoke). Trivial once the API is live.
- Token expiry + per-tool scope (today every token grants access
  to all 12 tools for its tenant).
- Lifting DatabasePool into compliance-core (duplicated for now
  in compliance-mcp to keep this PR focused; lift if a third
  consumer appears).

Production
- The `<prefix>__admin` DB needs to NOT collide with a tenant
  DB. Sanitized tenant_id never starts with `_admin` for any
  current tenant_id shape (UUIDs); flagged in the database.rs
  docstring so tenant provisioning can reject `_admin*` ids
  proactively.
- orca-infra MCP service block already has MONGODB_URI /
  MONGODB_DATABASE — no new env needed. No KC creds since MCP
  doesn't use Keycloak for its own auth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Some checks are pending
CI / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/m7.3-mcp-tenant-tokens:feat/m7.3-mcp-tenant-tokens
git checkout feat/m7.3-mcp-tenant-tokens
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sharang/compliance-scanner-agent#92