Skip to content

feat: route tunneled MCP credentials by recorded resource identity - #5905

Merged
daviddanialy merged 7 commits into
mainfrom
daviddanialy/aim-151-server
Sep 1, 2026
Merged

feat: route tunneled MCP credentials by recorded resource identity#5905
daviddanialy merged 7 commits into
mainfrom
daviddanialy/aim-151-server

Conversation

@daviddanialy

@daviddanialy daviddanialy commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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.

  • Management API: tunneledMcp.updateServer accepts an optional resource_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's WWW-Authenticate later).
  • Consent (grant minting): the meta member claim query LEFT JOINs both proxied backends and qualifies grants with 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.
  • Routing: the lone-token fallback is deleted on both surfaces — an unmatched credential is never forwarded, however few there are.

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:write could 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 with hostedMemberTokens, 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. The backend_no_resource reason is gone.

Behavior changes to note

  • A lone remote-surface grant with no recorded resource (legacy, pre-resource-column) now fails closed instead of being forwarded. Refresh-time backfill qualifies these on next refresh and re-consent is the clean recovery. The mismatched-lone-token warn log this replaces existed to size exactly this tightening.
  • Several credentials against a tunneled backend used to fail closed (backend_no_resource); they now route by the backend's own issuer identity or degrade to an anonymous call.
  • A soft-deleted backend source now isolates its own member instead of failing every member of a gateway (fixes a latent gap on the remote arm that predates this PR).

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

@daviddanialy
daviddanialy requested a review from a team as a code owner August 31, 2026 17:30
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

AIM-151

@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: afc5cdc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
server Minor
dashboard Minor

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

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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.

@blacksmith-sh

This comment has been minimized.

Comment thread server/internal/mcp/serve_meta_proxy.go Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread server/internal/remotesessions/queries.sql Outdated
Comment thread server/internal/mcp/authnchallenge_consent_mcp_endpoint.go
Comment thread server/internal/mcp/serveendpoint.go Outdated
Comment thread server/internal/tunneledmcp/impl.go Outdated
Comment thread server/internal/tunneledmcp/impl.go Outdated
Comment thread server/internal/remotesessions/queries.sql Outdated
Comment thread server/internal/mcp/serve_meta_proxy.go Outdated
Comment thread server/internal/tunneledmcp/resourceidentifier_test.go Outdated
Comment thread server/internal/mcp/authnchallenge_consent_meta_resource_test.go
Base automatically changed from daviddanialy/aim-151-mig to main August 31, 2026 18:11

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread server/internal/tunneledmcp/impl.go Outdated
@daviddanialy

Copy link
Copy Markdown
Contributor Author

Follow-up from a second review pass — the direct-surface derivation no longer stamps tunneled identifiers (108992e).

What was wrong. Adding tms.resource_identifier to ListOrganizationMcpServersForClient's COALESCE meant a user_session_issuer fronting both a remote server and a tunneled one produced two distinct candidate resources, so clientUpstreamResource folded to "". Recording an identifier on the tunnel therefore unqualified the remote sibling's grants — breaking its routing at a distance, silently, from an unrelated settings change.

Why removing it is free. Since tunneled backends now route by their own derived remote_session_issuer and accept an unqualified grant, the direct surface never needed the identifier stamped in order to route. The member-scoped meta MCP derivation still stamps it, which is where it earns its keep: one member is unambiguous, and the qualification is what separates two members sharing an authorization server.

The cost is that a co-hosted authorization server no longer receives resource=<identifier> on direct-surface authorize, so it cannot audience-bind that token. Nothing consumes that today; AIS-584 can reintroduce it per-endpoint, where it has the context to be unambiguous.

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 "" permanently, so its grants stay unqualified and now fail closed with no self-healing path (refresh-time backfill re-derives "" too). That predates this PR in shape — the lone-token fallback was masking it — and wants its own ticket rather than a rushed fix here.

daviddanialy and others added 4 commits August 31, 2026 12:24
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
@daviddanialy
daviddanialy force-pushed the daviddanialy/aim-151-server branch from 108992e to 17adc3b Compare August 31, 2026 19:26
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gm43HhrAUR6poQA5xKK9W
@daviddanialy
daviddanialy requested a review from a team as a code owner August 31, 2026 19:39
@daviddanialy

Copy link
Copy Markdown
Contributor Author

Manual end-to-end check on a local stack running this branch: gram-server, tunnel-gateway, a real tunnel-agent over WebSocket/yamux, and two standalone HTTP MCP backends that echo whichever Authorization header they receive — so the test observes what Gram routed rather than trusting a log line.

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.

  • list_servers returns both members; the tunneled one reports available (live route resolved from Redis), the remote unknown (remote liveness is not probed).
  • execute_tool succeeds against both. The tunneled backend logs the full handshake-first sequence arriving over the tunnel: initialize, notifications/initialized, tools/call.
  • Both members are called anonymously — correct: no grant exists for either provider, so the router forwards nothing rather than borrowing a sibling's bearer.

Field behavior. https://mcp.internal.corp/mcp/ stores as https://mcp.internal.corp/mcp; not-a-uri and a bare # fragment are both rejected bad_request; empty string clears to null. The gateway keeps serving the tunneled member with the identifier set and after clearing — exercising the ListServableMetaMCPMembers LEFT JOIN that replaced the per-dial GetServerByID, against a live database.

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 serve_meta_tunneled_test.go, which build real token maps and assert a sibling's bearer never reaches the tunnel.

Unrelated bug found. .mise-tasks/start/tunnel-gateway.sh:17 hardcodes TUNNEL_GATEWAY_ADVERTISE_ADDR to http://127.0.0.1:8091 instead of deriving it from TUNNEL_GATEWAY_FORWARD_PORT. In a git worktree (remapped ports) the gateway listens on the remapped port but publishes the default one to Redis, so every tunneled call fails "upstream unreachable or timed out". Worked around with a local override here; wants a one-line fix separately.

Comment thread server/internal/mcp/authnchallenge_consent_meta_resource_test.go Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread server/internal/mcp/serve_meta_tunneled_test.go
@daviddanialy
daviddanialy added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit fa05d58 Sep 1, 2026
46 checks passed
@daviddanialy
daviddanialy deleted the daviddanialy/aim-151-server branch September 1, 2026 17:25
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants