🔐 fix: Prefer WWW-Authenticate resource_metadata Hint for MCP OAuth - #12763
Conversation
Per RFC 9728 §5.1, the `resource_metadata=<url>` parameter in a 401 `WWW-Authenticate: Bearer` challenge is the authoritative protected-resource metadata source. Path-aware `.well-known` discovery was winning over the hint, so split deployments that serve valid-but-wrong metadata at the path-aware endpoint stranded OAuth at defunct authorization servers. Threads the hint through `discoverOAuthProtectedResourceMetadata` via `opts.resourceMetadataUrl` in both startup detection and the OAuth handler, matching the behavior of Claude Desktop, the MCP Inspector, OpenAI tooling, and Microsoft Copilot Studio. Fixes #12761.
GitNexus: 🚀 deployedThe |
There was a problem hiding this comment.
Pull request overview
Fixes MCP OAuth protected-resource discovery to honor the RFC 9728 WWW-Authenticate: Bearer resource_metadata=<url> hint (authoritative when present) instead of always relying on path-aware /.well-known/oauth-protected-resource/<path> discovery, preventing flows from getting stranded on stale metadata in split deployments.
Changes:
- Adds a shared
probeResourceMetadataHint()helper that probes the MCP endpoint (HEAD then POST) and parsesWWW-Authenticatevia the MCP SDK. - Updates OAuth detection and the user-facing OAuth handler to pass
resourceMetadataUrlintodiscoverOAuthProtectedResourceMetadata(...). - Bumps
@modelcontextprotocol/sdkto^1.29.0and adds/updates Jest coverage for hint precedence and fallbacks.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/api/src/mcp/oauth/resourceHint.ts | New shared probe helper to extract resource_metadata/scope/Bearer challenge from 401 responses. |
| packages/api/src/mcp/oauth/resourceHint.test.ts | Unit tests for the probe helper (HEAD/POST fallback, hint extraction, Bearer-only detection, etc.). |
| packages/api/src/mcp/oauth/index.ts | Re-exports the new helper. |
| packages/api/src/mcp/oauth/handler.ts | Threads the hint into SDK protected-resource metadata discovery during OAuth flow initiation. |
| packages/api/src/mcp/oauth/detectOAuth.ts | Reorders detection to probe first and forwards hint into SDK discovery; falls back to Bearer-only detection. |
| packages/api/src/mcp/oauth/detectOAuth.test.ts | Regression tests for hint precedence + additional scenarios. |
| packages/api/src/mcp/tests/handler.test.ts | Asserts the handler forwards hint into the SDK call. |
| packages/api/package.json | Bumps @modelcontextprotocol/sdk to ^1.29.0. |
| api/package.json | Bumps @modelcontextprotocol/sdk to ^1.29.0. |
| package-lock.json | Locks SDK bump to 1.29.0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const fetchFn = this.createOAuthFetch(oauthHeaders); | ||
|
|
||
| /** | ||
| * RFC 9728 §5.1: when the server's 401 `WWW-Authenticate` header advertises a | ||
| * `resource_metadata` URL, use that URL as the authoritative source. Path-aware | ||
| * `.well-known` discovery is a fallback for when the hint is absent — not the | ||
| * other way round — or a split deployment can serve stale/wrong metadata at the | ||
| * path-aware endpoint and strand the flow at a defunct authorization server. | ||
| */ | ||
| const hint = await probeResourceMetadataHint(serverUrl); | ||
| if (hint?.resourceMetadataUrl) { |
There was a problem hiding this comment.
probeResourceMetadataHint(serverUrl) uses the global fetch without the custom fetchFn you build just above (which injects oauthHeaders). If OAuth discovery requires those headers to reach the MCP endpoint (e.g., gateway/Access headers), the probe may fail to observe the Bearer challenge/hint and you’ll fall back to path-aware discovery again. Consider allowing probeResourceMetadataHint to accept a fetch implementation (or headers) and pass fetchFn here so the probe and subsequent discovery observe the same server behavior.
| const hint = await probeResourceMetadataHint(serverUrl); | ||
|
|
||
| const challengeResult = await check401ChallengeMetadata(serverUrl); | ||
| if (challengeResult) return challengeResult; | ||
| const metadataResult = await checkProtectedResourceMetadata(serverUrl, hint?.resourceMetadataUrl); | ||
| if (metadataResult) return metadataResult; |
There was a problem hiding this comment.
resourceMetadataUrl is sourced from the server’s WWW-Authenticate header and then forwarded into discoverOAuthProtectedResourceMetadata, which will fetch that URL. Because the hint can point off-origin, this makes OAuth detection perform network requests to arbitrary hosts. Add SSRF hardening (block private/loopback/resolved-private targets, or restrict to same-origin with serverUrl) before passing the hint into the SDK.
| const hint = await probeResourceMetadataHint(serverUrl); | ||
| if (hint?.resourceMetadataUrl) { | ||
| logger.debug( | ||
| `[MCPOAuth] Using resource_metadata URL from WWW-Authenticate: ${sanitizeUrlForLogging(hint.resourceMetadataUrl.toString())}`, | ||
| ); | ||
| } | ||
|
|
||
| try { | ||
| // Try to discover resource metadata first | ||
| logger.debug( | ||
| `[MCPOAuth] Attempting to discover protected resource metadata from ${serverUrl}`, | ||
| `[MCPOAuth] Attempting to discover protected resource metadata from ${sanitizeUrlForLogging(serverUrl)}`, | ||
| ); | ||
| resourceMetadata = await discoverOAuthProtectedResourceMetadata( | ||
| serverUrl, | ||
| { resourceMetadataUrl: hint?.resourceMetadataUrl }, | ||
| fetchFn, |
There was a problem hiding this comment.
resource_metadata hint URLs come from an untrusted server response and can point to an arbitrary host. Passing hint.resourceMetadataUrl straight into discoverOAuthProtectedResourceMetadata causes the SDK to fetch that URL, which can become an SSRF vector (e.g., hint points to a private IP). Validate the hint URL (ideally reusing the existing SSRF/allowedDomains checks) before forwarding it, and ignore/clear the hint when it fails validation so discovery falls back safely.
Without this, admin-configured `oauthHeaders` (e.g. a gateway API key that fronts the MCP endpoint) were stripped from the probe, causing the gateway to 401 for the wrong reason and masking the real `WWW-Authenticate` hint. The helper now accepts a FetchLike and defaults to global fetch, so the startup detection path is unchanged while the handler passes its OAuth- aware wrapper through.
GitNexus: 🚀 deployedThe |
- Thread `fetchFn` through `probeResourceMetadataHint` so admin-configured `oauthHeaders` reach the probe (a gateway API key that fronts the MCP endpoint would otherwise 401 us for the wrong reason and hide the real Bearer challenge). - Skip the redundant HEAD request in `checkAuthErrorFallback` when the probe already observed a 401/403; fall back to a fresh HEAD only when every probe attempt threw (transient network error). - Narrow the oauth barrel: drop `export * from './resourceHint'` so the helper stays an internal module. - Add `scope` extraction coverage (`Bearer scope="read write"`) and a 403-only observation path; isolate `MCP_OAUTH_ON_AUTH_ERROR=true` in a dedicated suite so precise-outcome tests aren't muddied by the safety net.
MCP SDK 1.28 tightened `McpServer.tool()` to require Zod schemas instead
of plain JSON-Schema objects. Swap the `{ message: { type: 'string' } }`
shape for `z.string()` so the fixture server spins up under SDK 1.29.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b3c8d91f50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (hint?.authChallenge) { | ||
| return { | ||
| requiresOAuth: true, | ||
| method: 'no-metadata-found', | ||
| metadata: null, |
There was a problem hiding this comment.
Avoid treating POST-only 403 as OAuth fallback signal
With MCP_OAUTH_ON_AUTH_ERROR enabled by default, this branch now treats any probe-level authChallenge as OAuth-required, but authChallenge is set from both HEAD and POST probes. That means a non-OAuth server that returns HEAD 200 and POST 403 (common with method/WAF/CSRF enforcement) is now misclassified as requiring OAuth, whereas the previous fallback only checked HEAD and would not trigger. This can incorrectly force OAuth flows and break otherwise valid unauthenticated MCP connections.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in f88c779. The merged flag is now headAuthChallenge, derived from the HEAD probe only — POST's 401/403 is explicitly excluded from the fallback decision since WAF/CSRF rules routinely 403 a body-less JSON POST on endpoints that aren't OAuth-protected. Added a regression test for the HEAD 200 + POST 403 shape.
| resourceMetadata = await discoverOAuthProtectedResourceMetadata( | ||
| serverUrl, | ||
| { resourceMetadataUrl: hint?.resourceMetadataUrl }, | ||
| fetchFn, |
There was a problem hiding this comment.
Validate hinted metadata URL before fetching it
The resource_metadata URL extracted from WWW-Authenticate is passed directly into discoverOAuthProtectedResourceMetadata before any validateOAuthUrl/SSRF checks run. Because this hint is fully server-controlled, a malicious MCP endpoint can cause LibreChat to fetch an arbitrary URL (including internal network targets), and this happens before assertResourceBoundToServer is evaluated. The hint URL should be validated (domain allowlist/SSRF guard) before the SDK call.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already resolved in 0a11980 — the hint URL now runs through validateOAuthUrl (same SSRF + allowedDomains gate used for the authorization server URL) before being passed to discoverOAuthProtectedResourceMetadata. detectOAuth.ts applies the strict variant (no allowedDomains at startup) via isSSRFTarget + resolveHostnameSSRF. Rejected hints fall back to path-aware discovery safely.
The `resource_metadata` URL is echoed from an untrusted MCP server, so handing it straight to the SDK lets a malicious server redirect discovery at private IPs, the cloud metadata service, or any host the admin did not intend to reach. Caught by the Copilot review on #12763. - `handler.ts`: run the hint through the same `validateOAuthUrl` / `allowedDomains` gate that already guards the authorization-server URL; drop it and fall back to path-aware discovery on rejection. - `detectOAuth.ts`: no admin-scoped allowedDomains here, so apply a strict `isSSRFTarget` + DNS resolution check and silently discard any hint pointing at a private/loopback/metadata address. - Tests cover both the hostname-list and DNS-resolution rejection paths and assert the SDK falls back to path-aware discovery unharmed.
GitNexus: 🚀 deployedThe |
|
@codex review |
Matches the main `detectOAuth.test.ts` mock so the SSRF guards added in the previous commit don't touch the real `~/auth` module at test time.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0a11980ffe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); | ||
| const bearerChallenge = /bearer/i.test(wwwAuth); |
There was a problem hiding this comment.
Parse Bearer params from multi-challenge headers
This extraction path misses resource_metadata when the response advertises multiple auth schemes in one WWW-Authenticate value (for example Basic ..., Bearer resource_metadata="..."): extractWWWAuthenticateParams returns {} unless the header starts with Bearer, but bearerChallenge is still set to true by the regex. In that case we silently discard the authoritative hint and fall back to path-aware discovery, which can route OAuth to stale metadata in split deployments (the scenario this change is meant to fix).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in f88c779. When extractWWWAuthenticateParams returns no hint but the header contains Bearer, probeWithMethod now falls back to a regex across the full header for resource_metadata and scope. Added a regression test covering Basic realm="api", Bearer resource_metadata="..." scope="mcp.read".
GitNexus: 🚀 deployedThe |
…cate Two codex findings on #12763: - **P1**: the merged `authChallenge` flag was letting POST-only 401/403 flip the `MCP_OAUTH_ON_AUTH_ERROR` fallback, misclassifying WAF/CSRF-hardened endpoints (HEAD 200 + POST 403) as OAuth-required. Rename to `headAuthChallenge` and derive it only from the HEAD probe, matching the legacy fallback's HEAD-only semantics. Add a regression test. - **P2**: the SDK's `extractWWWAuthenticateParams` only inspects the first scheme token, so multi-scheme headers like `Basic realm="api", Bearer resource_metadata="..."` silently dropped the authoritative Bearer hint. Fall back to a regex across the full header when the SDK returns nothing but Bearer is present. Add a regression test covering the multi-scheme case.
|
@codex review |
GitNexus: 🚀 deployedThe |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f88c779f5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (hint === null) { | ||
| const fallbackResult = await checkAuthErrorFallback(serverUrl); |
There was a problem hiding this comment.
Retry fallback when HEAD probe outcome is missing
The fallback is now gated on hint === null, which only covers the case where both probe attempts threw. If the HEAD probe fails (timeout/network error) but POST returns a normal non-Bearer response, probeResourceMetadataHint returns a non-null object and this block is skipped, so MCP_OAUTH_ON_AUTH_ERROR no longer retries HEAD. That can produce false negatives for OAuth-required servers when the initial HEAD probe transiently fails, even though the fallback exists specifically to classify 401/403 auth errors.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 3367a2d. probeResourceMetadataHint now returns null when HEAD threw and POST didn't produce Bearer/hint — the invariant is "non-null means HEAD was observed." The caller's hint === null gate then correctly routes the transient-HEAD case back through checkAuthErrorFallback's fresh HEAD. Added a regression test: HEAD-ETIMEDOUT + POST-200 + fallback-HEAD-401 yields requiresOAuth: true via the fallback.
Addresses the second external review pass plus codex P2:
- Merge the two stacked JSDoc blocks on `probeResourceMetadataHint` into one
with a proper `@returns` section.
- Only short-circuit HEAD when it delivered the `resource_metadata` hint
itself — a Bearer-without-params HEAD now lets POST run, since some
servers surface their hint only on POST and we were missing it.
- Drop the unused `scope` field from `ResourceHintProbeResult`; no caller
read it, and YAGNI beats a reserved field.
- Remove the redundant `OAUTH_ON_AUTH_ERROR` guard inside
`checkAuthErrorFallback` — the only call site already gates on it.
- Codex P2: signal "HEAD status unknown" via `null` when the HEAD probe
threw and POST returned non-auth. Previously that combination leaked a
`{headAuthChallenge: false}` result and silently skipped the fallback's
retry HEAD, which could misclassify OAuth-required servers after a
transient HEAD failure.
Regression tests cover every path: Bearer-no-hint-on-HEAD + hint-on-POST,
multi-scheme `Basic + Bearer` headers, HEAD-threw + POST-200 retry, and
the WAF/CSRF-only POST 403 case.
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep it up! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
GitNexus: 🚀 deployedThe |
Two NITs from the follow-up review: - Move `bearerChallenge` computation after the `!wwwAuth` guard so the variable is only derived when it can be meaningfully `true`. The early-return path is now a clean unconditional exit. - Add a regression test that asserts `Bearer resource_metadata="not-a-url"` yields `resourceMetadataUrl: undefined` without throwing, locking in the try/catch safety net in `extractHintFromHeader` and the SDK parser alike.
GitNexus: 🚀 deployedThe |
Adds an "Upstream context and related work" section pointing at relevant work on danny-avila/LibreChat: - PR LibreChat-AI#11799 + Issue LibreChat-AI#10641: direct overlap with this plan's MCP Apps work; lists the 10 axes on which v8 intentionally diverges (self-contained HTML, no direct browser networking, one proxy per instance, hop-specific relay validation, manifest-hash approval, etc.). - Issue LibreChat-AI#11997: the only upstream artifact for MCP Tasks (no PR yet); plan's Tasks work is greenfield. - PR LibreChat-AI#12850: 307/308 redirect handling and credential stripping is merged into dev but NOT in HEAD 738003b. Earlier revisions of this plan claimed main already had it; corrected. Phase 0 now tracks the upstream merge or ports the work if it slips. - PR LibreChat-AI#12535, LibreChat-AI#12853, LibreChat-AI#12910, Issue LibreChat-AI#12802: adjacent transport reliability and OAuth hygiene worth tracking. - PRs already in HEAD listed for context (LibreChat-AI#12782, LibreChat-AI#12763, LibreChat-AI#12755, LibreChat-AI#12745, LibreChat-AI#12812). - Notably absent upstream: session-id reuse correctness, header consistency, 404 → re-init, per-user token scoping, outstanding-task revalidation, legacy renderer retirement. References section reorganized into Specs / Upstream LibreChat / MDN subsections. https://claude.ai/code/session_011NZqb4xN9QcXpdY2LCtnuH
…ibreChat-AI#12763) * 📦 chore: Bump @modelcontextprotocol/sdk to v1.29.0 * ♻️ refactor: Extract WWW-Authenticate Probe Helper for MCP OAuth * 🔐 fix: Prefer WWW-Authenticate resource_metadata Hint for MCP OAuth Per RFC 9728 §5.1, the `resource_metadata=<url>` parameter in a 401 `WWW-Authenticate: Bearer` challenge is the authoritative protected-resource metadata source. Path-aware `.well-known` discovery was winning over the hint, so split deployments that serve valid-but-wrong metadata at the path-aware endpoint stranded OAuth at defunct authorization servers. Threads the hint through `discoverOAuthProtectedResourceMetadata` via `opts.resourceMetadataUrl` in both startup detection and the OAuth handler, matching the behavior of Claude Desktop, the MCP Inspector, OpenAI tooling, and Microsoft Copilot Studio. Fixes LibreChat-AI#12761. * 🧵 fix: Thread OAuth-Aware fetchFn Through Resource-Metadata Probe Without this, admin-configured `oauthHeaders` (e.g. a gateway API key that fronts the MCP endpoint) were stripped from the probe, causing the gateway to 401 for the wrong reason and masking the real `WWW-Authenticate` hint. The helper now accepts a FetchLike and defaults to global fetch, so the startup detection path is unchanged while the handler passes its OAuth- aware wrapper through. * 🧹 refactor: Address MCP OAuth Probe Review Findings - Thread `fetchFn` through `probeResourceMetadataHint` so admin-configured `oauthHeaders` reach the probe (a gateway API key that fronts the MCP endpoint would otherwise 401 us for the wrong reason and hide the real Bearer challenge). - Skip the redundant HEAD request in `checkAuthErrorFallback` when the probe already observed a 401/403; fall back to a fresh HEAD only when every probe attempt threw (transient network error). - Narrow the oauth barrel: drop `export * from './resourceHint'` so the helper stays an internal module. - Add `scope` extraction coverage (`Bearer scope="read write"`) and a 403-only observation path; isolate `MCP_OAUTH_ON_AUTH_ERROR=true` in a dedicated suite so precise-outcome tests aren't muddied by the safety net. * ✅ fix: Use Zod Schema in MCP Reconnection-Storm Test Tool MCP SDK 1.28 tightened `McpServer.tool()` to require Zod schemas instead of plain JSON-Schema objects. Swap the `{ message: { type: 'string' } }` shape for `z.string()` so the fixture server spins up under SDK 1.29. * 🛡️ fix: Harden MCP OAuth resource_metadata Hint Against SSRF The `resource_metadata` URL is echoed from an untrusted MCP server, so handing it straight to the SDK lets a malicious server redirect discovery at private IPs, the cloud metadata service, or any host the admin did not intend to reach. Caught by the Copilot review on LibreChat-AI#12763. - `handler.ts`: run the hint through the same `validateOAuthUrl` / `allowedDomains` gate that already guards the authorization-server URL; drop it and fall back to path-aware discovery on rejection. - `detectOAuth.ts`: no admin-scoped allowedDomains here, so apply a strict `isSSRFTarget` + DNS resolution check and silently discard any hint pointing at a private/loopback/metadata address. - Tests cover both the hostname-list and DNS-resolution rejection paths and assert the SDK falls back to path-aware discovery unharmed. * 🧪 test: Mock ~/auth in fallback Suite for Consistency Matches the main `detectOAuth.test.ts` mock so the SSRF guards added in the previous commit don't touch the real `~/auth` module at test time. * 🔍 fix: Scope OAuth Fallback to HEAD + Parse Multi-Scheme WWW-Authenticate Two codex findings on LibreChat-AI#12763: - **P1**: the merged `authChallenge` flag was letting POST-only 401/403 flip the `MCP_OAUTH_ON_AUTH_ERROR` fallback, misclassifying WAF/CSRF-hardened endpoints (HEAD 200 + POST 403) as OAuth-required. Rename to `headAuthChallenge` and derive it only from the HEAD probe, matching the legacy fallback's HEAD-only semantics. Add a regression test. - **P2**: the SDK's `extractWWWAuthenticateParams` only inspects the first scheme token, so multi-scheme headers like `Basic realm="api", Bearer resource_metadata="..."` silently dropped the authoritative Bearer hint. Fall back to a regex across the full header when the SDK returns nothing but Bearer is present. Add a regression test covering the multi-scheme case. * 🧽 refactor: Tighten MCP OAuth Probe Semantics Addresses the second external review pass plus codex P2: - Merge the two stacked JSDoc blocks on `probeResourceMetadataHint` into one with a proper `@returns` section. - Only short-circuit HEAD when it delivered the `resource_metadata` hint itself — a Bearer-without-params HEAD now lets POST run, since some servers surface their hint only on POST and we were missing it. - Drop the unused `scope` field from `ResourceHintProbeResult`; no caller read it, and YAGNI beats a reserved field. - Remove the redundant `OAUTH_ON_AUTH_ERROR` guard inside `checkAuthErrorFallback` — the only call site already gates on it. - Codex P2: signal "HEAD status unknown" via `null` when the HEAD probe threw and POST returned non-auth. Previously that combination leaked a `{headAuthChallenge: false}` result and silently skipped the fallback's retry HEAD, which could misclassify OAuth-required servers after a transient HEAD failure. Regression tests cover every path: Bearer-no-hint-on-HEAD + hint-on-POST, multi-scheme `Basic + Bearer` headers, HEAD-threw + POST-200 retry, and the WAF/CSRF-only POST 403 case. * 🪥 polish: Tighten Probe Null-Guard Ordering + Add Malformed-Hint Test Two NITs from the follow-up review: - Move `bearerChallenge` computation after the `!wwwAuth` guard so the variable is only derived when it can be meaningfully `true`. The early-return path is now a clean unconditional exit. - Add a regression test that asserts `Bearer resource_metadata="not-a-url"` yields `resourceMetadataUrl: undefined` without throwing, locking in the try/catch safety net in `extractHintFromHeader` and the SDK parser alike.
…ibreChat-AI#12763) * 📦 chore: Bump @modelcontextprotocol/sdk to v1.29.0 * ♻️ refactor: Extract WWW-Authenticate Probe Helper for MCP OAuth * 🔐 fix: Prefer WWW-Authenticate resource_metadata Hint for MCP OAuth Per RFC 9728 §5.1, the `resource_metadata=<url>` parameter in a 401 `WWW-Authenticate: Bearer` challenge is the authoritative protected-resource metadata source. Path-aware `.well-known` discovery was winning over the hint, so split deployments that serve valid-but-wrong metadata at the path-aware endpoint stranded OAuth at defunct authorization servers. Threads the hint through `discoverOAuthProtectedResourceMetadata` via `opts.resourceMetadataUrl` in both startup detection and the OAuth handler, matching the behavior of Claude Desktop, the MCP Inspector, OpenAI tooling, and Microsoft Copilot Studio. Fixes LibreChat-AI#12761. * 🧵 fix: Thread OAuth-Aware fetchFn Through Resource-Metadata Probe Without this, admin-configured `oauthHeaders` (e.g. a gateway API key that fronts the MCP endpoint) were stripped from the probe, causing the gateway to 401 for the wrong reason and masking the real `WWW-Authenticate` hint. The helper now accepts a FetchLike and defaults to global fetch, so the startup detection path is unchanged while the handler passes its OAuth- aware wrapper through. * 🧹 refactor: Address MCP OAuth Probe Review Findings - Thread `fetchFn` through `probeResourceMetadataHint` so admin-configured `oauthHeaders` reach the probe (a gateway API key that fronts the MCP endpoint would otherwise 401 us for the wrong reason and hide the real Bearer challenge). - Skip the redundant HEAD request in `checkAuthErrorFallback` when the probe already observed a 401/403; fall back to a fresh HEAD only when every probe attempt threw (transient network error). - Narrow the oauth barrel: drop `export * from './resourceHint'` so the helper stays an internal module. - Add `scope` extraction coverage (`Bearer scope="read write"`) and a 403-only observation path; isolate `MCP_OAUTH_ON_AUTH_ERROR=true` in a dedicated suite so precise-outcome tests aren't muddied by the safety net. * ✅ fix: Use Zod Schema in MCP Reconnection-Storm Test Tool MCP SDK 1.28 tightened `McpServer.tool()` to require Zod schemas instead of plain JSON-Schema objects. Swap the `{ message: { type: 'string' } }` shape for `z.string()` so the fixture server spins up under SDK 1.29. * 🛡️ fix: Harden MCP OAuth resource_metadata Hint Against SSRF The `resource_metadata` URL is echoed from an untrusted MCP server, so handing it straight to the SDK lets a malicious server redirect discovery at private IPs, the cloud metadata service, or any host the admin did not intend to reach. Caught by the Copilot review on LibreChat-AI#12763. - `handler.ts`: run the hint through the same `validateOAuthUrl` / `allowedDomains` gate that already guards the authorization-server URL; drop it and fall back to path-aware discovery on rejection. - `detectOAuth.ts`: no admin-scoped allowedDomains here, so apply a strict `isSSRFTarget` + DNS resolution check and silently discard any hint pointing at a private/loopback/metadata address. - Tests cover both the hostname-list and DNS-resolution rejection paths and assert the SDK falls back to path-aware discovery unharmed. * 🧪 test: Mock ~/auth in fallback Suite for Consistency Matches the main `detectOAuth.test.ts` mock so the SSRF guards added in the previous commit don't touch the real `~/auth` module at test time. * 🔍 fix: Scope OAuth Fallback to HEAD + Parse Multi-Scheme WWW-Authenticate Two codex findings on LibreChat-AI#12763: - **P1**: the merged `authChallenge` flag was letting POST-only 401/403 flip the `MCP_OAUTH_ON_AUTH_ERROR` fallback, misclassifying WAF/CSRF-hardened endpoints (HEAD 200 + POST 403) as OAuth-required. Rename to `headAuthChallenge` and derive it only from the HEAD probe, matching the legacy fallback's HEAD-only semantics. Add a regression test. - **P2**: the SDK's `extractWWWAuthenticateParams` only inspects the first scheme token, so multi-scheme headers like `Basic realm="api", Bearer resource_metadata="..."` silently dropped the authoritative Bearer hint. Fall back to a regex across the full header when the SDK returns nothing but Bearer is present. Add a regression test covering the multi-scheme case. * 🧽 refactor: Tighten MCP OAuth Probe Semantics Addresses the second external review pass plus codex P2: - Merge the two stacked JSDoc blocks on `probeResourceMetadataHint` into one with a proper `@returns` section. - Only short-circuit HEAD when it delivered the `resource_metadata` hint itself — a Bearer-without-params HEAD now lets POST run, since some servers surface their hint only on POST and we were missing it. - Drop the unused `scope` field from `ResourceHintProbeResult`; no caller read it, and YAGNI beats a reserved field. - Remove the redundant `OAUTH_ON_AUTH_ERROR` guard inside `checkAuthErrorFallback` — the only call site already gates on it. - Codex P2: signal "HEAD status unknown" via `null` when the HEAD probe threw and POST returned non-auth. Previously that combination leaked a `{headAuthChallenge: false}` result and silently skipped the fallback's retry HEAD, which could misclassify OAuth-required servers after a transient HEAD failure. Regression tests cover every path: Bearer-no-hint-on-HEAD + hint-on-POST, multi-scheme `Basic + Bearer` headers, HEAD-threw + POST-200 retry, and the WAF/CSRF-only POST 403 case. * 🪥 polish: Tighten Probe Null-Guard Ordering + Add Malformed-Hint Test Two NITs from the follow-up review: - Move `bearerChallenge` computation after the `!wwwAuth` guard so the variable is only derived when it can be meaningfully `true`. The early-return path is now a clean unconditional exit. - Add a regression test that asserts `Bearer resource_metadata="not-a-url"` yields `resourceMetadataUrl: undefined` without throwing, locking in the try/catch safety net in `extractHintFromHeader` and the SDK parser alike.
Summary
I fixed MCP OAuth discovery so LibreChat honors the
resource_metadata=<url>hint from a server's 401WWW-Authenticate: Bearerchallenge instead of unconditionally falling back to RFC 9728 path-aware.well-knowndiscovery. Fixes #12761.Per RFC 9728 §5.1, the hint is authoritative when present. Other mainstream MCP clients (Claude Desktop, MCP Inspector, OpenAI tooling, Microsoft Copilot Studio) already behave this way. LibreChat diverged because both
detectOAuth.tsandhandler.tscalleddiscoverOAuthProtectedResourceMetadata(serverUrl)without forwarding the hint, so in split deployments — where the root and path-aware well-known endpoints are served by different processes — a valid-but-wrong path-aware document could strand the OAuth flow at a defunct authorization server (HTTP 404on/register).packages/api/src/mcp/oauth/resourceHint.ts) that sends HEAD then POST to the MCP URL, uses the SDK'sextractWWWAuthenticateParamsto parse the challenge, and returns the hint URL / scope / bearer-challenge flag. Non-Bearer 401s are treated as uninformative so MCP servers that only emit their Bearer challenge on POST still get probed.detectOAuthRequirementto probe first, then calldiscoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: hint }). The SDK now fetches the authoritative document (or falls back to path-aware when no hint is present), and the raw-fetch-and-parse path for hinted metadata is gone.MCPOAuthHandler.discoverMetadataso the user-facing OAuth flow benefits from the same precedence (this is the call site that was actually failing in the issue reproduction).@modelcontextprotocol/sdkfrom^1.27.1to^1.29.0in bothapi/andpackages/api/to pick upextractWWWAuthenticateParamsplus the 1.28 fix that defaults toclient_secret_basicwhen the server omitstoken_endpoint_auth_methods_supported.resourceHint.test.ts(6 tests) covering HEAD/POST fallback, hint extraction, Bearer-only detection, non-Bearer short-circuit, and probe-failure safety.detectOAuth.test.ts(hint preferred over path-aware, hint fetch failure falls back to Bearer-only, pure path-aware when no challenge).handler.test.tsasserting the hint flows into the SDK call, and an undefinedresourceMetadataUrlis passed through when no hint is present.Closes #12762 — my change supersedes it and covers the same scenario with a reusable helper, HEAD-first probing, SDK-based WWW-Authenticate parsing, and the SDK bump.
Change Type
Testing
cd packages/api && npx jest src/mcp/oauth/resourceHint.test.ts src/mcp/oauth/detectOAuth.test.ts src/mcp/__tests__/handler.test.ts→ 86 tests pass.cd api && npx jest --testPathPatterns='mcp|oauth'→ 184 tests pass.packages/apiJest run confirms the 25 pre-existing failures (Redis/tokenizer/Windows file-lock issues onmain) are unrelated to this change.Checklist