👛 fix: Drop Unread Credentials From MCP Domain Validation - #16035
Conversation
The early domain gate resolves the whole server config through `processMCPEnv`, but decides from the URL alone. A credential placeholder in any other field therefore raised `OpenIDReauthRequiredError` from a stale request-time OpenID snapshot, before the connection path could refresh that bearer, and the tool was dropped from the agent's toolset. `buildMCPDomainValidationConfig` narrows the config to what the decision reads, so the gate needs no live credential. A URL placeholder still fails closed, and the argument is never mutated, so direct-bearer recovery keeps the placeholder it knows how to refresh. Replaces the per-tool `upstreamTokenProvider` call the gate would otherwise make (1 + N per request per server) and covers every credential-bearing field, not only `Authorization`. Co-authored-by: Artyom Bogachenko <SpectralOne@users.noreply.github.com>
|
Pushed What the head carries The diagnosis here is right: Instead of refreshing the bearer for validation, the gate now drops the fields it never reads. Three reasons for the swap:
Per Tests — your three cases kept their shape, with the assertions following the new mechanism: the gate no longer calls Checks on this head
Not run: four mongo-backed suites ( Thanks for the diagnosis and the repro tests — the write-up made the ordering bug easy to confirm. |
|
Retitled and rewrote the description to match CI is green on this head: 23 checks pass, including |
Summary
With a YAML-configured streamable-http MCP server whose
Authorizationheader uses{{LIBRECHAT_OPENID_TOKEN}}, agent MCP tool loading fails whenever the session's OpenID access token is expired, even though the session still holds a valid refresh token and direct OpenID bearer recovery (#15704) is active for that server.The early domain gate
isEarlyDomainAllowed(api/server/services/MCP.js) runsprocessMCPEnvover the whole config with the request-timereq.usersnapshot, whichopenIdJwtStrategypopulates fromreq.session.openidTokenswithout refreshing.processSingleValue(packages/api/src/utils/env.ts) then throwsOpenIDReauthRequiredErrorbecause that snapshot token is stale, before the connection path'sresolveDirectOpenIDBearerConfighook can refresh it.loadTools(api/app/clients/tools/util/handleTools.js) catches and logs the error, the tool is never registered, and the agent run fails withTool "<name>_mcp_<server>" not found.The gate decides from
config.urlalone. The credential it demanded is one it never inspects.How it works
buildMCPDomainValidationConfig(packages/api/src/mcp/domainValidation.ts) narrows the config to the fields the decision actually reads, so resolving it needs no live credential:const validationConfig = processMCPEnv({ user, body: requestBody, dbSourced: isUserSourced(serverConfig), - options: serverConfig, + options: buildMCPDomainValidationConfig(serverConfig), customUserVars: getServerCustomUserVars(userMCPAuthMap, serverName), });apiKey,args,env,headers,oauthandoauth_headersare dropped.urlis deliberately kept, so a credential placeholder there still fails closed, and so do the{{LIBRECHAT_USER_*}}placeholders a URL may depend on.sourceanddbIdare kept because they decide which placeholdersprocessMCPEnvresolves at all, as is the presence or absence ofurlthatisMCPDomainAllowedfails closed on.The argument is never mutated, so direct-bearer recovery keeps the placeholder it knows how to refresh.
An earlier revision of this PR instead resolved the live bearer for the gate. That worked, but it spent an
upstreamTokenProvidercall per gate evaluation, andcreateMCPToolsre-runs the gate for every discovered tool, so a 30-tool server paid roughly 1 + N session round trips per request even when the snapshot was healthy. It also only covered configs whoseAuthorizationheader carries the placeholder, since that is whatisDirectOpenIDBearerRecoveryEnabledrequires; anX-Auth-Tokenor an adminapiKeyholding the same placeholder still failed the gate. Dropping the unread fields needs no IdP work and covers every credential-bearing field.hasDurableMCPAuthorizationin the same file already builds its validation copy this way.Change Type
Testing
Hard to reproduce on the fly, so this is covered by unit tests. Three in
api/server/services/MCP.spec.js:createMCPToolloads the tool when the gate meets an expired snapshot token, calls noupstreamTokenProvider, and passes the domain check a config with noheaderswhile the original keeps its placeholder.OpenIDReauthRequiredErrorwhen the URL carries an unresolvable OpenID credential.createMCPTools.Ten in
packages/api/src/mcp/domainValidation.spec.ts: each credential-bearing field in turn (headers,oauth_headers,env,args,oauth,apiKey), the URL fail-closed case, non-mutation of the argument, and{{LIBRECHAT_USER_ID}}still resolving in a URL that an allowlist then accepts or rejects.Both new
/apicases fail against the pre-fix service.Note on behavior
For a server that is not in direct-bearer-recovery mode but carries an OpenID credential placeholder in a header, re-authentication now surfaces from the connection path rather than from this gate. Same outcome for the user, raised slightly later and with an actionable message, instead of the tool silently disappearing from the agent's toolset.
Checklist