Summary
There is no supported pattern today for placing a backend whose upstream
accepts only user-owned OAuth tokens (GitLab, GitHub, etc.) inside a
VirtualMCPServer while preserving per-user identity at that upstream.
Two independent architectural constraints block it. This issue describes
the gap precisely, documents a confirmed working standalone workaround, and
proposes two approaches that would close it.
Concrete use case
Setup: A VirtualMCPServer fronts ~10 backends. Inbound auth is a
corporate Keycloak instance. Outbound auth for all other backends uses
outgoingAuth.source: discovered + per-backend MCPExternalAuthConfig
of type tokenExchange against a mcp-exchange-svc Keycloak client —
Keycloak mints backend-scoped JWTs from the inbound token. This works for
any backend whose upstream accepts a Keycloak-issued JWT.
Desired addition: an MCPRemoteProxy for the GitLab native MCP
endpoint
(/api/v4/mcp) behind the same vmcp gateway, with per-user identity at
GitLab (not a shared service account PAT).
Why token exchange cannot bridge this: GitLab's /oauth/token does
not implement RFC 8693 — it returns unsupported_grant_type on
urn:ietf:params:oauth:grant-type:token-exchange requests.
MCPExternalAuthConfig type: tokenExchange therefore cannot produce a
GitLab-issued token from a Keycloak JWT.
Standalone workaround (confirmed working): Run the GitLab proxy as a
standalone MCPRemoteProxy — no groupRef, no vmcp — with its own
externalAuthConfigRef → embeddedAuthServer federating to GitLab as the
sole upstream IdP. Clients do a one-time GitLab OAuth flow; the embedded
AS stores per-user refresh tokens and injects the upstream GitLab token
on each request (upstreamInject). This gives correct per-user identity
but produces two separate MCP endpoints instead of one unified gateway.
Gap 1 — IncomingAuth is gateway-wide; no per-backend override
VirtualMCPServerSpec.IncomingAuth is a single field that applies to
every client connecting to vmcp. BackendAuthConfig exists only in the
outgoing direction (vmcp → backends). There is no mechanism to say
"clients authenticating to reach the GitLab backend must go through
embeddedAuthServer; clients reaching all other backends authenticate
via Keycloak OIDC directly."
If IncomingAuth is switched to embeddedAuthServer (GitLab upstream),
Keycloak-issued tokens are no longer accepted at all, breaking every
other backend.
Gap 2 — upstreamProviders are alternates, not a chain
Multiple entries in embeddedAuthServer.upstreamProviders are treated as
alternate IdPs for the same user session (only the first is authoritative;
the operator emits a warning condition when multiple upstreams are
combined with authz policy). There is no way to say "authenticate the
client via Keycloak (inbound), and in parallel acquire and store a GitLab
token for this session to inject into the GitLab backend."
When a user authenticates through the Keycloak upstream, no GitLab token
is ever acquired. The upstreamInject pattern only works in the
standalone case because the embedded AS's sole upstream is GitLab and
every session already carries a GitLab access token.
Proposed solutions
Option A — Linked-account model in embeddedAuthServer
Extend the embedded AS to support a linked-account flow: the user's
primary authentication goes through the configured inboundProvider
(e.g., Keycloak). For each declared linkedProvider, the embedded AS
separately initiates an OAuth authorization-code flow against the
external IdP (GitLab) and persists the per-user upstream access + refresh
token in its session store, associated with the primary sub.
On each proxied request, if the target backend is the one linked to
GitLab, the stored GitLab access token is injected (upstreamInject).
For all other backends the existing Keycloak tokenExchange outgoing
auth applies unchanged.
This is the higher-value path because it preserves a single unified MCP
endpoint and keeps inbound auth on Keycloak for all clients.
Option B — Per-backend IncomingAuth override on VirtualMCPServer
Add an optional incomingAuthOverride field at the BackendRef level in
VirtualMCPServerSpec. When set, vmcp uses the specified
MCPExternalAuthConfig to validate tokens on requests routed to that
backend, bypassing the gateway-wide IncomingAuth. Clients must hold a
valid token for the backend-specific config; the gateway routes by
matching incoming bearer token issuer against registered configs.
This is the more targeted change and may be easier to implement, though
it still does not acquire GitLab tokens automatically — clients would need
to obtain them independently and present them in a backend-specific
header or scope hint, which degrades UX.
Related issues
Environment
- ToolHive operator: v0.27.0 (Kubernetes, self-managed deployment)
- Keycloak 26.3 (inbound IdP)
- GitLab self-managed (upstream backend,
/api/v4/mcp)
- Confirmed by Stacklok/Yolanda on Discord (2026-05-19) that neither gap
is a misconfiguration — both are current architectural limits with no
workaround inside vmcp.
Summary
There is no supported pattern today for placing a backend whose upstream
accepts only user-owned OAuth tokens (GitLab, GitHub, etc.) inside a
VirtualMCPServerwhile preserving per-user identity at that upstream.Two independent architectural constraints block it. This issue describes
the gap precisely, documents a confirmed working standalone workaround, and
proposes two approaches that would close it.
Concrete use case
Setup: A
VirtualMCPServerfronts ~10 backends. Inbound auth is acorporate Keycloak instance. Outbound auth for all other backends uses
outgoingAuth.source: discovered+ per-backendMCPExternalAuthConfigof type
tokenExchangeagainst amcp-exchange-svcKeycloak client —Keycloak mints backend-scoped JWTs from the inbound token. This works for
any backend whose upstream accepts a Keycloak-issued JWT.
Desired addition: an
MCPRemoteProxyfor the GitLab native MCPendpoint
(
/api/v4/mcp) behind the same vmcp gateway, with per-user identity atGitLab (not a shared service account PAT).
Why token exchange cannot bridge this: GitLab's
/oauth/tokendoesnot implement RFC 8693 — it returns
unsupported_grant_typeonurn:ietf:params:oauth:grant-type:token-exchangerequests.MCPExternalAuthConfig type: tokenExchangetherefore cannot produce aGitLab-issued token from a Keycloak JWT.
Standalone workaround (confirmed working): Run the GitLab proxy as a
standalone
MCPRemoteProxy— nogroupRef, no vmcp — with its ownexternalAuthConfigRef → embeddedAuthServerfederating to GitLab as thesole upstream IdP. Clients do a one-time GitLab OAuth flow; the embedded
AS stores per-user refresh tokens and injects the upstream GitLab token
on each request (
upstreamInject). This gives correct per-user identitybut produces two separate MCP endpoints instead of one unified gateway.
Gap 1 —
IncomingAuthis gateway-wide; no per-backend overrideVirtualMCPServerSpec.IncomingAuthis a single field that applies toevery client connecting to vmcp.
BackendAuthConfigexists only in theoutgoing direction (vmcp → backends). There is no mechanism to say
"clients authenticating to reach the GitLab backend must go through
embeddedAuthServer; clients reaching all other backends authenticatevia Keycloak OIDC directly."
If
IncomingAuthis switched toembeddedAuthServer(GitLab upstream),Keycloak-issued tokens are no longer accepted at all, breaking every
other backend.
Gap 2 —
upstreamProvidersare alternates, not a chainMultiple entries in
embeddedAuthServer.upstreamProvidersare treated asalternate IdPs for the same user session (only the first is authoritative;
the operator emits a warning condition when multiple upstreams are
combined with authz policy). There is no way to say "authenticate the
client via Keycloak (inbound), and in parallel acquire and store a GitLab
token for this session to inject into the GitLab backend."
When a user authenticates through the Keycloak upstream, no GitLab token
is ever acquired. The
upstreamInjectpattern only works in thestandalone case because the embedded AS's sole upstream is GitLab and
every session already carries a GitLab access token.
Proposed solutions
Option A — Linked-account model in
embeddedAuthServerExtend the embedded AS to support a linked-account flow: the user's
primary authentication goes through the configured
inboundProvider(e.g., Keycloak). For each declared
linkedProvider, the embedded ASseparately initiates an OAuth authorization-code flow against the
external IdP (GitLab) and persists the per-user upstream access + refresh
token in its session store, associated with the primary
sub.On each proxied request, if the target backend is the one linked to
GitLab, the stored GitLab access token is injected (
upstreamInject).For all other backends the existing Keycloak
tokenExchangeoutgoingauth applies unchanged.
This is the higher-value path because it preserves a single unified MCP
endpoint and keeps inbound auth on Keycloak for all clients.
Option B — Per-backend
IncomingAuthoverride onVirtualMCPServerAdd an optional
incomingAuthOverridefield at theBackendReflevel inVirtualMCPServerSpec. When set, vmcp uses the specifiedMCPExternalAuthConfigto validate tokens on requests routed to thatbackend, bypassing the gateway-wide
IncomingAuth. Clients must hold avalid token for the backend-specific config; the gateway routes by
matching incoming bearer token issuer against registered configs.
This is the more targeted change and may be easier to implement, though
it still does not acquire GitLab tokens automatically — clients would need
to obtain them independently and present them in a backend-specific
header or scope hint, which degrades UX.
Related issues
identity-assertion grants. Not available on self-managed instances
today, and depends on the upstream IdP implementing the draft spec.
embeddedAuthServerRFC 8693 token exchange: a superset ofthis request for the agentic case; the linked-account model in Option A
is the user-interactive analog.
Environment
/api/v4/mcp)is a misconfiguration — both are current architectural limits with no
workaround inside vmcp.