From fbeb7240f7e1157201187eec45297bb290dc154b Mon Sep 17 00:00:00 2001 From: Ankesh Date: Tue, 24 Feb 2026 19:19:05 +0530 Subject: [PATCH] Fix resource metadata discovery URL construction to comply with MCP spec for subpath deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 9728 §3 and the MCP spec (https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements), the .well-known/oauth-protected-resource URL is formed by inserting the well-known prefix at the origin root and appending the resource path after it: https://example.com/public/mcp → https://example.com/.well-known/oauth-protected-resource/public/mcp The previous code used `new URL("/.well-known/oauth-protected-resource", serverUrl)` which always resolves to the host root, ignoring the resource path entirely. This broke metadata discovery for any subpath-deployed MCP server (issue #1008). Changes: - Add `getResourceMetadataDiscoveryUrl()` in oauthUtils.ts that prepends /.well-known/oauth-protected-resource at the origin and appends the resource pathname (trailing slash stripped), matching the MCP SDK implementation - Replace all three inline well-known URL constructions in OAuthFlowProgress.tsx with the new utility via a memoized `resourceMetadataDiscoveryUrl` variable - Add 6 unit tests covering: single-segment path, subpath, deep path, trailing slash, no path, and URL object input Co-Authored-By: Claude Sonnet 4.6 --- client/src/components/OAuthFlowProgress.tsx | 32 +++++------- client/src/utils/__tests__/oauthUtils.test.ts | 49 +++++++++++++++++++ client/src/utils/oauthUtils.ts | 33 +++++++++++++ 3 files changed, 94 insertions(+), 20 deletions(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 58d4e99fb..c34c14b74 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -6,7 +6,10 @@ import { useEffect, useMemo, useState } from "react"; import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; import { validateRedirectUrl } from "@/utils/urlValidation"; import { useToast } from "@/lib/hooks/useToast"; -import { getAuthorizationServerMetadataDiscoveryUrl } from "@/utils/oauthUtils"; +import { + getAuthorizationServerMetadataDiscoveryUrl, + getResourceMetadataDiscoveryUrl, +} from "@/utils/oauthUtils"; interface OAuthStepProps { label: string; @@ -90,6 +93,11 @@ export const OAuthFlowProgress = ({ return getAuthorizationServerMetadataDiscoveryUrl(authState.authServerUrl); }, [authState.authServerUrl]); + const resourceMetadataDiscoveryUrl = useMemo( + () => getResourceMetadataDiscoveryUrl(serverUrl), + [serverUrl], + ); + const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); useEffect(() => { @@ -150,13 +158,7 @@ export const OAuthFlowProgress = ({

Resource Metadata:

- From{" "} - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } + From {resourceMetadataDiscoveryUrl}

                     {JSON.stringify(authState.resourceMetadata, null, 2)}
@@ -169,22 +171,12 @@ export const OAuthFlowProgress = ({
                   

ℹ️ Problem with resource metadata from{" "} - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } + {resourceMetadataDiscoveryUrl}

diff --git a/client/src/utils/__tests__/oauthUtils.test.ts b/client/src/utils/__tests__/oauthUtils.test.ts index b885b5ecb..1e6f57bc9 100644 --- a/client/src/utils/__tests__/oauthUtils.test.ts +++ b/client/src/utils/__tests__/oauthUtils.test.ts @@ -3,6 +3,7 @@ import { parseOAuthCallbackParams, generateOAuthState, getAuthorizationServerMetadataDiscoveryUrl, + getResourceMetadataDiscoveryUrl, } from "@/utils/oauthUtils.ts"; describe("parseOAuthCallbackParams", () => { @@ -86,6 +87,54 @@ describe("generateOAuthErrorDescription", () => { }); }); +describe("getResourceMetadataDiscoveryUrl", () => { + it("appends single-segment resource path after well-known prefix", () => { + expect( + getResourceMetadataDiscoveryUrl("https://example.com/resource"), + ).toBe("https://example.com/.well-known/oauth-protected-resource/resource"); + }); + + it("appends full subpath resource path after well-known prefix", () => { + expect( + getResourceMetadataDiscoveryUrl("https://example.com/public/mcp"), + ).toBe( + "https://example.com/.well-known/oauth-protected-resource/public/mcp", + ); + }); + + it("appends deeply nested resource path after well-known prefix", () => { + expect( + getResourceMetadataDiscoveryUrl("https://example.com/foo/bar/resource"), + ).toBe( + "https://example.com/.well-known/oauth-protected-resource/foo/bar/resource", + ); + }); + + it("strips trailing slash before appending resource path", () => { + expect( + getResourceMetadataDiscoveryUrl("https://example.com/public/mcp/"), + ).toBe( + "https://example.com/.well-known/oauth-protected-resource/public/mcp", + ); + }); + + it("returns bare well-known URL when resource URL has no path", () => { + expect(getResourceMetadataDiscoveryUrl("https://example.com")).toBe( + "https://example.com/.well-known/oauth-protected-resource", + ); + }); + + it("accepts a URL object as input", () => { + expect( + getResourceMetadataDiscoveryUrl( + new URL("https://example.com/public/mcp"), + ), + ).toBe( + "https://example.com/.well-known/oauth-protected-resource/public/mcp", + ); + }); +}); + describe("getAuthorizationServerMetadataDiscoveryUrl", () => { it("uses root discovery URL for root authorization server URL", () => { expect( diff --git a/client/src/utils/oauthUtils.ts b/client/src/utils/oauthUtils.ts index cb6faf277..806fca7c7 100644 --- a/client/src/utils/oauthUtils.ts +++ b/client/src/utils/oauthUtils.ts @@ -88,6 +88,39 @@ export const generateOAuthErrorDescription = ( .join("\n"); }; +/** + * Compute the `.well-known/oauth-protected-resource` URL in compliance with + * the MCP spec and RFC 9728 for protected resource metadata discovery. + * + * Per RFC 9728 §3, the resource path is appended after the well-known prefix + * at the origin root: + * - `https://host/resource` → `https://host/.well-known/oauth-protected-resource/resource` + * - `https://host/public/mcp` → `https://host/.well-known/oauth-protected-resource/public/mcp` + * - `https://host` (no path) → `https://host/.well-known/oauth-protected-resource` + * + * @see https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements + * @see https://www.rfc-editor.org/rfc/rfc9728#section-3 + * @param resourceUrl - Full string URL or URL object for the resource endpoint + */ +export function getResourceMetadataDiscoveryUrl( + resourceUrl: string | URL, +): string { + const url = + typeof resourceUrl === "string" ? new URL(resourceUrl) : resourceUrl; + + // Strip trailing slash (except for bare origin) to avoid a double slash + // or a spurious trailing slash in the well-known URL. + const pathname = + url.pathname.endsWith("/") && url.pathname !== "/" + ? url.pathname.slice(0, -1) + : url.pathname; + + const path = pathname === "/" ? "" : pathname; + + return new URL(`/.well-known/oauth-protected-resource${path}`, url.origin) + .href; +} + /** * Returns the primary OAuth authorization server metadata discovery URL * for a given authorization server URL, including tenant path handling.