The lab Ram wrote up is concrete: an MCP client (Claude Code) calls Snowflake's managed MCP server through Enterprise AgentGateway. Inbound the user signs in with Keycloak. Upstream Snowflake will only accept a token from Microsoft Entra ID via its External OAuth integration. Two different IdPs, no federation between them, and a real per-user identity required at the database end so existing Snowflake RBAC keeps working. Elicitation is the pattern that closes the gap. This page sits one layer above the YAML and describes the moving parts; the gist itself walks the apply path.
§1 The two-IdP gap elicitation closes
The inbound and the upstream IdPs don't have to be the same and usually aren't. The corporate IdP issues the user's day-to-day SSO; the upstream SaaS sits behind its own OAuth surface, often a different tenant entirely. The gateway sees both but can't mint a token in the upstream IdP's name without a one-time user consent.
| Leg | Who decides identity | What the gateway has by default |
|---|---|---|
| Inbound — MCP client → gateway | Corporate IdP (Keycloak in the lab). User signs in once, JWT carries sub, aud, iss. |
A validated Keycloak JWT, audience + issuer pinned at the gateway. |
| Upstream — gateway → managed MCP server | Upstream IdP (Microsoft Entra in the lab). Resource accepts only tokens issued by this IdP. | Nothing. The Keycloak token is the wrong issuer, wrong audience, wrong signing keys. Snowflake will reject it. |
The gateway needs a real Entra token for the calling user. Asking that user to paste an Entra token by hand defeats the point. Asking them to log in to Entra from inside the MCP client doesn't work either — the client speaks MCP, not browser-OAuth. Elicitation moves the consent out of the client and into a separate browser flow the user runs once, then the gateway holds the result.
§2 What elicitation actually is
Strip away the actors and elicitation is a small protocol with three moves: pause, prompt, persist. The gateway pauses an MCP call that can't yet be served, prompts the user out of band for the upstream credential, and persists the result so the next call is unblocked.
- Pause. Inbound JWT validates, but the gateway has no
upstream token cached for this
sub. The MCP call doesn't proceed and the gateway returns an HTTP 500 with an elicitation URL in the body. - Prompt. The user opens the URL in a browser, signs in to the Solo Enterprise UI under the same inbound identity, and is redirected into the upstream IdP's standard OAuth consent screen. They sign in there and approve once.
- Persist. The upstream IdP redirects back to the
gateway's callback. The gateway stores the access token and, if
offline_accesswas requested, a refresh token — all keyed to the inboundsub. The next MCP call from that user is served end-to-end.
§3 Actors and components
Six moving parts, three of them human-facing. The gist's worked example pins each role to a concrete product; the pattern is general.
| Component | Role | In the gist |
|---|---|---|
| MCP client | Holds the inbound JWT, sends MCP calls to the gateway, displays the elicitation URL when the gateway asks for one. | Claude Code, connected via claude mcp add --transport http. |
| Inbound IdP | Authenticates the user. Issues the JWT the gateway validates on every MCP call. Supplies the sub that the gateway uses as the elicitation key. |
Keycloak realm snow with agentgateway, solo-ui and solo-ui-frontend clients. Anonymous Dynamic Client Registration on so the MCP client can self-register. |
| Enterprise AgentGateway | Validates inbound JWTs, owns the elicitation state machine, acts as the OAuth client to the upstream IdP, stores access + refresh tokens, injects the upstream token on outbound MCP calls and rotates it automatically. | EnterpriseAgentgatewayPolicy for inbound JWT + CORS, EnterpriseAgentgatewayBackend for the Snowflake target, an elicitation policy on that backend, and an elicitation-secret holding the upstream OAuth client credentials. |
| Solo Enterprise UI | Where the user lands when they open the elicitation URL. Sits in front of the gateway's elicitation API, gates it with the same inbound IdP, and renders the "Authorize" screen that kicks off the upstream OAuth redirect. | Solo management UI, configured against the same Keycloak realm and clients solo-ui / solo-ui-frontend. |
| Upstream IdP | Issues the token the resource will actually accept. Owns its own users, scopes and consent. The gateway is registered here as a confidential OAuth client. | Microsoft Entra ID app registration with session:role-any scope, web redirect pointed at the gateway's elicitation callback, client secret stored in the gateway's elicitation-secret. |
| Upstream resource | The thing being protected. Configured to trust the upstream IdP and to map a claim in the upstream token onto its own user identity. | Snowflake managed MCP. EXTERNAL_OAUTH security integration trusts Entra, maps upn → Snowflake LOGIN_NAME, runs queries under the user's DEFAULT_ROLE. |
§4 The three identities
The user has one experience but the system carries three distinct identities. Each lives in a different system, each does a specific job, and they have to line up — this is where most first-time setups go wrong.
| Identity | Lives in | Job |
|---|---|---|
| Inbound user | Inbound IdP (Keycloak) | Authenticates the MCP call. Authenticates the Solo UI session. The sub claim is the key the gateway files the upstream token under. |
| Upstream user | Upstream IdP (Entra) | Consents on the upstream IdP's screen. Carries the claim (upn in the gist) that the upstream resource will use as its user-mapping attribute. |
| Resource user | Upstream resource (Snowflake) | Runs the actual work. Picks up the user's roles, warehouse defaults, grants. Mapped from the upstream token's user-identifier claim. |
§5 How the identities link
Two joins. Get either wrong and the second leg fails silently with "no token found" or "user not provisioned".
- Inbound
sub— the elicitation key. The gateway stores the upstream token under the inbound JWT'ssub. The samesubmust show up on both the MCP call and the Solo UI session where the user clicks "Authorize", otherwise the consent gets filed under a different key and the next MCP call from the client still sees "no token". Practically: the user must log in to the Solo UI as the same Keycloak user their MCP client authenticated as. - Upstream
upn— the resource mapping. The upstream IdP stamps a user-identity claim into its access token. The upstream resource is configured to map that claim onto a local user record. In the gist, Snowflake'sEXTERNAL_OAUTHintegration setsEXTERNAL_OAUTH_TOKEN_USER_MAPPING_CLAIM = 'upn'andEXTERNAL_OAUTH_SNOWFLAKE_USER_MAPPING_ATTRIBUTE = 'login_name', and the Snowflake user hasLOGIN_NAME = <Entra UPN>. If the claim isn't there or the local user isn't provisioned with a matching value, the resource rejects the call after the gateway already injected a valid token.
Implementation note. Solo files elicitation inside the token-exchange machinery in the CRD shape — the policy lives under
spec.backend.tokenExchange.elicitation, the Helm keys
sit on tokenExchange.elicitation.*, and the data-plane
STS endpoint serves the elicitation OAuth dance at
/elicitations/oauth2/token. So when you read the gist
and see a policy called snowflake-mcp-tx, that
-tx suffix isn't an error: it's the
cross-IdP-consent mode of the token-exchange subsystem,
not same-domain RFC 8693 exchange.
§6 Architecture at a glance
The gateway sits between the MCP client and the upstream resource, talking to two IdPs and one UI. Solid arrows are call paths, dashed arrows are browser redirects the user follows manually during the one-time consent.
Components and the two distinct paths: gateway-to-machine calls and one-time browser consent.
§7 Request flow, first call vs steady state
The interesting moment is the first call from a given user. Everything after that looks like a normal MCP call — the gateway pulls the cached upstream token out of its store and rotates it via refresh when it's about to expire.
Steps 1–7 happen once per user. From step 8 onward, every call looks ordinary and the gateway refreshes the upstream token in the background as it nears expiry.
§8 When elicitation fits, when it doesn't
Elicitation is the right tool when the inbound and upstream IdPs are genuinely independent and the upstream side won't accept anything minted on its behalf. Other patterns fit other shapes — the point is to pick by the trust topology, not by what's familiar.
Reach for elicitation when
- The corporate IdP and the upstream provider's IdP are different and have no federation.
- The upstream resource enforces per-user identity and existing RBAC over there should keep working (Snowflake roles, Atlassian permissions, GitLab scopes).
- Users would otherwise have to manage long-lived upstream tokens by hand.
- You're happy with a one-time browser consent per user, with refresh-token rotation thereafter.
Reach for something else when
- One IdP, federated trust. Use RFC 8693 token exchange — the gateway mints the upstream token from the inbound one on every call, no consent screen needed. See JWT token exchange.
- Consent must complete before any tools appear. Use chained auth at connect time — the gateway orchestrates both OAuth flows up front, so the MCP session opens with both tokens already held and no mid-session 500. The Solo docs use-case Enterprise MCP Chained Auth (IdP SSO + Upstream OAuth) covers this.
- You don't need per-user identity at the resource. A static service-account credential is simpler — you lose user-level audit but gain operational simplicity.
- The upstream resource is just an internal MCP/HTTP service in your mesh. SPIFFE workload identity + mTLS is the cheaper hop.
§9 Anatomy of the configuration
Enough to recognise the moving parts in the gist. The full YAML, Helm values, Snowflake SQL and Claude Code wire-up are in Ram's workshop gist — this page intentionally doesn't repeat them.
- Inbound auth.
An
EnterpriseAgentgatewayPolicyattached to the MCPHTTPRoutewithtraffic.jwtAuthenticationinStrictmode, issuer pinned to the inbound IdP, JWKS pulled from the IdP, and three audiences in the allow-list — the gateway's OAuth client name (e.g.agentgateway), the protected resource URL itself (e.g.https://<gateway-host>/snowflake-mcp), andaccount, the default audience Keycloak stamps onto tokens minted via Dynamic Client Registration. The same policy advertises the MCP protected-resource metadata so MCP clients can discover where to authenticate. - Upstream backend.
An
EnterpriseAgentgatewayBackenddescribing the managed MCP target — host, port, path, TLS / SNI for the upstream SaaS, and the MCP transport. - Elicitation secret.
A Kubernetes
Secretthat carries the upstream OAuth client credentials — client ID, client secret, authorize URL, token URL, requested scopes (includingoffline_accessif you want refresh tokens), and the redirect URI back to the gateway's elicitation callback hosted on the Solo UI. - Elicitation binding. A policy that points the backend at that secret — effectively "for this upstream, run the elicitation flow against these credentials". This is what turns a normal backend into one that knows how to pause-and-prompt.
- Solo UI registration.
The Solo Enterprise UI is configured against the same inbound IdP
so that the elicitation page is gated by the user's corporate SSO.
That gating is what binds the consent click to the
subthe gateway will key the stored token by. - Controller wiring. A few Helm / controller env values (the elicitation secret name and the callback URL) tell the gateway data plane and the controller where to land the OAuth callback and how to talk to the token-exchange/STS endpoint internally.
- Upstream resource integration.
On the resource side, the External-OAuth trust object that
declares "I trust tokens from upstream IdP X for audience Y" and
the user-mapping that ties the upstream identity claim to a
local user record (Snowflake's
EXTERNAL_OAUTHintegration in the gist).
§10 Things that bite first time
Things to budget for when you stand this up. Each is a place I'd expect a brand-new setup to land on at least once.
- Sub mismatch between MCP login and Solo UI login.
If the user authenticates the MCP client as Alice but signs into
the Solo UI as Bob to click Authorize, the stored upstream token
goes under Bob's
suband Alice's next call still gets HTTP 500. Same realm, same user, every time. - Missing
accountfrom the audience allow-list. The whole flow leans on Dynamic Client Registration — the MCP client (Claude Code) self-registers with Keycloak. Keycloak stampsaccountas the default audience on tokens for those dynamically-registered clients. If the gateway'sjwtAuthentication.audiencesonly listsagentgateway(the obvious choice), every DCR-issued token gets a silent 401 at the gateway and the MCP client looks broken. Pin all three:agentgateway, the protected resource URL, andaccount. - No refresh because
offline_accesswasn't requested. Without it, the upstream IdP issues an access token but no refresh token, so the user gets prompted again at the first expiry. Make sure the elicitation secret'sscopesincludesoffline_accessand that the upstream IdP app is allowed to issue it. - Resource-side user not provisioned. A perfectly
valid upstream token doesn't help if the resource has no local
user matching the user-mapping claim. Snowflake won't auto-create
the
mcp_userfor you; the upstream system has to already know about each end user (manually, or via SCIM/just-in-time provisioning). - Issuer pinning across browser and in-cluster callers.
The inbound IdP must publish a stable issuer URL that both the
browser (during Solo UI sign-in) and the gateway (during JWKS
fetch) see the same way — otherwise the JWT's
isswon't match the gateway's pinned issuer for some flows. - The callback URL has to be HTTPS and reachable from the user's browser. Elicitation is fundamentally a browser flow; the redirect URI registered in the upstream IdP app must match the gateway/UI's external HTTPS endpoint exactly.
- The 500 is by design. The first MCP call returning an HTTP 500 with an elicitation URL is the protocol — it isn't an error to silence in client code. MCP clients that understand elicitation surface the URL to the user; older clients may treat it as a generic failure.
Thanks again to Ram Vennam for the source workshop and for the patient walk-throughs while I'm ramping on Solo's MCP stack.