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.