diff --git a/client/package.json b/client/package.json index f29f303..eb96502 100644 --- a/client/package.json +++ b/client/package.json @@ -30,6 +30,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.447.0", + "pkce-challenge": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-toastify": "^10.0.6", diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts new file mode 100644 index 0000000..ce49d05 --- /dev/null +++ b/client/src/lib/auth.ts @@ -0,0 +1,52 @@ +import pkceChallenge from 'pkce-challenge'; + +export interface OAuthMetadata { + authorization_endpoint: string; + token_endpoint: string; +} + +export async function discoverOAuthMetadata(serverUrl: string): Promise { + try { + const url = new URL('/.well-known/oauth-authorization-server', serverUrl); + const response = await fetch(url.toString()); + + if (response.ok) { + const metadata = await response.json(); + return { + authorization_endpoint: metadata.authorization_endpoint, + token_endpoint: metadata.token_endpoint + }; + } + } catch (error) { + console.warn('OAuth metadata discovery failed:', error); + } + + // Fall back to default endpoints + const baseUrl = new URL(serverUrl); + return { + authorization_endpoint: new URL('/authorize', baseUrl).toString(), + token_endpoint: new URL('/token', baseUrl).toString() + }; +} + +export async function startOAuthFlow(serverUrl: string): Promise { + // Generate PKCE challenge + const challenge = await pkceChallenge(); + const codeVerifier = challenge.code_verifier; + const codeChallenge = challenge.code_challenge; + + // Store code verifier for later use + sessionStorage.setItem('mcp_code_verifier', codeVerifier); + + // Discover OAuth endpoints + const metadata = await discoverOAuthMetadata(serverUrl); + + // Build authorization URL + const authUrl = new URL(metadata.authorization_endpoint); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('redirect_uri', window.location.origin + '/oauth/callback'); + + return authUrl.toString(); +} diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index ef937d1..78d3a7d 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -1,5 +1,5 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { SSEClientTransport, SseError } from "@modelcontextprotocol/sdk/client/sse.js"; import { ClientNotification, ClientRequest, @@ -12,8 +12,9 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { useState } from "react"; import { toast } from "react-toastify"; -import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { z } from "zod"; +import { startOAuthFlow } from "../auth"; +import { Notification, StdErrNotificationSchema } from "../notificationTypes"; const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000; @@ -160,7 +161,17 @@ export function useConnection({ ); } - await client.connect(clientTransport); + try { + await client.connect(clientTransport); + } catch (error) { + console.error("Failed to connect to MCP server:", error); + if (error instanceof SseError && error.code === 401) { + const redirectUrl = await startOAuthFlow(sseUrl); + window.location.href = redirectUrl; + } + + throw error; + } const capabilities = client.getServerCapabilities(); setServerCapabilities(capabilities ?? null); diff --git a/package-lock.json b/package-lock.json index 43f00a5..a29a1ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.447.0", + "pkce-challenge": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-toastify": "^10.0.6", @@ -4946,6 +4947,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",