feat: unify OAuth scope discovery between automatic and manual flows

- Add shared discoverScopes function in auth.ts with resource metadata preference
- Update automatic flow (handleAuthError) to use dynamic scope discovery
- Update manual flow (OAuth state machine) to use shared scope discovery logic
- Add comprehensive test suite for discoverScopes function (11 test cases)
- Fix empty array handling to return undefined instead of empty string
- Maintain backward compatibility with existing oauthScope parameter
- All quality gates pass: TypeScript, linting, formatting, and tests

Resolves OAuth scope discovery inconsistency where automatic flow used static
scopes while manual flow used metadata scopes. Both flows now use identical
shared logic with proper resource metadata preference.
This commit is contained in:
Bobby Tiernay 2025-08-11 20:02:37 -04:00
commit 74900fca5d
No known key found for this signature in database
GPG key ID: EAD61E52CC15DCD5

View file

@ -25,6 +25,7 @@ const mockOAuthMetadata = {
token_endpoint: "https://oauth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
scopes_supported: ["read", "write"],
};
const mockOAuthClientInfo = {
@ -56,6 +57,40 @@ import {
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
// Mock local auth module
jest.mock("@/lib/auth", () => ({
DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
clear: jest.fn().mockImplementation(() => {
// Mock the real clear() behavior which removes items from sessionStorage
sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens");
sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info");
sessionStorage.removeItem(
"[https://example.com/mcp] mcp_server_metadata",
);
}),
redirectUrl: "http://localhost:3000/oauth/callback/debug",
clientMetadata: {
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector",
},
clientInformation: jest.fn(),
saveClientInformation: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
saveServerMetadata: jest.fn(),
getServerMetadata: jest.fn(),
})),
discoverScopes: jest.fn().mockResolvedValue("read write" as never),
}));
import { discoverScopes } from "@/lib/auth";
// Type the mocked functions properly
const mockDiscoverAuthorizationServerMetadata =
discoverAuthorizationServerMetadata as jest.MockedFunction<
@ -75,6 +110,9 @@ const mockDiscoverOAuthProtectedResourceMetadata =
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
typeof discoverOAuthProtectedResourceMetadata
>;
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
typeof discoverScopes
>;
const sessionStorageMock = {
getItem: jest.fn(),
@ -103,9 +141,15 @@ describe("AuthDebugger", () => {
// Suppress console errors in tests to avoid JSDOM navigation noise
jest.spyOn(console, "error").mockImplementation(() => {});
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
mockOAuthMetadata,
);
// Set default mock behaviors with complete OAuth metadata
mockDiscoverAuthorizationServerMetadata.mockResolvedValue({
issuer: "https://oauth.example.com",
authorization_endpoint: "https://oauth.example.com/authorize",
token_endpoint: "https://oauth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
scopes_supported: ["read", "write"],
});
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
new Error("No protected resource metadata found"),
@ -427,7 +471,24 @@ describe("AuthDebugger", () => {
});
});
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
it("should include scope in authorization URL when scopes_supported is not present", async () => {
const updateAuthState =
await setupAuthorizationUrlTest(mockOAuthMetadata);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
}),
);
});
});
it("should omit scope from authorization URL when discoverScopes returns undefined", async () => {
// Mock discoverScopes to return undefined (no scopes available)
mockDiscoverScopes.mockResolvedValueOnce(undefined);
const updateAuthState =
await setupAuthorizationUrlTest(mockOAuthMetadata);