feat: route tunneled MCP credentials by recorded resource identity - #5905
Conversation
🦋 Changeset detectedLatest commit: afc5cdc The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Running ultrareview automatically — Reworking tunneled MCP credential routing: removes lone-token fallback, adds resource_identifier across API, SQL LEFT JOINs, and both routers, with fail-closed semantics — auth/credential-forwarding changes with high blast radius if a subtle routing or join bug slips through.. I'll post findings when complete. |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Ultrareview completed in 9m 34s
All reported issues were addressed across 30 files
Linked issue analysis
Linked issue: AIM-151: Give tunneled backends an upstream identity so their credentials can be routed
| Status | Acceptance criteria | Notes |
|---|---|---|
| ✅ | Tunneled servers can record an optional RFC 9728 resource identifier that is validated, normalized, exposed through the API, and never dialed. | The PR adds create/update/read API fields, URI validation and normalization, persistence plumbing, documentation stating the value is never dialed, and tests for normalization, clearing, and invalid values. |
| ✅ | Consent/grant minting records the tunneled server's resource identifier as the RFC 8707 resource on both direct and gateway surfaces. | Both consent derivation queries use the tunneled resource identifier, and tests verify direct-surface derivation and meta-member consent stamping. |
| ✅ | Credentials route by exact resource match on both serving surfaces, without forwarding an unmatched lone credential. | The lone-token fallback is removed, exact matching is shared by the direct and meta routers, and tests cover matches, mismatches, duplicates, and legacy unqualified grants. |
| ✅ | A credential is not forwarded to a backend for which it was not granted, while identifierless tunneled compatibility paths remain fail-closed or backend-specific. | Resource mismatches and audience-qualified issuer entries are rejected; identifierless tunneled servers only use their own unqualified issuer entry or make an anonymous call. |
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 26 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
Follow-up from a second review pass — the direct-surface derivation no longer stamps tunneled identifiers (108992e). What was wrong. Adding Why removing it is free. Since tunneled backends now route by their own derived The cost is that a co-hosted authorization server no longer receives Two tests pin it — a tunneled server contributes nothing with or without an identifier, and a remote sibling sharing its issuer stays qualified to its own URL. Both fail against the previous revision. Still open and not addressed here, from the same pass: a client whose issuer spans several remote upstreams derives |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gm43HhrAUR6poQA5xKK9W
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gm43HhrAUR6poQA5xKK9W
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gm43HhrAUR6poQA5xKK9W
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gm43HhrAUR6poQA5xKK9W
108992e to
17adc3b
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gm43HhrAUR6poQA5xKK9W
|
Manual end-to-end check on a local stack running this branch: gram-server, tunnel-gateway, a real Setup. One gateway (meta MCP) fronting two members: a tunneled source (agent → local backend) and a remote server. Full OAuth on the gateway — DCR, authorize, consent approve, token exchange — then MCP calls with the resulting bearer. Result.
Field behavior. Not covered. Credential selection — that needs a per-user grant minted against a member's own provider. Covered instead by the wire-level tests in Unrelated bug found. |
…ashboard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqfckcPNrYiTCPpVXDUgvq
There was a problem hiding this comment.
All reported issues were addressed across 31 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqfckcPNrYiTCPpVXDUgvq
AIM-151. Stacked on #5904 (migration).
Summary
Tunneled MCP backends gain a routable upstream identity, so their per-user credentials route deterministically instead of by guesswork.
tunneledMcp.updateServeraccepts an optionalresource_identifier(tri-state: omit = unchanged, empty = clear, value = set). Validated as an absolute http(s) URI with no fragment, stored with the trailing slash trimmed from its path only. Deliberately not checked against the guardian URL policy — the value names a host inside the customer's network and is never dialed. It is not on the create form: at creation the tunnel does not exist yet and the identifier is unknowable (it is read off the backend'sWWW-Authenticatelater).COALESCE(remote url, tunneled resource_identifier); the direct-surface derivation picks the identifier up the same way. Tunneled members now claim their issuer's credential — with no identifier the claim mints an unqualified grant, which also blocks the weaker per-client fallback.The routing rule, and why tunneled differs from remote
A remote backend matches on recorded RFC 8707 resource across the session's credentials, because its routing key is the URL the proxy dials: a credential that matches is being returned to the audience it names.
A tunneled backend is routed by identity alone — only the entry keyed by its own derived
remote_session_issuer, accepted when that grant is unqualified (no identifier recorded, or minted before one was) or when it names the recorded identifier.The asymmetry is load-bearing. A tunnel's dial target is decoupled from the resource its identifier claims, and the identifier is operator-supplied and unverifiable (guardian refuses private hosts, so discovery is impossible). An earlier revision of this PR let tunneled members match across the whole map; an operator holding
mcp:writecould then set their tunnel's identifier to a sibling member's upstream URL and have every user's correctly-audienced credential for that vendor delivered into their tunnel. Scanning is now remote-only. This also aligns tunneled members withhostedMemberTokens, which has always been issuer-keyed.Fail-closed shapes are unchanged for remote (
no_match/legacy_null_resource/duplicate_resource); a tunneled backend with no usable entry calls anonymously. Thebackend_no_resourcereason is gone.Behavior changes to note
backend_no_resource); they now route by the backend's own issuer identity or degrade to an anonymous call.Tests
Router unit tables cover every branch on both surfaces. Two regression tests pin the cross-issuer rule specifically — one at the router level, one end-to-end through a fake tunnel gateway asserting the victim credential never reaches the tunnel; both fail against the previous revision. The wire-level suite also proves the intended path: a grant qualified to a member's recorded identifier arrives as that member's bearer.
Motivation
Tunneled backends recorded no RFC 8707 resource, so routing guessed: forward a lone stored token as-is (possibly to a backend it was never granted for), fail closed on several — which broke every tunneled member of a gateway holding multiple providers. Recording the tunneled server's own RFC 9728 protected-resource identifier makes those grants routable without ever handing a backend a credential it was not granted.
🤖 Generated with Claude Code
https://claude.ai/code/session_015gm43HhrAUR6poQA5xKK9W