Skip to content

🔐 fix: Prefer WWW-Authenticate resource_metadata Hint for MCP OAuth - #12763

Merged
danny-avila merged 11 commits into
devfrom
claude/nice-noether-b1d667
Apr 21, 2026
Merged

danny-avila merged 11 commits into
devfrom
claude/nice-noether-b1d667

Conversation

@danny-avila

Copy link
Copy Markdown
Collaborator

Summary

I fixed MCP OAuth discovery so LibreChat honors the resource_metadata=<url> hint from a server's 401 WWW-Authenticate: Bearer challenge instead of unconditionally falling back to RFC 9728 path-aware .well-known discovery. 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.ts and handler.ts called discoverOAuthProtectedResourceMetadata(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 404 on /register).

  • Extracted a shared probe helper (packages/api/src/mcp/oauth/resourceHint.ts) that sends HEAD then POST to the MCP URL, uses the SDK's extractWWWAuthenticateParams to 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.
  • Rewrote detectOAuthRequirement to probe first, then call discoverOAuthProtectedResourceMetadata(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.
  • Threaded the hint into MCPOAuthHandler.discoverMetadata so the user-facing OAuth flow benefits from the same precedence (this is the call site that was actually failing in the issue reproduction).
  • Upgraded @modelcontextprotocol/sdk from ^1.27.1 to ^1.29.0 in both api/ and packages/api/ to pick up extractWWWAuthenticateParams plus the 1.28 fix that defaults to client_secret_basic when the server omits token_endpoint_auth_methods_supported.
  • Added resourceHint.test.ts (6 tests) covering HEAD/POST fallback, hint extraction, Bearer-only detection, non-Bearer short-circuit, and probe-failure safety.
  • Added regression tests in detectOAuth.test.ts (hint preferred over path-aware, hint fetch failure falls back to Bearer-only, pure path-aware when no challenge).
  • Added 3 tests in handler.test.ts asserting the hint flows into the SDK call, and an undefined resourceMetadataUrl is 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

  • Bug fix (non-breaking change which fixes an issue)

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.
  • Full packages/api Jest run confirms the 25 pre-existing failures (Redis/tokenizer/Windows file-lock issues on main) are unrelated to this change.
  • Manual repro with a fixture MCP server whose path-aware well-known returns a stale authorization server while the root one returns the correct one: LibreChat now follows the hint to the correct AS and completes DCR.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

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.
@github-actions

Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12763 index is now live on the MCP server.
Deploy run

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 parses WWW-Authenticate via the MCP SDK.
  • Updates OAuth detection and the user-facing OAuth handler to pass resourceMetadataUrl into discoverOAuthProtectedResourceMetadata(...).
  • Bumps @modelcontextprotocol/sdk to ^1.29.0 and 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.

Comment on lines 146 to +156
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) {

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +38
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;

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread packages/api/src/mcp/oauth/handler.ts Outdated
Comment on lines +155 to +169
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,

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.
@github-actions

Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12763 index is now live on the MCP server.
Deploy run

- 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +55 to +59
if (hint?.authChallenge) {
return {
requiresOAuth: true,
method: 'no-metadata-found',
metadata: null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +170 to +173
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
serverUrl,
{ resourceMetadataUrl: hint?.resourceMetadataUrl },
fetchFn,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@github-actions

Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12763 index is now live on the MCP server.
Deploy run

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +96 to +97
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
const bearerChallenge = /bearer/i.test(wwwAuth);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

@github-actions

Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12763 index is now live on the MCP server.
Deploy run

…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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@github-actions

Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12763 index is now live on the MCP server.
Deploy run

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +75 to +76
if (hint === null) {
const fallbackResult = await checkAuthErrorFallback(serverUrl);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

ℹ️ 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".

@github-actions

Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12763 index is now live on the MCP server.
Deploy run

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.
@github-actions

Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12763 index is now live on the MCP server.
Deploy run

@danny-avila
danny-avila merged commit f5c4dea into dev Apr 21, 2026
12 checks passed
@danny-avila
danny-avila deleted the claude/nice-noether-b1d667 branch April 21, 2026 23:26
mbiskach pushed a commit to mbiskach/LibreChat that referenced this pull request May 3, 2026
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
jcbartle pushed a commit to jcbartle/LibreChat that referenced this pull request May 11, 2026
…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.
ThomasVuNguyen pushed a commit to ThomasVuNguyen/LibreChat that referenced this pull request Jul 15, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: MCP OAuth discovery ignores WWW-Authenticate resource_metadata hint; wrong metadata chosen when path-aware endpoint diverges from root

2 participants