feat(mcp): read-only, token-claim-bound tiered-redaction MCP server (JEF-488)#263
Merged
thejefflarson merged 3 commits intoJul 23, 2026
Conversation
…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
enabled auto-merge (squash)
July 23, 2026 04:16
…ver-api-with-token-claim-bound-tiered
thejefflarson
deleted the
thejefflarson/jef-488-read-only-mcp-server-over-api-with-token-claim-bound-tiered
branch
July 23, 2026 04:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A read-only MCP server over the same data
/apiserves — 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 direngine/src/engine/mcp/(every file < 1000 lines).rmcp spike — SUCCEEDED
rmcp = 2.2.0(featuresserver, macros, transport-streamable-http-server, tower, server-side-http). The streamable-HTTPStreamableHttpServicemounts as an axum service BEHIND our OIDC verifier layer: an unauthenticated MCP call is rejected by the SAMEauthenticate()seam as/api(401) before rmcp is reached, and a verifiedIdentityreaches the tool handler via a request extension (rmcp propagateshttp::request::Parts, whose.extensionscarry theIdentityour 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(optionaltier)explain_verdict(entryrequired, optionaltier) — the only per-entry path torawget_coverage(optionaltier)signing_inventory(optionaltier)A test asserts the surface is exactly these four and rejects an unknown/actuation tool name.
Tier ceiling + redaction
Tier), clampedmin(requested, ceiling)server-side — thetierarg can only narrow. Bulk tools cap atforensicso raw secret names are per-entry only (no dump-all-at-raw).{tier, withheld:[{kind,count,unlock}]}manifest.redacted= verdict/counts/technique IDs/coverage/freshness;forensicadds CVE ids + reachability + paths + judgement prompt/reply (secret names still scrubbed);rawadds secret names — never values (no value read path exists).Audit seam (JEF-490)
AuditSinktrait +TracingAuditSink(default) +RecordingAuditSink(tests). Every forensic/raw access emitssubject·entry·tool·tier·timebound to the verifiedsub. 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) + aWWW-Authenticate: Bearerchallenge on every denial, so an ID-JAG-capable client onboards zero-touch.Wiring
run_loopspawns the MCP server only whenPROTECTOR_MCP_ADDRis set AND OIDC is configured (fail-closed — a tiered cluster-data surface never runs unauthenticated). Also threads the proven chain'sAttackRefonto the publishedFindingso the technique ID surfaces atredactedwithout 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 --checkclean,cargo clippy --all-targets -D warningsclean, fullcargo testgreen (878 lib + integration), file-size guard passes.Decisions recorded
redacted, the entry workload key is treated as a path/topology fact (forensic+); aredactedclient references findings via a stable non-reversible opaqueref(FNV hash of the entry) whichexplain_verdictalso 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_inventorycap atforensic(they carry no secret names, sorawadds nothing).Closes JEF-488
🤖 Generated with Claude Code
https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP