Uh oh!
There was an error while loading. Please reload this page.
fix(oauth): keep the server's path in the fallback authorization-server URL (#2110) - #2136
Conversation
…er URL
When RFC 9728 protected-resource metadata names no authorization server, the
MCP server URL stands in as one. `getAuthorizationServerUrl` built that with
`new URL("/", serverUrl)`, which discards the path — exactly the part discovery
needs when the server is not hosted at the domain root. The SDK's
`buildDiscoveryUrls` derives the path-scoped well-known locations from that
pathname, so an origin-only value could only ever probe the domain root, which
404s for a server like `https://example.com/mod/minilesson/mcp.php`.
Keep the path (and strip query/fragment, which an RFC 8414 issuer never
carries). A straight swap would regress the case the origin-only value did
serve — a server that merely lives under a path while publishing its metadata
at the root — so the URL becomes the first of two candidates:
`getAuthorizationServerUrlCandidates` yields the path-scoped form then the bare
origin, and `discoverAuthorizationServerMetadataForServer` walks them, treating
a candidate's failure as non-fatal while another remains and rethrowing the
first error only when all of them fail.
`discoverScopes` and the CIMD pre-registration probe both go through the walk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>There was a problem hiding this comment.
Pull request overview
Fixes OAuth metadata discovery for MCP servers hosted below the domain root.
Changes:
- Preserves server paths and tries path-scoped discovery before the origin.
- Applies candidate discovery to scope and CIMD flows.
- Adds regression tests and architectural documentation.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
core/auth/discovery.ts | Adds path-preserving candidate discovery. |
core/auth/cimd.ts | Uses candidate discovery for CIMD probing. |
clients/web/src/test/integration/auth/discovery.test.ts | Tests path-hosted discovery behavior. |
AGENTS.md | Documents the authorization-server URL policy. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Copilot review round 1. `refreshStoredAuthToken` consumes the same fallback and made a single SDK discovery call, so preserving the path there — without the walk the other two consumers got — would have regressed exactly the case the walk exists to keep: a path-hosted MCP server whose authorization metadata is published at the domain root. It injects its own discovery function as a test seam, so the walk is now also exposed as `discoverAuthorizationServerMetadataFromCandidates(candidates, discover)`, which takes that function and reports *which* candidate answered — the CLI needs the winning URL, since it is the base the token request is made against, not just the metadata. Also drops the `as unknown as typeof fetch` from the new test in favour of `vi.fn<typeof fetch>()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
cliffhall
commented
Aug 26, 2026
Copilot review round 1 — both comments addressed (2bb0b62)Mirroring the inline replies here, since they get folded away once the fix is pushed. 1. Copilot is right, and this was the one consumer I left behind. Two details shaped the fix:
When no candidate answers, Three regression tests in 2. Unjustified Replaced with
|
Closes#2110
The defect
When RFC 9728 protected-resource metadata names no authorization server, the MCP server URL stands in as one.
core/auth/discovery.tsbuilt that URL with:which discards the path — exactly the part discovery needs when the server is not hosted at the domain root. The SDK's
buildDiscoveryUrlsderives the path-scoped well-known locations (/.well-known/oauth-authorization-server{path},/.well-known/openid-configuration{path},{path}/.well-known/openid-configuration) from that pathname, so an origin-only value can only ever probe the domain root. For the reporter's server —https://misc.poodll.com/mod/minilesson/mcp.php— every probe 404s and the flow dies before authorization is ever reached.The fix
Keep the path, and strip query/fragment (an RFC 8414 issuer identifier carries neither, and the SDK compares the discovered
issueragainst this URL).A straight swap would have regressed the case the origin-only value did serve: a server that merely lives under a path while publishing its metadata at the domain root. So the corrected URL becomes the first of two candidates rather than the only answer:
getAuthorizationServerUrlCandidates()— the path-scoped URL first, the bare origin second. When protected-resource metadata does name an authorization server there is exactly one candidate; the server told us where to look, and probing past it would be guessing.discoverAuthorizationServerMetadataForServer()— walks them. A candidate that throws (a 5xx, or an RFC 8414 §3.3 issuer mismatch, both of which the SDK raises rather than returningundefined) is not fatal while another candidate remains; if every candidate fails the first error is rethrown, so callers keep the original diagnosis instead of a bareundefined.Both in-repo consumers go through the walk:
discoverScopes()and the CIMD pre-registration probe (core/auth/cimd.ts). EMA'sresourceContextkeeps the single-URLgetAuthorizationServerUrl(), which now preserves the path too — for a path-hosted resource the full URL is the more correct audience.Scope — what this PR does not fix
The issue reports three defects. Only the first has a copy in this repo; the other two are in the pinned SDK (
@modelcontextprotocol/client@2.0.0) and are not reachable from here:core/auth/discovery.tsand the SDK'sdiscoverOAuthServerInfoauth()and is not overridable./.well-known/openid-configurationforces the full OIDC schema, rejecting a plain OAuth 2.0 ASOpenIdProviderDiscoveryMetadataSchemadiscoverMetadataWithFallbackresource_metadataon its 401 gets that URL used verbatim.Tests
clients/web/src/test/integration/auth/discovery.test.tsgains apath-hosted MCP servers (#2110)block: path preserved, query/fragment stripped, candidate ordering (path-hosted → 2, root-hosted → 1, metadata-named → 1), and the walk's five outcomes — first candidate wins without probing further, falls through onundefined, survives a throwing candidate, rethrows the first error when all fail, and forwards the caller'sfetchFnto every candidate.npm run cipasses. Coverage:discovery.ts100% lines / 95.45% branches,cimd.tsunchanged at 92.85%.No UI surface changed, so there are no screenshots to attach.
🤖 Generated with Claude Code
https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev