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.
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:
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 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.
## 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)
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
mcpt_<43 url-safe random chars>(48 total). Opaque, never embeds tenant_id, never stored in plaintext.<prefix>__admin.mcp_tokenscollection, keyed by SHA-256 hash. Carriestenant_id,name,created_by,created_at,last_used_at,revoked.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 revokeAuthorization: Bearer mcpt_..., sniff prefix, SHA-256 → lookup in admin DB → reject if missing or revoked.last_used_atupdates fire-and-forget. Setstokio::task_local!TENANT_IDfor the inner service call.TENANT_ID, resolve to per-tenant DB viapool.for_tenant_id(...), call the tool fn unchanged.Why
task_local!: rmcp's macro-generated tool router gives the handlers&selfand the tool params — no access to request extensions.TENANT_ID.scope(...)wrapsnext.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:
Then in Claude Desktop's
claude_desktop_config.json:Test plan
cargo fmt --all -- --checkcleancargo clippy --workspace --exclude compliance-dashboard -- -D warningscleancargo test -p compliance-core --lib— 7 passcargo test -p compliance-agent --lib— 230 pass (+2 new for token generation + sha256 stability)cargo test -p compliance-agent --test tenant_isolation— 6 passcargo test -p compliance-mcp— 34 pass (+1 new sha256 vector against a known python-computed hash)What's deferred
DatabasePoolintocompliance-core— duplicated for now incompliance-mcpto keep this PR focused. Lift if a third consumer appears.Production notes
<prefix>__adminDB 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.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).https://comp-mcp-dev.meghsakha.com/mcpwithout a bearer will start getting 401. The MCP endpoint was effectively unauthenticated until now; this is the intended security change.🤖 Generated with Claude Code
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>628f346529toe3c9b2d3a1