Skip to content

Forward the caller's identity to upstream MCP servers - #64

Closed
clintonb wants to merge 48 commits into
stainless-api:mainfrom
voriteam:clintonb/vor-20327-forward-user-email
Closed

clintonb wants to merge 48 commits into
stainless-api:mainfrom
voriteam:clintonb/vor-20327-forward-user-email

Conversation

@clintonb

Copy link
Copy Markdown

An upstream server entry can now name the authenticated caller in a request header. Previously an upstream saw either a static token or a per-user OAuth token, and a shared-secret backend had no way to learn which person or service account was behind a call. With a $userEmail header value, the gateway stamps the caller's email on every request it opens for them, whether they signed in with Google or presented a service-account token.

Closes VOR-20327

clintonb and others added 30 commits June 10, 2026 14:37
Injects the git commit SHA at Docker build time via ldflags and replaces
http.DefaultTransport at startup with a wrapper that sets User-Agent on
every outgoing request. Format follows RFC 9110: mcp-front/1.0 (build; <sha>).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a token refresh fails with invalid_grant, invalid_client, or
unauthorized_client, the refresh token is permanently invalid. Delete
the stored token so /my/tokens and /tools accurately reflect that the
user needs to re-authenticate instead of showing a stale "connected"
status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The service OAuth flow now uses PKCE (S256) for all providers—the
verifier is embedded in the signed state parameter so it survives the
redirect. Removes the hardcoded access_type=offline param which is
Google-specific and rejected by some authorization servers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extends the inline tool system with two generic capabilities:

GET support for HTTP tools — when method is GET, tool arguments are
appended as query parameters instead of sent as a JSON body. Static
params can be baked into the URL.

HTML fetch tool type — a new execution mode that fetches a URL,
validates it against an allowed domain list, and extracts text content
using a CSS selector. Strips script, style, nav, and footer elements.

Adds help-docs inline server with search and fetch tools for Vori's
HubSpot-hosted knowledge base at help.vori.com.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Services can now configure clientCredentials with clientId, clientSecret,
tokenUrl, and optional scopes. mcp-front exchanges the credentials for a
Bearer token and refreshes it automatically via oauth2.TokenSource. This
is useful for services like Mintlify that use machine-to-machine auth
rather than per-user OAuth flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- GKE tools returned 401 because `forwardAuthToken` forwarded mcp-front
JWTs (not GCP tokens) to `container.googleapis.com`
- Adds `gcpAuth` config option that uses Application Default Credentials
to get GCP access tokens
- Token source handles caching and auto-refresh, solving the 1-hour GCP
token lifespan
- GKE uses the cached backend path for fast tool calls (no per-call init
overhead)

- [x] All unit tests pass
- [x] Deployed and verified `gke__kube_get` and `gke__list_clusters`
work in production

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Allow GCP service accounts to exchange their short-lived access tokens
for long-lived JWT + refresh token pairs. This enables Coder workspaces
to maintain persistent MCP gateway authentication instead of relying on
static tokens that expire after ~1 hour.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mitted

When requireResourceParam is false (i.e. dangerouslyAcceptIssuerAudience is
enabled), the authorize endpoint allowed requests without the RFC 8707
resource parameter but issued tokens with an empty audience. These tokens
then failed ValidateAudienceForService because an empty audience matches
neither the per-service URI nor the issuer URI.

Now falls back to the base issuer URI as audience, which the token validation
middleware already accepts when acceptIssuerAudience is enabled.

Fixes VOR-16272

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Renders tools grouped by backend service with annotation badges
(readOnlyHint, destructiveHint, idempotentHint, openWorldHint).
Uses the same browser SSO middleware as /my/tokens so users can
view available tools after authenticating with Google.

The tools handler queries the aggregate's cached tool schemas via
ListToolsByBackend, which reuses the existing discovery cache rather
than triggering fresh discovery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds opt-in response streamlining to aggregate servers via
streamlineResponses config flag. Strips embedded documentation
(XML example/hints blocks), truncates long descriptions, removes
parameter descriptions, simplifies nullable types, and strips
validation-only schema fields.

Reduces tools/list response size significantly for deployments
aggregating many tools with verbose documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Service OAuth configurations can now omit clientId, clientSecret, and
scopes — mcp-front performs RFC 7591 dynamic client registration with
the upstream service when these fields are absent. Only authorizationUrl
and tokenUrl are strictly required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
html/template URL-escapes values interpolated into href attributes.
Pre-escaping with url.QueryEscape causes double encoding: the = at
the end of a base64 signature becomes %3D, then %253D, making the
signature unverifiable when the link is clicked.

Pass the raw signed state and returnURL to the template and let
html/template handle escaping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Go's http.ServeMux 301-redirects /gateway to /gateway/ when only the
trailing-slash variant is registered. HTTP clients often convert the
redirected POST into a GET, which breaks MCP streamable-http handshakes
with cryptic timeouts.

Register the handler at both paths so the redirect never happens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Streamable-http MCP servers return Mcp-Session-Id in the initialize
response header. Clients must echo it in subsequent requests. Without
Access-Control-Expose-Headers, browsers and strict HTTP libraries can't
read the header, so clients send follow-up notifications without a
session ID and the server returns 400.

Also add Mcp-Session-Id to Allow-Headers so clients can include it in
request headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChainMiddleware applies middlewares in order, meaning the last entry
becomes the outermost. Previously auth wrapped CORS, so OPTIONS
preflights were rejected with 401 before CORS could short-circuit
them. This broke browser-based clients trying to read the
Mcp-Session-Id response header.

New order (outer → inner): logger → recover → CORS → auth → serviceAuth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mcpserver.NewStreamableHTTPServer takes a WithHTTPContextFunc that
runs after the library populates the context with the session. Our
callback returned r.Context() — throwing away the session. Downstream,
ClientSessionFromContext(ctx) returned nil during tools/list, so
session-specific tools (set by our onRegisterSession hook) weren't
found and the aggregate returned an empty tool list.

Drop the callback entirely. If we ever need per-request context, it
must extend the passed ctx, not replace it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Standalone reproducer that starts a mock MCP backend with 3 tools,
wires it through an aggregate server with streamable-http transport,
and verifies that tools/list returns the backend's tools rather than
an empty list. Catches the session-context bug fixed in the previous
commit.

Run with: go run ./internal/aggregate/streamable_race_test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cube server implements the inline.MCPServer interface, providing meta,
query, and dimension_search tools against a Cube API. Uses JWT auth
with HMAC-SHA256 signing and automatic token refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Vori-specific deployment: tracks vori/config.json (aggregate gateway
plus 15+ backend MCP servers), the Docker build script with git-SHA
version injection, a local dev run script, and a Dockerfile tweak to
COPY vori/config.json as the default /app/config.json.

Un-ignores vori/config.json so the deployment config travels with the
fork. No secrets — environment variables are referenced via {"$env":
"VAR_NAME"}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backends with gcpAuth: true had no Authorization header attached to outbound
requests. The dead newGCPTokenSource function was never wired to the new
aggregate package after the upstream rebase, so GKE tool calls reached
container.googleapis.com without credentials and silently returned no useful
data.

Adds a generic TokenSources map[backendName]oauth2.TokenSource on
aggregate.ServerConfig. In createConn, after any user-token substitution,
the matching token source (if any) is fetched and applied as a Bearer
Authorization header via a new copy-on-write WithBearerToken method on
MCPClientConfig.

Sources for gcpAuth backends are built by buildGCPTokenSources (one shared
google.DefaultTokenSource keyed by each backend name) and passed through
to every aggregate.
Token sources were built by buildClientCredentialsSources but never reached
the aggregate, so backends like Mintlify that authenticate via the
client_credentials grant got no Authorization header and servers rejected
requests with "No authorization provided". Merge the client-credentials
sources into the aggregate's TokenSources map alongside GCP sources.
Restores the GCP fallback in NewValidateTokenMiddleware that the upstream
rebase removed. Service-to-service callers (e.g. opsbot) authenticate by
sending their raw GCP access token as a Bearer token rather than going
through the RFC 8693 token-exchange flow, and the JWT-only validator was
returning 401 for every such request. When ValidateAccessToken rejects the
token, fall back to gcpValidator.Validate; on success use the GCP-resolved
email as the user identity, store the raw token on the context for backends
that forward auth, and honor X-On-Behalf-Of impersonation against
allowedDomains.
Streamable-http session state (per-session tool registrations) lives
in mcp-go's in-process sessionToolsStore and is lost when the pod
restarts. Clients holding the old Mcp-Session-Id got 400 Invalid
session ID on every follow-up call until they reinitialized, which
most MCP clients don't do on their own.

Switch the aggregate's streamable transport to
StatelessGeneratingSessionIdManager so the session id is validated by
format only — stale ids from restarted-over sessions pass validation
and produce an empty ephemeral session instead of a 400. Then, in
BeforeListTools and BeforeCallTool hooks, lazily rebuild the per-session
tool list from the aggregate's cached discovery when it is missing,
so that rehydrated sessions see the same tools they had before the
restart. The onRegisterSession path is reused via a shared
ensureSessionTools helper that's idempotent against already-populated
sessions.

Bumps mcp-go v0.43.0 → v0.44.1 for
StatelessGeneratingSessionIdManager, which generates proper per-client
UUIDs (v0.43.0's StatelessSessionIdManager returns an empty string,
collapsing all users onto one shared session).
Drops the custom "cube" transport type and the bundled
internal/cube package. Cube integration now lives in a
standalone cube-mcp server; mcp-front treats it like any other
streamable-http backend. Follow-up commits add the replacement
configuration.
Lets a backend declare `hmacJWT` in its config with a secret,
algorithm (HS256), arbitrary claims, and TTL. mcp-front mints a
short-lived HMAC-signed JWT and attaches it as a Bearer token on
outbound requests. The implementation plugs into the existing
backend TokenSources map via a new oauth2.TokenSource and refreshes
tokens automatically before expiry.

Wires the Vori-side cube backend through this mechanism pointing at
the standalone cube-mcp sidecar on localhost:8003, and adds cube to
each aggregate's server list.
## Summary

Adds the Materialize developer MCP server as a backend in the Vori
`mcp-front` gateway, so developers can query Materialize's system
catalog through the OAuth proxy alongside the other internal tools.

Wiring it up surfaced two pre-existing bugs in `mcp-front`, which this
PR also fixes — so the change is broader than just a config entry.

## Changes

### Materialize backend — `vori/config.json`

New `materialize` server: `streamable-http` transport pointed at the
Materialize developer MCP endpoint (`/api/mcp/developer`), added to both
the `gateway` and `gateway-streamable` aggregate servers. Auth is a
per-user manual token — each developer generates a personal MCP token in
the Materialize Console and pastes it into `/my/tokens`; `mcp-front`
injects it as `Authorization: Basic <token>`. Per-user tokens preserve
audit attribution in Materialize.

### Per-user tool discovery in the aggregate gateway —
`internal/aggregate/server.go`

The aggregate cached discovered tools in a single process-global entry
(60s TTL), populated by whichever user happened to trigger a discovery
cycle. That is incorrect for `requiresUserToken` backends: such a
backend was visible or absent for *every* user depending on whether the
triggering user had a token — so token-gated tools (`materialize`,
`linear`, `notion`, `sentry`, `coder`) flickered in and out of
everyone's tool list every 60 seconds.

Discovery is now split: backends needing no user token are discovered
once and cached globally; token-gated backends are discovered and cached
per-user, limited to the backends each user actually has a token for.
New regression test `TestPerUserTokenBackendIsolation`.

### mcp-go v0.44.1 → v0.49.0 and empty-`params` stripping — `go.mod`,
`internal/client/client.go`

mcp-go v0.44.1 serialized the required MCP `notifications/initialized`
message — and `tools/list` requests — with an empty `"params":{}` object
(`omitempty` does not apply to struct-typed fields). Materialize's MCP
server rejects an empty `params` with HTTP 422, so the materialize
handshake never completed.

- The mcp-go bump to v0.49.0 fixes the notification (`omitempty` →
`omitzero`) and trims the indirect-dependency set.
- For the request path, `stripEmptyParamsTransport` — an
`http.RoundTripper` on streamable-http transports — drops an empty
`"params":{}` / `null` from outgoing JSON-RPC bodies. An empty `params`
is equivalent to an absent one per JSON-RPC 2.0, so this is safe for
every backend and keeps `mcp-front` compatible with strict MCP servers.

## Test plan

- [x] `go build ./...`
- [x] `go vet ./...`
- [x] `go test ./internal/...` — full suite passes, including
`TestPerUserTokenBackendIsolation` and the `stripEmptyParams` tests
- [x] Verified directly against the live Materialize endpoint with
`curl`: `initialize`, `notifications/initialized` (no `params`), and
`tools/list` (no `params`) all succeed; `tools/list` returns
`query_system_catalog`
- [x] Deployed the mcp-go bump to the production gateway — the
materialize handshake now completes (`Backend connection established` in
the logs)
- [ ] End-to-end through Claude: `materialize__query_system_catalog`
callable once the empty-`params` stripping change is deployed

## Linear

VOR-18099

🤖 Generated with [Claude Code](https://claude.com/claude-code)
clintonb and others added 18 commits June 10, 2026 14:43
The aggregate keeps a long-lived pooled connection per (user, backend) and reuses the backend's `Mcp-Session-Id` across calls. When a streamable-http backend expires that session server-side before the idle connection is evicted (idle timeout is 5 minutes), the next pooled call sends a stale session id and the backend rejects it. Datadog returns "Invalid session ID"; mcp-go surfaces this as an error and does not re-initialize on its own. The aggregate evicted the dead connection but proxied the error to the client, so the next call self-healed and the user saw intermittent "Invalid session ID" failures.

Production logs confirmed the pattern: dozens of `Evicted broken backend connection backend=datadog` events clustered seconds apart, with no accompanying retry. The literal error was never logged — it was serialized into the MCP tool result and proxied to the client — so this also adds the missing backend-error log.

`withConnRetry` now evicts the broken connection and retries once against a freshly-initialized connection (which mints a new session) when the error indicates session invalidation: `transport.ErrSessionTerminated` for an HTTP 404, or an error whose text mentions a session for backends that reject with a different 4xx (Datadog's 400 "Invalid session ID"). The retry covers both tool calls and tool discovery. Non-session errors keep the previous evict-and-return behavior. The fix generalizes to every streamable-http backend, which share the same session-expiry behavior.
Upstream's new trier-middleware and redirect-URI tests call
NewValidateTokenMiddleware and NewAuthHandlers with the upstream arity;
our fork extends both with a GCP access token validator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The middleware has sent Mcp-Session-Id in Access-Control-Allow-Headers
since it was added for streamable-http clients; the test assertion was
never updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream now rejects DCR redirect URIs whose origin is not allowlisted,
closing an authorization-code-theft vector. An audit of all 1,854
registered clients in Firestore found exactly three origins in use:
http://localhost, http://127.0.0.1, and https://claude.ai. claude.com is
included for Anthropic's domain migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aggregate tool calls intermittently fail with "invalid character '\x1f'"
when a still-gzipped body from gke or datadog reaches mcp-go's JSON
decoder. A response-side RoundTripper on streamable backends now sniffs
Content-Encoding and the gzip magic bytes on application/json bodies,
decompresses (looping for double-encoded bodies), and logs a WARN with
the trigger so production occurrences reveal whether the backend
double-encodes or Go skipped transparent decompression. SSE responses
pass through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tics

Production showed the guard firing with trigger=header on gke, meaning
Go skipped transparent decompression. Nothing in the stack sets
Accept-Encoding, so logging the outgoing header plus request method and
response status narrows down why Go did not request or strip gzip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mcp-go stamps every inbound HTTP request's headers onto the parsed
request structs (PR #480) and its streamable client sends request.Header
verbatim to the backend (PR #546, since v0.43.0). A proxy passing the
request struct through therefore forwards the end client's headers —
Accept-Encoding, cookies, auth — to backends. An explicit
Accept-Encoding disables Go's transparent gzip decompression, which is
how still-gzipped gke/datadog responses reached the JSON decoder.

Clears request.Header at every backend-forwarding boundary: the
aggregate tool handler and the proxy tool, prompt, resource, and
resource-template handlers. The gzip guard transport remains as
defense-in-depth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This repository no longer includes Vori's configuration. That data now resides in our backend repo.

Closes VOR-18857
The system now properly revokes tokens for deactivated Google accounts. This is achieved by re-verifying the user against the Google directory when the client refreshes their token. If the provider no longer recognizes them, refresh is denied and stored credentials/sessions are purged.
…12)

Two fixes for an incident where MCP clients repeatedly lost their connection to the proxy.

## Gateway streamable-session memory leak (the reconnects)

The aggregate gateway attaches a per-session tool map to every session via `SetSessionTools`, and mints a fresh session on every client `initialize` (`StatelessGeneratingSessionIdManager`). MCP clients rarely send an HTTP `DELETE`, so mcp-go never frees those per-session maps — this is mcp-go issue [#723](mark3labs/mcp-go#723). Memory grows linearly with the number of sessions ever created, until the process is killed. As it nears the limit the runtime GC-thrashes and requests begin exceeding their deadlines, which surfaces to clients as the server going unresponsive.

The remedy is mcp-go's built-in idle sweeper, `WithSessionIdleTTL` ([PR #724](mark3labs/mcp-go#724)). It is opt-in and off by default, which is why the leak persisted. Enabling it with a 10-minute TTL bounds retained per-session state to recent activity. Swept sessions rebuild their tools lazily from cached discovery on the next request, and `StatelessGeneratingSessionIdManager.Terminate` is a no-op with format-only validation, so a session ID reused after a sweep rehydrates transparently with no client-visible rejection.

## Service-account revocation churn

The refresh-time revocation check denies any principal without a stored Google IdP refresh token. Service accounts that authenticate via RFC 8693 token exchange never have one, so every refresh was denied and they were forced to re-authenticate continuously. The identity's provider type now threads into the check, and the IdP replay runs only for Google identities. Others are validated by their provider at exchange time and are exempt. Deactivated-Google-user enforcement is unchanged.
A backend authenticating with an OAuth client-credentials grant whose access token has a short TTL flaps: `Backend connection established` succeeds, then once the token's lifetime elapses `Backend discovery failed … authorization required` and `Evicted broken backend connection`, repeating for every user (these backends use no per-user token, so it is global).

## Root cause

`createConn` fetches a token from the per-backend `oauth2.TokenSource` and bakes the bearer into the pooled connection's static HTTP headers. That connection is reused for its whole lifetime. The `clientcredentials` `ReuseTokenSource` refreshes correctly, but that refresh never reaches an already-established pooled connection — it keeps sending the bearer captured at creation time. Once the captured token expires, the next discovery or tool call sends a stale bearer and the backend returns HTTP 401.

`withConnRetry` evicts on any error but only retries when the error is a stale-session error. A 401 is not a session error, so the connection is evicted and the error is returned: discovery fails and the backend drops from the aggregated tool list. The next request mints a fresh connection with a fresh token, works until the next expiry, and the cycle repeats.

Refreshing the token source proactively does not help — the staleness lives on the pooled connection, not in the source. The fix belongs at the connection level.

## Fix

An authorization error is now treated like a stale session: evict, recreate the connection (which re-mints the token via the source and re-applies any user token), and retry once, making the 401 invisible to the client. The auth retry is gated on the backend having a re-mintable credential (a client-credentials token source or a per-connection user token), so a genuinely misconfigured backend is not retried with the same rejected bearer. Auth errors are detected via mcp-go's `transport.ErrAuthorizationRequired`/`ErrUnauthorized` sentinels, mirroring the existing session-error check. No thundering herd: oauth2's `ReuseTokenSource` serializes refresh, so concurrent recreations hit the token endpoint once.

This also benefits user-token backends — an expired user token now recreates the connection and re-fetches the refreshed token rather than evicting.
mcp-go v0.55.1 fixes the upstream bug where the client forwarded inbound HTTP headers to backend MCP servers (mark3labs/mcp-go#905, fixed by mark3labs/mcp-go#909). The new `outboundHeader()` helper returns `nil` for any request decoded from an inbound JSON-RPC message — exactly what the local workaround did by clearing `request.Header` at every backend-forwarding boundary.

With the fix upstream, the workaround is redundant. Removed:
- the `stripInboundHeaders` wrapper and its call site in the tool-call path
- the inline `request.Header = nil` clears in the prompt, resource, resource-template, and aggregate tool-call paths
- the two tests that asserted the old behavior (`TestStripInboundHeaders`, `TestToolHandlerDropsInboundHeaders`)

`gzipGuardTransport` stays as defense-in-depth. Its trigger (a leaked `Accept-Encoding`) disappears with this upgrade, but the gzip root cause was never fully pinned down, so its WARN log remains a useful signal of any new unaccounted encoding source. `copyRequestHeaders` (the SSE proxy header allowlist) and `stripEmptyParamsTransport` are unrelated and untouched.
Tools from an integration would disappear from the gateway whenever that integration was briefly slow to respond, and anyone who connected during that window lost them for the rest of their session.

An integration's tools now survive a slow moment upstream. They are still withdrawn when access is genuinely revoked, and an integration that stays unreachable still falls away rather than being offered indefinitely.
Account executives can now generate onboarding links.

Relates to GTM-1362
…nfig (#18)

A gateway server entry for an OAuth-protected backend now needs only its URL. The endpoints a user is sent to, and the client registration behind them, are read from what the backend advertises, so adding one takes what the vendor's own setup instructions give you rather than four values copied by hand. A backend that cannot be discovered, or one that needs different values, still takes them from config as before.

Registration also now works where a backend puts its authorization endpoint on a different host than its issuer, which previously failed outright.
An upstream server entry can now name the authenticated caller in a request header. Previously an upstream saw either a static token or a per-user OAuth token, and a shared-secret backend had no way to learn which person or service account was behind a call. With a `$userEmail` header value, the gateway stamps the caller's email on every request it opens for them, whether they signed in with Google or presented a service-account token.
@clintonb

Copy link
Copy Markdown
Author

Opened against the wrong repository by mistake. This belongs on the voriteam fork.

@clintonb clintonb closed this Sep 13, 2026
@clintonb
clintonb deleted the clintonb/vor-20327-forward-user-email branch September 15, 2026 06:47
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.

2 participants