Skip to content

[auth] Add resource compatibility to debugger (RFC 8707) #526

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 23, 2025
Merged
2 changes: 1 addition & 1 deletion client/src/components/OAuthFlowProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const OAuthFlowProgress = ({
{authState.resourceMetadataError && (
<div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md">
<p className="text-sm font-medium text-blue-700">
ℹ️ No resource metadata available from{" "}
ℹ️ Problem with resource metadata from{" "}
<a
href={
new URL(
Expand Down
22 changes: 12 additions & 10 deletions client/src/components/__tests__/AuthDebugger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
discoverOAuthProtectedResourceMetadata: jest.fn(),
selectResourceURL: jest.fn(),
}));

// Import the functions to get their types
Expand Down Expand Up @@ -88,7 +89,7 @@ describe("AuthDebugger", () => {
const defaultAuthState = EMPTY_DEBUGGER_STATE;

const defaultProps = {
serverUrl: "https://example.com",
serverUrl: "https://example.com/mcp",
onBack: jest.fn(),
authState: defaultAuthState,
updateAuthState: jest.fn(),
Expand Down Expand Up @@ -203,7 +204,7 @@ describe("AuthDebugger", () => {

// Should first discover and save OAuth metadata
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"),
new URL("https://example.com/"),
);

// Check that updateAuthState was called with the right info message
Expand Down Expand Up @@ -320,6 +321,7 @@ describe("AuthDebugger", () => {
isInitiatingAuth: false,
resourceMetadata: null,
resourceMetadataError: null,
resource: null,
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
Expand Down Expand Up @@ -361,7 +363,7 @@ describe("AuthDebugger", () => {
});

expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"),
new URL("https://example.com/"),
);
});

Expand Down Expand Up @@ -496,11 +498,11 @@ describe("AuthDebugger", () => {
it("should successfully fetch and display protected resource metadata", async () => {
const updateAuthState = jest.fn();
const mockResourceMetadata = {
resource: "https://example.com/api",
resource: "https://example.com/mcp",
authorization_servers: ["https://custom-auth.example.com"],
bearer_methods_supported: ["header", "body"],
resource_documentation: "https://example.com/api/docs",
resource_policy_uri: "https://example.com/api/policy",
resource_documentation: "https://example.com/mcp/docs",
resource_policy_uri: "https://example.com/mcp/policy",
};

// Mock successful metadata discovery
Expand Down Expand Up @@ -538,7 +540,7 @@ describe("AuthDebugger", () => {
// Wait for the metadata to be fetched
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com",
"https://example.com/mcp",
);
});

Expand Down Expand Up @@ -584,7 +586,7 @@ describe("AuthDebugger", () => {
// Wait for the metadata fetch to fail
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com",
"https://example.com/mcp",
);
});

Expand All @@ -594,15 +596,15 @@ describe("AuthDebugger", () => {
expect.objectContaining({
resourceMetadataError: mockError,
// Should use the original server URL as fallback
authServerUrl: new URL("https://example.com"),
authServerUrl: new URL("https://example.com/"),
oauthStep: "client_registration",
}),
);
});

// Verify that regular OAuth metadata discovery was still called
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"),
new URL("https://example.com/"),
);
});
});
Expand Down
2 changes: 2 additions & 0 deletions client/src/lib/auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface AuthDebuggerState {
oauthStep: OAuthStep;
resourceMetadata: OAuthProtectedResourceMetadata | null;
resourceMetadataError: Error | null;
resource: URL | null;
authServerUrl: URL | null;
oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
Expand All @@ -47,6 +48,7 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = {
oauthMetadata: null,
resourceMetadata: null,
resourceMetadataError: null,
resource: null,
authServerUrl: null,
oauthClientInfo: null,
authorizationUrl: null,
Expand Down
19 changes: 14 additions & 5 deletions client/src/lib/oauth-state-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
startAuthorization,
exchangeAuthorization,
discoverOAuthProtectedResourceMetadata,
selectResourceURL,
} from "@modelcontextprotocol/sdk/client/auth.js";
import {
OAuthMetadataSchema,
Expand All @@ -29,17 +30,15 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
metadata_discovery: {
canTransition: async () => true,
execute: async (context) => {
let authServerUrl = new URL(context.serverUrl);
// Default to discovering from the server's URL
let authServerUrl = new URL("/", context.serverUrl);
let resourceMetadata: OAuthProtectedResourceMetadata | null = null;
let resourceMetadataError: Error | null = null;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
context.serverUrl,
);
if (
resourceMetadata &&
resourceMetadata.authorization_servers?.length
) {
if (resourceMetadata?.authorization_servers?.length) {
authServerUrl = new URL(resourceMetadata.authorization_servers[0]);
}
} catch (e) {
Expand All @@ -50,6 +49,13 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
}
}

const resource: URL | undefined = await selectResourceURL(
context.serverUrl,
context.provider,
// we default to null, so swap it for undefined if not set
resourceMetadata ?? undefined,
);

const metadata = await discoverOAuthMetadata(authServerUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
Expand All @@ -58,6 +64,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
context.provider.saveServerMetadata(parsedMetadata);
context.updateState({
resourceMetadata,
resource,
resourceMetadataError,
authServerUrl,
oauthMetadata: parsedMetadata,
Expand Down Expand Up @@ -113,6 +120,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
clientInformation,
redirectUrl: context.provider.redirectUrl,
scope,
resource: context.state.resource ?? undefined,
},
);

Expand Down Expand Up @@ -163,6 +171,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
authorizationCode: context.state.authorizationCode,
codeVerifier,
redirectUri: context.provider.redirectUrl,
resource: context.state.resource ?? undefined,
});

context.provider.saveTokens(tokens);
Expand Down
9 changes: 4 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"@modelcontextprotocol/inspector-cli": "^0.14.3",
"@modelcontextprotocol/inspector-client": "^0.14.3",
"@modelcontextprotocol/inspector-server": "^0.14.3",
"@modelcontextprotocol/sdk": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.13.1",
"concurrently": "^9.0.1",
"open": "^10.1.0",
"shell-quote": "^1.8.2",
Expand Down