diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d01f7175b..35ace3117 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: Check formatting - run: npx prettier --check . + run: npx prettier@3.7.4 --check . - uses: actions/setup-node@v4 with: diff --git a/client/src/App.tsx b/client/src/App.tsx index a9f99686d..633959aff 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -499,9 +499,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" && @@ -917,6 +922,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__/oauth-proxy.test.ts b/client/src/lib/__tests__/oauth-proxy.test.ts new file mode 100644 index 000000000..684807141 --- /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?authServerUrl=https%3A%2F%2Fauth.example.com%2F", + { + 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?serverUrl=https%3A%2F%2Fapi.example.com", + { + 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 797501127..a1ea696c6 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -153,15 +153,21 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } get clientMetadata(): OAuthClientMetadata { - return { + const metadata: OAuthClientMetadata = { redirect_uris: this.redirect_uris, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], client_name: "MCP Inspector", client_uri: "https://github.com/modelcontextprotocol/inspector", - scope: this.scope ?? "", }; + + // Only include scope if it has a value (RFC 7591 - scope is optional) + if (this.scope) { + metadata.scope = this.scope; + } + + return metadata; } state(): string | Promise { diff --git a/client/src/lib/oauth-proxy.ts b/client/src/lib/oauth-proxy.ts new file mode 100644 index 000000000..e5c0da2c1 --- /dev/null +++ b/client/src/lib/oauth-proxy.ts @@ -0,0 +1,219 @@ +/** + * 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 { + try { + return await proxyFetch( + "/oauth/metadata", + "GET", + config, + undefined, + { authServerUrl: authServerUrl.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 { + try { + return await proxyFetch( + "/oauth/resource-metadata", + "GET", + config, + undefined, + { serverUrl }, + ); + } 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..c16eed03a 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); } @@ -178,18 +218,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 +283,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 +294,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 758c0ea9e..e97a19880 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 07f84843c..4c53d4b1f 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 388fdaca7..d5253e960 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -165,6 +165,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(); @@ -448,7 +449,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); @@ -484,6 +485,7 @@ app.post( await (transport as StreamableHTTPServerTransport).handleRequest( req, res, + req.body, ); } } catch (error) { @@ -787,6 +789,212 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { } }); +// 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?authServerUrl= + */ +app.get( + "/oauth/metadata", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const authServerUrl = req.query.authServerUrl as string; + if (!authServerUrl) { + res + .status(400) + .json({ error: "authServerUrl query parameter is required" }); + return; + } + + console.log(`OAuth metadata discovery for: ${authServerUrl}`); + + // Append the well-known path to the authServerUrl + // Remove trailing slash if present, then append the well-known path + const baseUrl = authServerUrl.endsWith("/") + ? authServerUrl.slice(0, -1) + : authServerUrl; + const metadataUrl = `${baseUrl}/.well-known/oauth-authorization-server`; + console.log(`Fetching metadata from: ${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?serverUrl= + */ +app.get( + "/oauth/resource-metadata", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const serverUrl = req.query.serverUrl as string; + if (!serverUrl) { + res + .status(400) + .json({ error: "serverUrl query parameter is required" }); + return; + } + + console.log(`OAuth resource metadata discovery for: ${serverUrl}`); + + // For resource metadata, use the origin (protocol + host + port) only + // This is per RFC 8414 - resource metadata is at the origin root + const url = new URL(serverUrl); + const metadataUrl = `${url.origin}/.well-known/oauth-protected-resource`; + console.log(`Fetching resource metadata from: ${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,