Skip to content

feat(vmcp): composing per-user federated-IdP backends behind vmcp — linked-account model or per-backend IncomingAuth overrides #5383

@gastoncan

Description

@gastoncan

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions