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.