Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 12 additions & 20 deletions client/src/components/OAuthFlowProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -150,13 +158,7 @@ export const OAuthFlowProgress = ({
<div className="mt-2">
<p className="font-medium">Resource Metadata:</p>
<p className="text-xs text-muted-foreground">
From{" "}
{
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
From {resourceMetadataDiscoveryUrl}
</p>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.resourceMetadata, null, 2)}
Expand All @@ -169,22 +171,12 @@ export const OAuthFlowProgress = ({
<p className="text-sm font-medium text-blue-700">
ℹ️ Problem with resource metadata from{" "}
<a
href={
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
href={resourceMetadataDiscoveryUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
{
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
{resourceMetadataDiscoveryUrl}
</a>
</p>
<p className="text-xs text-blue-600 mt-1">
Expand Down
49 changes: 49 additions & 0 deletions client/src/utils/__tests__/oauthUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
parseOAuthCallbackParams,
generateOAuthState,
getAuthorizationServerMetadataDiscoveryUrl,
getResourceMetadataDiscoveryUrl,
} from "@/utils/oauthUtils.ts";

describe("parseOAuthCallbackParams", () => {
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions client/src/utils/oauthUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down