Skip to content

feat(mcp): read-only, token-claim-bound tiered-redaction MCP server (JEF-488)#263

Merged
thejefflarson merged 3 commits into
mainfrom
thejefflarson/jef-488-read-only-mcp-server-over-api-with-token-claim-bound-tiered
Jul 23, 2026
Merged

feat(mcp): read-only, token-claim-bound tiered-redaction MCP server (JEF-488)#263
thejefflarson merged 3 commits into
mainfrom
thejefflarson/jef-488-read-only-mcp-server-over-api-with-token-claim-bound-tiered

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

What

A read-only MCP server over the same data /api serves — the pull-side sibling of the ADR-0018 breach notifier and the second sanctioned egress carve-out (ADR-0031). Served on its own opt-in bind (PROTECTOR_MCP_ADDR, unset = not served), riding the same OIDC verifier as the dashboard (ADR-0030) and the same shared redaction module (ADR-0018/0031). New code lives in a focused module dir engine/src/engine/mcp/ (every file < 1000 lines).

rmcp spike — SUCCEEDED

rmcp = 2.2.0 (features server, macros, transport-streamable-http-server, tower, server-side-http). The streamable-HTTP StreamableHttpService mounts as an axum service BEHIND our OIDC verifier layer: an unauthenticated MCP call is rejected by the SAME authenticate() seam as /api (401) before rmcp is reached, and a verified Identity reaches the tool handler via a request extension (rmcp propagates http::request::Parts, whose .extensions carry the Identity our auth layer inserted). Every trust decision — verify, tier ceiling, redact, journal — stays in our code above rmcp; rmcp is transport below the boundary. The ADR's hand-rolled fallback was not needed.

The four tools (read-only; no actuation tool exists by construction)

  • list_findings (optional tier)
  • explain_verdict (entry required, optional tier) — the only per-entry path to raw
  • get_coverage (optional tier)
  • signing_inventory (optional tier)

A test asserts the surface is exactly these four and rejects an unknown/actuation tool name.

Tier ceiling + redaction

  • Ceiling from the verified token claim (JEF-485 Tier), clamped min(requested, ceiling) server-side — the tier arg can only narrow. Bulk tools cap at forensic so raw secret names are per-entry only (no dump-all-at-raw).
  • Per-entry redaction (never a bulk filter) with the withheld ≠ omitted contract: every field is present as a typed sentinel below its unlock tier, and every response carries a top-level {tier, withheld:[{kind,count,unlock}]} manifest. redacted = verdict/counts/technique IDs/coverage/freshness; forensic adds CVE ids + reachability + paths + judgement prompt/reply (secret names still scrubbed); raw adds secret names — never values (no value read path exists).

Audit seam (JEF-490)

AuditSink trait + TracingAuditSink (default) + RecordingAuditSink (tests). Every forensic/raw access emits subject·entry·tool·tier·time bound to the verified sub. The durable sink + the "Access" dashboard tab are JEF-490 — they wire a durable impl of this same trait; nothing in the tool/tier code changes.

ID-JAG

Unauthenticated .well-known/oauth-protected-resource (advertises the operator's issuer) + a WWW-Authenticate: Bearer challenge on every denial, so an ID-JAG-capable client onboards zero-touch.

Wiring

run_loop spawns the MCP server only when PROTECTOR_MCP_ADDR is set AND OIDC is configured (fail-closed — a tiered cluster-data surface never runs unauthenticated). Also threads the proven chain's AttackRef onto the published Finding so the technique ID surfaces at redacted without re-walking the graph.

Tests

18 MCP tests (tier clamp incl. redacted-token-requests-raw; redacted/forensic/raw per-entry redaction asserting no secret name/CVE/path leaks below tier; audit emission on raw; the exactly-four surface; and the rmcp-behind-verifier compose spike: unauth→401 + WWW-Authenticate, valid token passes to rmcp, verified identity reaches the handler via a request extension, well-known discovery). Reuses the JEF-485 in-test RSA keypair + injected JWKS.

Gates: cargo fmt --check clean, cargo clippy --all-targets -D warnings clean, full cargo test green (878 lib + integration), file-size guard passes.

Decisions recorded

  • At redacted, the entry workload key is treated as a path/topology fact (forensic+); a redacted client references findings via a stable non-reversible opaque ref (FNV hash of the entry) which explain_verdict also accepts. This is stricter than the notifier (which emits the entry at redacted) and matches ADR-0031 §2's "no paths/topology" for the redacted tier.
  • get_coverage/signing_inventory cap at forensic (they carry no secret names, so raw adds nothing).

Closes JEF-488

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

thejefflarson and others added 2 commits July 22, 2026 20:54
…JEF-488)

A second sanctioned egress carve-out (ADR-0031): a read-only MCP server on
its own opt-in bind (PROTECTOR_MCP_ADDR), riding the SAME OIDC verifier as
the dashboard (ADR-0030) and the SAME shared redaction module (ADR-0018/0031).

- Transport: rmcp 2.2.0 (streamable-HTTP) mounted BEHIND our OIDC verifier
  layer — the spike SUCCEEDED. An unauthenticated MCP call is a 401 on the
  same authenticate() path as /api, before rmcp is reached; a verified
  Identity reaches the tool handler via a request extension (rmcp propagates
  http::request::Parts). Every trust decision stays in our code above rmcp.
- Exactly four READ-ONLY tools (list_findings, explain_verdict, get_coverage,
  signing_inventory); no actuation tool exists by construction (asserted).
- Tier ceiling from the VERIFIED token claim, clamped min(requested, ceiling)
  server-side; bulk tools cap at forensic so raw secret NAMES are per-entry
  only (explain_verdict) — no dump-all-at-raw path.
- Per-entry tiered redaction with the withheld-not-omitted contract: typed
  sentinels + a top-level {tier, withheld:[{kind,count,unlock}]} manifest.
  redacted = counts/verdicts/techniques only; forensic adds CVE ids + paths +
  judgement prompt/reply (secret names still scrubbed); raw adds secret names,
  never values (no value read path exists).
- Audit seam (trait + tracing sink + in-memory test sink): every forensic/raw
  access emits subject·entry·tool·tier·time. Durable sink + Access tab = JEF-490.
- ID-JAG discovery: unauthenticated .well-known/oauth-protected-resource +
  WWW-Authenticate challenge on denial.
- run_loop spawns the server only when PROTECTOR_MCP_ADDR is set and OIDC is
  configured (fail-closed; the tiered surface never runs unauthenticated).
- Threads the chain's ATT&CK ref onto the published Finding so the technique
  ID surfaces at the redacted tier without re-walking the graph.

Closes JEF-488

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
…overnors (JEF-488)

Two HIGH findings from security review of PR #263.

FINDING 1 — redacted tier leaked graph TOPOLOGY via the free-text verdict
reason. The judge model's `why` prose routinely echoes the entry/namespace/
peer/path it reasoned over, which the shared scrubbers cannot strip (they only
remove SECRET names + CVE tokens). So the free-text reason is now WITHHELD below
forensic: at redacted, emit only the static verdict LABEL plus a
`[redacted — verdict reason; forensic tier required]` sentinel (+ a
`verdict_reason` withheld-manifest entry); at forensic+ (paths/topology already
disclosed) emit the scrubbed reason as before. Non-topology redacted fields
(label, objective count, technique IDs, coverage, freshness) unchanged.

FINDING 2 — no resource governor on the MCP mount (DoS/OOM). Wrapped the rmcp
route in a tower limit stack: RequestBodyLimitLayer (256 KiB, OUTERMOST so an
oversized body is rejected before verify/parse), TimeoutLayer (30s), and
ConcurrencyLimitLayer (64). Switched rmcp to STATELESS json_response mode so no
per-session task / keep-alive SSE stream can accumulate and the per-request
timeout is safe. Also guard PROTECTOR_MCP_ALLOWED_HOSTS so an all-empty parse
(e.g. ",") keeps the loopback DNS-rebinding default instead of allow-all.

Tests: redacted-reason-topology withholding (asserts model-echoed workload/
namespace/path absent at redacted + sentinel present, same prose present at
forensic); oversize-body rejected 413 while a normal body passes. Full suite
green; fmt + clippy -D warnings clean; no file >1000 lines.

Refs JEF-488

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson enabled auto-merge (squash) July 23, 2026 04:16
@thejefflarson
thejefflarson merged commit ec90089 into main Jul 23, 2026
5 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-488-read-only-mcp-server-over-api-with-token-claim-bound-tiered branch July 23, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant