From 1dba680a0ba4f72f93e149e6ea47656a157c6d61 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 23 Feb 2026 22:45:25 +0000 Subject: [PATCH] feat: add OAuth proxy support to eliminate CORS issues Add server-side proxy endpoints for OAuth discovery, client registration, and token exchange to avoid CORS issues when using proxy connection mode. Server proxy endpoints: - GET /oauth/resource-metadata - proxies OAuth protected resource metadata - GET /oauth/metadata - proxies OAuth authorization server metadata - POST /oauth/register - proxies dynamic client registration - POST /oauth/token - proxies token exchange Client changes: - Construct correct RFC 9728/8414 path-aware well-known URLs client-side - Route all OAuth discovery through proxy when in proxy connection mode - Eliminate direct browser fetches that fail with CORS in proxy mode --- .github/workflows/main.yml | 2 +- client/src/App.tsx | 13 +- client/src/components/AuthDebugger.tsx | 25 +- client/src/lib/__tests__/auth.test.ts | 48 ++- client/src/lib/__tests__/oauth-proxy.test.ts | 340 ++++++++++++++++++ client/src/lib/auth.ts | 16 +- .../hooks/__tests__/useConnection.test.tsx | 4 + client/src/lib/hooks/useConnection.ts | 22 +- client/src/lib/oauth-proxy.ts | 238 ++++++++++++ client/src/lib/oauth-state-machine.ts | 122 +++++-- package-lock.json | 2 +- package.json | 2 +- server/src/index.ts | 202 ++++++++++- 13 files changed, 991 insertions(+), 45 deletions(-) create mode 100644 client/src/lib/__tests__/oauth-proxy.test.ts create mode 100644 client/src/lib/oauth-proxy.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2cc4537ba..7cebcf009 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v6 - name: Check formatting - run: npx prettier --check . + run: npx prettier@3.7.4 --check . - uses: actions/setup-node@v6 with: diff --git a/client/src/App.tsx b/client/src/App.tsx index 12e9a7bd0..fc6a35b18 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -622,9 +622,14 @@ const App = () => { }; try { - const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { - currentState = { ...currentState, ...updates }; - }); + const stateMachine = new OAuthStateMachine( + sseUrl, + (updates) => { + currentState = { ...currentState, ...updates }; + }, + connectionType, + config, + ); while ( currentState.oauthStep !== "complete" && @@ -1264,6 +1269,8 @@ const App = () => { onBack={() => setIsAuthDebuggerVisible(false)} authState={authState} updateAuthState={updateAuthState} + connectionType={connectionType} + config={config} /> ); diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 6252c1161..702739696 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -7,12 +7,15 @@ import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthStateMachine } from "../lib/oauth-state-machine"; import { SESSION_KEYS } from "../lib/constants"; import { validateRedirectUrl } from "@/utils/urlValidation"; +import { InspectorConfig } from "../lib/configurationTypes"; export interface AuthDebuggerProps { serverUrl: string; onBack: () => void; authState: AuthDebuggerState; updateAuthState: (updates: Partial) => void; + connectionType: "direct" | "proxy"; + config: InspectorConfig; } interface StatusMessageProps { @@ -60,6 +63,8 @@ const AuthDebugger = ({ onBack, authState, updateAuthState, + connectionType, + config, }: AuthDebuggerProps) => { // Check for existing tokens on mount useEffect(() => { @@ -103,8 +108,9 @@ const AuthDebugger = ({ }, [serverUrl, updateAuthState]); const stateMachine = useMemo( - () => new OAuthStateMachine(serverUrl, updateAuthState), - [serverUrl, updateAuthState], + () => + new OAuthStateMachine(serverUrl, updateAuthState, connectionType, config), + [serverUrl, updateAuthState, connectionType, config], ); const proceedToNextStep = useCallback(async () => { @@ -150,11 +156,16 @@ const AuthDebugger = ({ latestError: null, }; - const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => { - // Update our temporary state during the process - currentState = { ...currentState, ...updates }; - // But don't call updateAuthState yet - }); + const oauthMachine = new OAuthStateMachine( + serverUrl, + (updates) => { + // Update our temporary state during the process + currentState = { ...currentState, ...updates }; + // But don't call updateAuthState yet + }, + connectionType, + config, + ); // Manually step through each stage of the OAuth flow while (currentState.oauthStep !== "complete") { diff --git a/client/src/lib/__tests__/auth.test.ts b/client/src/lib/__tests__/auth.test.ts index 329b7f027..b1296caa6 100644 --- a/client/src/lib/__tests__/auth.test.ts +++ b/client/src/lib/__tests__/auth.test.ts @@ -1,5 +1,6 @@ import { discoverScopes } from "../auth"; import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; +import { InspectorConfig } from "../configurationTypes"; jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ discoverAuthorizationServerMetadata: jest.fn(), @@ -10,6 +11,39 @@ const mockDiscoverAuth = typeof discoverAuthorizationServerMetadata >; +const mockConfig: InspectorConfig = { + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for MCP requests", + value: 30000, + is_session_item: false, + }, + MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: { + label: "Reset Timeout on Progress", + description: "Reset timeout on progress notifications", + value: true, + is_session_item: false, + }, + MCP_REQUEST_MAX_TOTAL_TIMEOUT: { + label: "Max Total Timeout", + description: "Maximum total timeout", + value: 300000, + is_session_item: false, + }, + MCP_PROXY_FULL_ADDRESS: { + label: "Proxy Address", + description: "Full address of the MCP proxy", + value: "http://localhost:6277", + is_session_item: false, + }, + MCP_PROXY_AUTH_TOKEN: { + label: "Proxy Auth Token", + description: "Authentication token for the proxy", + value: "", + is_session_item: false, + }, +}; + const baseMetadata = { issuer: "https://test.com", authorization_endpoint: "https://test.com/authorize", @@ -129,7 +163,12 @@ describe("discoverScopes", () => { }) => { mockDiscoverAuth.mockResolvedValue(mockResolves); - const result = await discoverScopes(serverUrl, resourceMetadata); + const result = await discoverScopes( + serverUrl, + "direct", + mockConfig, + resourceMetadata, + ); expect(result).toBe(expected); if (expectedCallUrl) { @@ -147,7 +186,12 @@ describe("discoverScopes", () => { mockDiscoverAuth.mockResolvedValue(mockResolves); } - const result = await discoverScopes(serverUrl, resourceMetadata); + const result = await discoverScopes( + serverUrl, + "direct", + mockConfig, + resourceMetadata, + ); expect(result).toBeUndefined(); }, diff --git a/client/src/lib/__tests__/oauth-proxy.test.ts b/client/src/lib/__tests__/oauth-proxy.test.ts new file mode 100644 index 000000000..d64c1dc86 --- /dev/null +++ b/client/src/lib/__tests__/oauth-proxy.test.ts @@ -0,0 +1,340 @@ +/** + * Tests for OAuth Proxy Utilities + */ + +import { + discoverAuthorizationServerMetadataViaProxy, + discoverOAuthProtectedResourceMetadataViaProxy, + registerClientViaProxy, + exchangeAuthorizationViaProxy, +} from "../oauth-proxy"; +import { InspectorConfig } from "../configurationTypes"; + +// Mock the config utils +jest.mock("@/utils/configUtils", () => ({ + getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), + getMCPProxyAuthToken: jest.fn(() => ({ + token: "test-token", + header: "x-mcp-proxy-auth", + })), +})); + +// Mock global fetch +global.fetch = jest.fn(); + +const mockConfig: InspectorConfig = { + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for MCP requests", + value: 30000, + is_session_item: false, + }, + MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: { + label: "Reset Timeout on Progress", + description: "Reset timeout on progress notifications", + value: true, + is_session_item: false, + }, + MCP_REQUEST_MAX_TOTAL_TIMEOUT: { + label: "Max Total Timeout", + description: "Maximum total timeout", + value: 300000, + is_session_item: false, + }, + MCP_PROXY_FULL_ADDRESS: { + label: "Proxy Address", + description: "Full address of the MCP proxy", + value: "http://localhost:6277", + is_session_item: false, + }, + MCP_PROXY_AUTH_TOKEN: { + label: "Proxy Auth Token", + description: "Authentication token for the proxy", + value: "test-token", + is_session_item: false, + }, +}; + +describe("OAuth Proxy Utilities", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("discoverAuthorizationServerMetadataViaProxy", () => { + it("should successfully fetch metadata through proxy", async () => { + const mockMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockMetadata, + }); + + const result = await discoverAuthorizationServerMetadataViaProxy( + new URL("https://auth.example.com"), + mockConfig, + ); + + expect(result).toEqual(mockMetadata); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:6277/oauth/metadata?url=https%3A%2F%2Fauth.example.com%2F.well-known%2Foauth-authorization-server", + { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-mcp-proxy-auth": "Bearer test-token", + }, + }, + ); + }); + + it("should handle network errors", async () => { + (global.fetch as jest.Mock).mockRejectedValueOnce( + new Error("Network error"), + ); + + await expect( + discoverAuthorizationServerMetadataViaProxy( + new URL("https://auth.example.com"), + mockConfig, + ), + ).rejects.toThrow("Network error"); + }); + + it("should handle non-ok responses", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + json: async () => ({ error: "Metadata not found" }), + }); + + await expect( + discoverAuthorizationServerMetadataViaProxy( + new URL("https://auth.example.com"), + mockConfig, + ), + ).rejects.toThrow( + "Failed to discover OAuth metadata: Not found: Metadata not found", + ); + }); + + it("should handle responses without error details", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => { + throw new Error("Invalid JSON"); + }, + }); + + await expect( + discoverAuthorizationServerMetadataViaProxy( + new URL("https://auth.example.com"), + mockConfig, + ), + ).rejects.toThrow( + "Failed to discover OAuth metadata: Server error (500): Internal Server Error", + ); + }); + }); + + describe("discoverOAuthProtectedResourceMetadataViaProxy", () => { + it("should successfully fetch resource metadata through proxy", async () => { + const mockMetadata = { + resource: "https://api.example.com", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["read", "write"], + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockMetadata, + }); + + const result = await discoverOAuthProtectedResourceMetadataViaProxy( + "https://api.example.com", + mockConfig, + ); + + expect(result).toEqual(mockMetadata); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:6277/oauth/resource-metadata?url=https%3A%2F%2Fapi.example.com%2F.well-known%2Foauth-protected-resource", + { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-mcp-proxy-auth": "Bearer test-token", + }, + }, + ); + }); + + it("should handle errors", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + json: async () => ({ error: "Resource metadata not found" }), + }); + + await expect( + discoverOAuthProtectedResourceMetadataViaProxy( + "https://api.example.com", + mockConfig, + ), + ).rejects.toThrow( + "Failed to discover resource metadata: Not found: Resource metadata not found", + ); + }); + }); + + describe("registerClientViaProxy", () => { + it("should successfully register client through proxy", async () => { + const clientMetadata = { + client_name: "Test Client", + redirect_uris: ["http://localhost:6274/oauth/callback"], + grant_types: ["authorization_code"], + }; + + const mockClientInformation = { + client_id: "test-client-id", + client_secret: "test-client-secret", + ...clientMetadata, + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientInformation, + }); + + const result = await registerClientViaProxy( + "https://auth.example.com/register", + clientMetadata, + mockConfig, + ); + + expect(result).toEqual(mockClientInformation); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:6277/oauth/register", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-proxy-auth": "Bearer test-token", + }, + body: JSON.stringify({ + registrationEndpoint: "https://auth.example.com/register", + clientMetadata, + }), + }, + ); + }); + + it("should handle registration errors", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: "Bad Request", + json: async () => ({ error: "Invalid client metadata" }), + }); + + await expect( + registerClientViaProxy( + "https://auth.example.com/register", + { + client_name: "Test", + redirect_uris: ["http://localhost:6274/oauth/callback"], + }, + mockConfig, + ), + ).rejects.toThrow( + "Failed to register client: Bad Request: Invalid client metadata", + ); + }); + }); + + describe("exchangeAuthorizationViaProxy", () => { + it("should successfully exchange authorization code through proxy", async () => { + const params = { + grant_type: "authorization_code", + code: "test-auth-code", + redirect_uri: "http://localhost:6274/oauth/callback", + code_verifier: "test-verifier", + client_id: "test-client-id", + }; + + const mockTokens = { + access_token: "test-access-token", + token_type: "Bearer", + expires_in: 3600, + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockTokens, + }); + + const result = await exchangeAuthorizationViaProxy( + "https://auth.example.com/token", + params, + mockConfig, + ); + + expect(result).toEqual(mockTokens); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:6277/oauth/token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-proxy-auth": "Bearer test-token", + }, + body: JSON.stringify({ + tokenEndpoint: "https://auth.example.com/token", + params, + }), + }, + ); + }); + + it("should handle token exchange errors", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: async () => ({ error: "invalid_grant" }), + }); + + await expect( + exchangeAuthorizationViaProxy( + "https://auth.example.com/token", + { grant_type: "authorization_code", code: "invalid" }, + mockConfig, + ), + ).rejects.toThrow( + "Failed to exchange authorization code: Authentication failed: invalid_grant", + ); + }); + + it("should handle network failures", async () => { + (global.fetch as jest.Mock).mockRejectedValueOnce( + new Error("Connection refused"), + ); + + await expect( + exchangeAuthorizationViaProxy( + "https://auth.example.com/token", + { grant_type: "authorization_code", code: "test" }, + mockConfig, + ), + ).rejects.toThrow("Connection refused"); + }); + }); +}); diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 879936104..e2c982ac0 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -12,21 +12,31 @@ import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/c import { SESSION_KEYS, getServerSpecificKey } from "./constants"; import { generateOAuthState } from "@/utils/oauthUtils"; import { validateRedirectUrl } from "@/utils/urlValidation"; +import { discoverAuthorizationServerMetadataViaProxy } from "./oauth-proxy"; +import { InspectorConfig } from "./configurationTypes"; /** * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes * @param serverUrl - The MCP server URL + * @param connectionType - Whether to use proxy or direct connection + * @param config - Inspector configuration (needed for proxy mode) * @param resourceMetadata - Optional resource metadata containing preferred scopes * @returns Promise resolving to space-separated scope string or undefined */ export const discoverScopes = async ( serverUrl: string, + connectionType: "direct" | "proxy", + config: InspectorConfig, resourceMetadata?: OAuthProtectedResourceMetadata, ): Promise => { try { - const metadata = await discoverAuthorizationServerMetadata( - new URL("/", serverUrl), - ); + const metadata = + connectionType === "proxy" + ? await discoverAuthorizationServerMetadataViaProxy( + new URL(serverUrl), + config, + ) + : await discoverAuthorizationServerMetadata(new URL("/", serverUrl)); // Prefer resource metadata scopes, but fall back to OAuth metadata if empty const resourceScopes = resourceMetadata?.scopes_supported; diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 4907a085b..3e6ce780d 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -1513,6 +1513,8 @@ describe("useConnection", () => { if (expectScopeCall) { expect(mockDiscoverScopes).toHaveBeenCalledWith( defaultProps.sseUrl, + "proxy", + defaultProps.config, undefined, ); } else { @@ -1537,6 +1539,8 @@ describe("useConnection", () => { expect(mockDiscoverScopes).toHaveBeenCalledWith( defaultProps.sseUrl, + "proxy", + defaultProps.config, undefined, ); expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), { diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index e14d1037f..5245341dd 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -64,6 +64,7 @@ import { clearScopeFromSessionStorage, discoverScopes, } from "../auth"; +import { discoverOAuthProtectedResourceMetadataViaProxy } from "../oauth-proxy"; import { getMCPProxyAddress, getMCPTaskTtl, @@ -404,13 +405,26 @@ export function useConnection({ // Only discover resource metadata when we need to discover scopes let resourceMetadata; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - new URL("/", sseUrl), - ); + if (connectionType === "proxy") { + resourceMetadata = + await discoverOAuthProtectedResourceMetadataViaProxy( + sseUrl, + config, + ); + } else { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + new URL("/", sseUrl), + ); + } } catch { // Resource metadata is optional, continue without it } - scope = await discoverScopes(sseUrl, resourceMetadata); + scope = await discoverScopes( + sseUrl, + connectionType, + config, + resourceMetadata, + ); } saveScopeToSessionStorage(sseUrl, scope); diff --git a/client/src/lib/oauth-proxy.ts b/client/src/lib/oauth-proxy.ts new file mode 100644 index 000000000..111b4382b --- /dev/null +++ b/client/src/lib/oauth-proxy.ts @@ -0,0 +1,238 @@ +/** + * OAuth Proxy Utilities + * + * These functions route OAuth requests through the MCP Inspector proxy server + * to avoid CORS issues when connectionType is "proxy". + */ + +import { + OAuthMetadata, + OAuthProtectedResourceMetadata, + OAuthClientInformation, + OAuthTokens, + OAuthClientMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { getMCPProxyAddress, getMCPProxyAuthToken } from "@/utils/configUtils"; +import { InspectorConfig } from "./configurationTypes"; + +/** + * Get proxy headers for authentication + * @param config - Inspector configuration containing proxy authentication settings + * @returns Headers object with Content-Type and optional Bearer token + */ +function getProxyHeaders(config: InspectorConfig): Record { + const { token, header } = getMCPProxyAuthToken(config); + const headers: Record = { + "Content-Type": "application/json", + }; + + if (token) { + headers[header] = `Bearer ${token}`; + } + + return headers; +} + +/** + * Common helper for proxying fetch requests + * @param endpoint - The API endpoint path (e.g., "/oauth/metadata") + * @param method - HTTP method (GET or POST) + * @param config - Inspector configuration containing proxy settings + * @param body - Optional request body object + * @param queryParams - Optional query parameters to append to URL + * @returns Promise resolving to the typed response + * @throws Error if the request fails or returns non-ok status + */ +async function proxyFetch( + endpoint: string, + method: "GET" | "POST", + config: InspectorConfig, + body?: object, + queryParams?: Record, +): Promise { + const proxyAddress = getMCPProxyAddress(config); + const url = new URL(endpoint, proxyAddress); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + } + + let response: Response; + try { + response = await fetch(url.toString(), { + method, + headers: getProxyHeaders(config), + ...(body && { body: JSON.stringify(body) }), + }); + } catch (error) { + // Network errors (connection refused, timeout, etc.) + throw new Error( + `Network error: ${error instanceof Error ? error.message : "Failed to connect to proxy server"}`, + ); + } + + if (!response.ok) { + // Try to get detailed error from response body + const errorData = await response + .json() + .catch(() => ({ error: response.statusText })); + const errorMessage = errorData.error || response.statusText; + const errorDetails = errorData.details ? ` - ${errorData.details}` : ""; + + // Provide specific error messages based on status code + switch (response.status) { + case 400: + throw new Error(`Bad Request: ${errorMessage}${errorDetails}`); + case 401: + throw new Error( + `Authentication failed: ${errorMessage}. Check your proxy authentication token.`, + ); + case 403: + throw new Error( + `Access forbidden: ${errorMessage}. You may not have permission to access this resource.`, + ); + case 404: + throw new Error( + `Not found: ${errorMessage}. The OAuth endpoint may not exist or be misconfigured.`, + ); + case 500: + case 502: + case 503: + case 504: + throw new Error( + `Server error (${response.status}): ${errorMessage}${errorDetails}`, + ); + default: + throw new Error( + `Request failed (${response.status}): ${errorMessage}${errorDetails}`, + ); + } + } + + return await response.json(); +} + +/** + * Discover OAuth Authorization Server Metadata via proxy + * @param authServerUrl - The OAuth authorization server URL + * @param config - Inspector configuration containing proxy settings + * @returns Promise resolving to OAuth metadata + * @throws Error if metadata discovery fails + */ +export async function discoverAuthorizationServerMetadataViaProxy( + authServerUrl: URL, + config: InspectorConfig, +): Promise { + // Construct the well-known URL per RFC 8414 + const pathname = authServerUrl.pathname.endsWith("/") + ? authServerUrl.pathname.slice(0, -1) + : authServerUrl.pathname; + const wellKnownUrl = new URL( + `/.well-known/oauth-authorization-server${pathname}`, + authServerUrl, + ); + + try { + return await proxyFetch( + "/oauth/metadata", + "GET", + config, + undefined, + { url: wellKnownUrl.toString() }, + ); + } catch (error) { + throw new Error( + `Failed to discover OAuth metadata: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Discover OAuth Protected Resource Metadata via proxy + * @param serverUrl - The MCP server URL + * @param config - Inspector configuration containing proxy settings + * @returns Promise resolving to OAuth protected resource metadata + * @throws Error if resource metadata discovery fails + */ +export async function discoverOAuthProtectedResourceMetadataViaProxy( + serverUrl: string, + config: InspectorConfig, +): Promise { + // Construct the well-known URL per RFC 9728 + const url = new URL(serverUrl); + const pathname = url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + const wellKnownUrl = new URL( + `/.well-known/oauth-protected-resource${pathname}`, + url, + ); + + try { + return await proxyFetch( + "/oauth/resource-metadata", + "GET", + config, + undefined, + { url: wellKnownUrl.toString() }, + ); + } catch (error) { + throw new Error( + `Failed to discover resource metadata: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Register OAuth client via proxy (Dynamic Client Registration) + * @param registrationEndpoint - The OAuth client registration endpoint URL + * @param clientMetadata - OAuth client metadata for registration + * @param config - Inspector configuration containing proxy settings + * @returns Promise resolving to OAuth client information + * @throws Error if client registration fails + */ +export async function registerClientViaProxy( + registrationEndpoint: string, + clientMetadata: OAuthClientMetadata, + config: InspectorConfig, +): Promise { + try { + return await proxyFetch( + "/oauth/register", + "POST", + config, + { registrationEndpoint, clientMetadata }, + ); + } catch (error) { + throw new Error( + `Failed to register client: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Exchange authorization code for tokens via proxy + * @param tokenEndpoint - The OAuth token endpoint URL + * @param params - Token exchange parameters (code, client_id, etc.) + * @param config - Inspector configuration containing proxy settings + * @returns Promise resolving to OAuth tokens + * @throws Error if token exchange fails + */ +export async function exchangeAuthorizationViaProxy( + tokenEndpoint: string, + params: Record, + config: InspectorConfig, +): Promise { + try { + return await proxyFetch("/oauth/token", "POST", config, { + tokenEndpoint, + params, + }); + } catch (error) { + throw new Error( + `Failed to exchange authorization code: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 8dc9da8f9..73ce35a25 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -13,12 +13,21 @@ import { OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { generateOAuthState } from "@/utils/oauthUtils"; +import { InspectorConfig } from "./configurationTypes"; +import { + discoverAuthorizationServerMetadataViaProxy, + discoverOAuthProtectedResourceMetadataViaProxy, + registerClientViaProxy, + exchangeAuthorizationViaProxy, +} from "./oauth-proxy"; export interface StateMachineContext { state: AuthDebuggerState; serverUrl: string; provider: DebugInspectorOAuthClientProvider; updateState: (updates: Partial) => void; + connectionType: "direct" | "proxy"; + config: InspectorConfig; } export interface StateTransition { @@ -36,9 +45,18 @@ export const oauthTransitions: Record = { let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - context.serverUrl, - ); + // Use proxy if connectionType is "proxy" + if (context.connectionType === "proxy") { + resourceMetadata = + await discoverOAuthProtectedResourceMetadataViaProxy( + context.serverUrl, + context.config, + ); + } else { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + context.serverUrl, + ); + } if (resourceMetadata?.authorization_servers?.length) { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } @@ -57,7 +75,15 @@ export const oauthTransitions: Record = { resourceMetadata ?? undefined, ); - const metadata = await discoverAuthorizationServerMetadata(authServerUrl); + // Use proxy if connectionType is "proxy" + const metadata = + context.connectionType === "proxy" + ? await discoverAuthorizationServerMetadataViaProxy( + authServerUrl, + context.config, + ) + : await discoverAuthorizationServerMetadata(authServerUrl); + if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } @@ -86,8 +112,8 @@ export const oauthTransitions: Record = { const scopesSupported = context.state.resourceMetadata?.scopes_supported || metadata.scopes_supported; - // Add all supported scopes to client registration - if (scopesSupported) { + // Add all supported scopes to client registration (only if non-empty) + if (scopesSupported && scopesSupported.length > 0) { clientMetadata.scope = scopesSupported.join(" "); } } @@ -95,10 +121,24 @@ export const oauthTransitions: Record = { // Try Static client first, with DCR as fallback let fullInformation = await context.provider.clientInformation(); if (!fullInformation) { - fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - }); + // Use proxy if connectionType is "proxy" + if (context.connectionType === "proxy") { + if (!metadata.registration_endpoint) { + throw new Error( + "No registration endpoint available for dynamic client registration", + ); + } + fullInformation = await registerClientViaProxy( + metadata.registration_endpoint, + clientMetadata, + context.config, + ); + } else { + fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); + } context.provider.saveClientInformation(fullInformation); } @@ -121,6 +161,8 @@ export const oauthTransitions: Record = { if (!scope || scope.trim() === "") { scope = await discoverScopes( context.serverUrl, + context.connectionType, + context.config, context.state.resourceMetadata ?? undefined, ); } @@ -178,18 +220,50 @@ export const oauthTransitions: Record = { const metadata = context.provider.getServerMetadata()!; const clientInformation = (await context.provider.clientInformation())!; - const tokens = await exchangeAuthorization(context.serverUrl, { - metadata, - clientInformation, - authorizationCode: context.state.authorizationCode, - codeVerifier, - redirectUri: context.provider.redirectUrl, - resource: context.state.resource - ? context.state.resource instanceof URL - ? context.state.resource - : new URL(context.state.resource) - : undefined, - }); + let tokens; + + // Use proxy if connectionType is "proxy" + if (context.connectionType === "proxy") { + // Build the token request parameters + const params: Record = { + grant_type: "authorization_code", + code: context.state.authorizationCode, + redirect_uri: context.provider.redirectUrl, + code_verifier: codeVerifier, + client_id: clientInformation.client_id, + }; + + if (clientInformation.client_secret) { + params.client_secret = clientInformation.client_secret; + } + + if (context.state.resource) { + const resourceUrl = + context.state.resource instanceof URL + ? context.state.resource.toString() + : context.state.resource; + params.resource = resourceUrl; + } + + tokens = await exchangeAuthorizationViaProxy( + metadata.token_endpoint, + params, + context.config, + ); + } else { + tokens = await exchangeAuthorization(context.serverUrl, { + metadata, + clientInformation, + authorizationCode: context.state.authorizationCode, + codeVerifier, + redirectUri: context.provider.redirectUrl, + resource: context.state.resource + ? context.state.resource instanceof URL + ? context.state.resource + : new URL(context.state.resource) + : undefined, + }); + } context.provider.saveTokens(tokens); context.updateState({ @@ -211,6 +285,8 @@ export class OAuthStateMachine { constructor( private serverUrl: string, private updateState: (updates: Partial) => void, + private connectionType: "direct" | "proxy", + private config: InspectorConfig, ) {} async executeStep(state: AuthDebuggerState): Promise { @@ -220,6 +296,8 @@ export class OAuthStateMachine { serverUrl: this.serverUrl, provider, updateState: this.updateState, + connectionType: this.connectionType, + config: this.config, }; const transition = oauthTransitions[state.oauthStep]; diff --git a/package-lock.json b/package-lock.json index f709c6289..bf2deb535 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "jest-fixed-jsdom": "^0.0.9", "lint-staged": "^16.1.5", "playwright": "^1.56.1", - "prettier": "^3.7.1", + "prettier": "^3.7.4", "rimraf": "^6.0.1", "typescript": "^5.4.2" }, diff --git a/package.json b/package.json index 948f361e6..6d218f23a 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "jest-fixed-jsdom": "^0.0.9", "lint-staged": "^16.1.5", "playwright": "^1.56.1", - "prettier": "^3.7.1", + "prettier": "^3.7.4", "rimraf": "^6.0.1", "typescript": "^5.4.2" }, diff --git a/server/src/index.ts b/server/src/index.ts index 4d1fffa29..7da5442ce 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -174,6 +174,7 @@ const updateHeadersInPlace = ( const app = express(); app.use(cors()); +app.use(express.json()); // Enable JSON body parsing globally app.use((req, res, next) => { res.header("Access-Control-Expose-Headers", "mcp-session-id"); next(); @@ -457,7 +458,7 @@ app.get( res.status(404).end("Session not found"); return; } else { - await transport.handleRequest(req, res); + await transport.handleRequest(req, res, req.body); } } catch (error) { console.error("Error in /mcp route:", error); @@ -473,6 +474,16 @@ app.post( async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; + // Diagnostic logging for debugging "Parse error: Invalid JSON" issues + // See: https://github.com/modelcontextprotocol/inspector/pull/996 + console.log( + `[/mcp POST] sessionId=${sessionId ?? "(none)"} ` + + `content-type=${req.headers["content-type"] ?? "(missing)"} ` + + `body-type=${typeof req.body} ` + + `body-defined=${req.body !== undefined} ` + + `body=${typeof req.body === "object" ? JSON.stringify(req.body) : String(req.body)}`, + ); + if (sessionId) { console.log(`Received POST message for sessionId ${sessionId}`); const headerHolder = sessionHeaderHolders.get(sessionId); @@ -493,6 +504,7 @@ app.post( await (transport as StreamableHTTPServerTransport).handleRequest( req, res, + req.body, ); } } catch (error) { @@ -816,6 +828,194 @@ app.get( }, ); +// OAuth Proxy Endpoints - for routing OAuth requests through the proxy to avoid CORS issues + +/** + * Proxy endpoint for OAuth Authorization Server Metadata Discovery + * GET /oauth/metadata?url= + */ +app.get( + "/oauth/metadata", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const metadataUrl = req.query.url as string; + if (!metadataUrl) { + res.status(400).json({ error: "url query parameter is required" }); + return; + } + + console.log(`OAuth metadata discovery: ${metadataUrl}`); + + const response = await fetch(metadataUrl); + + if (!response.ok) { + const errorText = await response.text(); + res.status(response.status).json({ + error: `Failed to fetch OAuth metadata: ${response.statusText}`, + details: errorText, + }); + return; + } + + const metadata = await response.json(); + res.json(metadata); + } catch (error) { + console.error("Error in /oauth/metadata route:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, +); + +/** + * Proxy endpoint for OAuth Protected Resource Metadata Discovery + * GET /oauth/resource-metadata?url= + */ +app.get( + "/oauth/resource-metadata", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const metadataUrl = req.query.url as string; + if (!metadataUrl) { + res.status(400).json({ error: "url query parameter is required" }); + return; + } + + console.log(`OAuth resource metadata discovery: ${metadataUrl}`); + + const response = await fetch(metadataUrl); + + if (!response.ok) { + const errorText = await response.text(); + res.status(response.status).json({ + error: `Failed to fetch resource metadata: ${response.statusText}`, + details: errorText, + }); + return; + } + + const metadata = await response.json(); + res.json(metadata); + } catch (error) { + console.error("Error in /oauth/resource-metadata route:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, +); + +/** + * Proxy endpoint for OAuth Dynamic Client Registration (DCR) + * POST /oauth/register + * Body: { registrationEndpoint: string, clientMetadata: object } + */ +app.post( + "/oauth/register", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const { registrationEndpoint, clientMetadata } = req.body; + + if (!registrationEndpoint || !clientMetadata) { + res.status(400).json({ + error: + "registrationEndpoint and clientMetadata are required in request body", + }); + return; + } + + console.log(`OAuth client registration at: ${registrationEndpoint}`); + + const response = await fetch(registrationEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(clientMetadata), + }); + + if (!response.ok) { + const errorText = await response.text(); + res.status(response.status).json({ + error: `Failed to register client: ${response.statusText}`, + details: errorText, + }); + return; + } + + const clientInformation = await response.json(); + res.json(clientInformation); + } catch (error) { + console.error("Error in /oauth/register route:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, +); + +/** + * Proxy endpoint for OAuth Token Exchange + * POST /oauth/token + * Body: { tokenEndpoint: string, params: object } + */ +app.post( + "/oauth/token", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const { tokenEndpoint, params } = req.body; + + if (!tokenEndpoint || !params) { + res.status(400).json({ + error: "tokenEndpoint and params are required in request body", + }); + return; + } + + console.log(`OAuth token exchange at: ${tokenEndpoint}`); + + // Convert params object to URLSearchParams for form encoding + const formBody = new URLSearchParams(params as Record); + + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: formBody.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + res.status(response.status).json({ + error: `Failed to exchange token: ${response.statusText}`, + details: errorText, + }); + return; + } + + const tokens = await response.json(); + res.json(tokens); + } catch (error) { + console.error("Error in /oauth/token route:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, +); + const PORT = parseInt( process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT, 10,