From e6a22d452e81953886d71282163565b5396ab383 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Sat, 22 Mar 2025 22:29:16 +1100 Subject: [PATCH] got something building and activating in a react app, at least! --- package.json | 1 + pnpm-lock.yaml | 15 + src/react/index.ts | 662 ++++++++++++++++++++++++++++++++++++++++++++- tsconfig.json | 5 +- tsup.config.ts | 2 +- 5 files changed, 670 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 4ea39d4..479bedd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "devDependencies": { "@types/express": "^5.0.0", "@types/node": "^22.13.10", + "@types/react": "^19.0.12", "prettier": "^3.5.3", "react": "^19.0.0", "tsup": "^8.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6821d5f..ca214d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@types/node': specifier: ^22.13.10 version: 22.13.10 + '@types/react': + specifier: ^19.0.12 + version: 19.0.12 prettier: specifier: ^3.5.3 version: 3.5.3 @@ -347,6 +350,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.0.12': + resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==} + '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -473,6 +479,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1340,6 +1349,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react@19.0.12': + dependencies: + csstype: 3.1.3 + '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -1476,6 +1489,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.1.3: {} + debug@2.6.9: dependencies: ms: 2.0.0 diff --git a/src/react/index.ts b/src/react/index.ts index 39c3139..d3408b2 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,11 +1,33 @@ -import { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { useCallback, useState } from "react"; +import { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { useCallback, useEffect, useState, useRef } from "react"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; export type UseMcpOptions = { /** The /sse URL of your remote MCP server */ url: string, - - // more options here as I think of them + /** OAuth client name for registration */ + clientName?: string, + /** OAuth client URI for registration */ + clientUri?: string, + /** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */ + callbackUrl?: string, + /** Storage key prefix for OAuth data (defaults to "mcp_auth") */ + storageKeyPrefix?: string, + /** Custom configuration for the MCP client */ + clientConfig?: { + name?: string, + version?: string, + }, + /** Whether to enable debug logging */ + debug?: boolean, + /** Auto retry connection if it fails, with delay in ms (default: false) */ + autoRetry?: boolean | number, + /** Auto reconnect if connection is lost, with delay in ms (default: 3000) */ + autoReconnect?: boolean | number, } export type UseMcpResult = { @@ -24,21 +46,515 @@ export type UseMcpResult = { error?: string, /** All internal log messages */ log: {level: 'debug' | 'info' | 'warn' | 'error', message: string}[], + /** Call a tool on the MCP server */ + callTool: (name: string, args?: Record) => Promise, + /** Manually retry connection if it's in a failed state */ + retry: () => void, + /** Manually disconnect from the MCP server */ + disconnect: () => void, +} - // more as i think of them +/** + * Browser-compatible OAuth client provider for MCP + */ +class BrowserOAuthClientProvider { + private storageKeyPrefix: string; + private serverUrlHash: string; + private clientName: string; + private clientUri: string; + private callbackUrl: string; + + constructor( + readonly serverUrl: string, + options: { + storageKeyPrefix?: string; + clientName?: string; + clientUri?: string; + callbackUrl?: string; + } = {} + ) { + this.storageKeyPrefix = options.storageKeyPrefix || "mcp_auth"; + this.serverUrlHash = this.hashString(serverUrl); + this.clientName = options.clientName || "MCP Browser Client"; + this.clientUri = options.clientUri || window.location.origin; + this.callbackUrl = options.callbackUrl || new URL("/oauth/callback", window.location.origin).toString(); + } + + get redirectUrl(): string { + return this.callbackUrl; + } + + get clientMetadata() { + return { + redirect_uris: [this.redirectUrl], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: this.clientName, + client_uri: this.clientUri, + }; + } + + private hashString(str: string): string { + // Simple hash function for browser environments + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16); + } + + private getKey(key: string): string { + return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}`; + } + + async clientInformation(): Promise { + const key = this.getKey("client_info"); + const data = localStorage.getItem(key); + if (!data) return undefined; + + try { + return JSON.parse(data) as OAuthClientInformation; + } catch (e) { + return undefined; + } + } + + async saveClientInformation(clientInformation: OAuthClientInformation): Promise { + const key = this.getKey("client_info"); + localStorage.setItem(key, JSON.stringify(clientInformation)); + } + + async tokens(): Promise { + const key = this.getKey("tokens"); + const data = localStorage.getItem(key); + if (!data) return undefined; + + try { + return JSON.parse(data) as OAuthTokens; + } catch (e) { + return undefined; + } + } + + async saveTokens(tokens: OAuthTokens): Promise { + const key = this.getKey("tokens"); + localStorage.setItem(key, JSON.stringify(tokens)); + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + // Store the auth state for the popup flow + const stateKey = this.getKey("auth_state"); + const state = Math.random().toString(36).substring(2); + localStorage.setItem(stateKey, state); + authorizationUrl.searchParams.set("state", state); + + // Open the authorization URL in a popup window + const popup = window.open(authorizationUrl.toString(), "mcp_auth", "width=600,height=700"); + + if (!popup || popup.closed) { + console.warn("Popup blocked. Redirecting in the same window..."); + window.location.href = authorizationUrl.toString(); + } + } + + async saveCodeVerifier(codeVerifier: string): Promise { + const key = this.getKey("code_verifier"); + localStorage.setItem(key, codeVerifier); + } + + async codeVerifier(): Promise { + const key = this.getKey("code_verifier"); + const verifier = localStorage.getItem(key); + if (!verifier) { + throw new Error("No code verifier found in storage"); + } + return verifier; + } } /** * useMcp is a React hook that connects to a remote MCP server, negotiates auth * (including opening a popup window or new tab to complete the OAuth flow), * and enables passing a list of tools (once loaded) to ai-sdk (using `useChat`). - * - * The authorization flow */ -export function useMcp( - options: UseMcpOptions -):UseMcpResult { - // TODO: implement hook +export function useMcp(options: UseMcpOptions): UseMcpResult { + const [state, setState] = useState('discovering'); + const [tools, setTools] = useState([]); + const [error, setError] = useState(undefined); + const [log, setLog] = useState([]); + + const clientRef = useRef(null); + const transportRef = useRef(null); + const authProviderRef = useRef(null); + const metadataRef = useRef(undefined); + const connectingRef = useRef(false); + const isInitialMount = useRef(true); + + // Set up default options + const { + url, + clientName = "MCP React Client", + clientUri = window.location.origin, + callbackUrl = new URL("/oauth/callback", window.location.origin).toString(), + storageKeyPrefix = "mcp_auth", + clientConfig = { + name: "mcp-react-client", + version: "0.1.0", + }, + debug = false, + autoRetry = false, + autoReconnect = 3000, + } = options; + + // Add to log + const addLog = useCallback((level: 'debug' | 'info' | 'warn' | 'error', message: string) => { + if (level === 'debug' && !debug) return; + setLog(prevLog => [...prevLog, { level, message }]); + }, [debug]); + + // Call a tool on the MCP server + const callTool = useCallback(async (name: string, args?: Record) => { + if (!clientRef.current || state !== 'ready') { + throw new Error("MCP client not ready"); + } + + try { + const result = await clientRef.current.request( + { + method: "tools/call", + params: { name, arguments: args }, + } + ); + return result; + } catch (err) { + addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } + }, [state, addLog]); + + // Disconnect from the MCP server + const disconnect = useCallback(async () => { + if (clientRef.current) { + try { + await clientRef.current.close(); + } catch (err) { + addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`); + } + clientRef.current = null; + } + + if (transportRef.current) { + try { + await transportRef.current.close(); + } catch (err) { + addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`); + } + transportRef.current = null; + } + + connectingRef.current = false; + setState('discovering'); + setTools([]); + setError(undefined); + }, [addLog]); + let handleAuthentication: () => Promise; + + // Initialize connection to MCP server + const connect = useCallback(async () => { + // Prevent multiple simultaneous connection attempts + if (connectingRef.current) return; + connectingRef.current = true; + + try { + setState('discovering'); + setError(undefined); + + // Create auth provider if not already created + if (!authProviderRef.current) { + authProviderRef.current = new BrowserOAuthClientProvider(url, { + storageKeyPrefix, + clientName, + clientUri, + callbackUrl, + }); + } + + // Discover OAuth metadata if not already discovered + if (!metadataRef.current) { + addLog('info', 'Discovering OAuth metadata...'); + metadataRef.current = await discoverOAuthMetadata(url); + addLog('debug', `OAuth metadata: ${metadataRef.current ? 'Found' : 'Not available'}`); + } + + // Create MCP client + clientRef.current = new Client( + { + name: clientConfig.name || "mcp-react-client", + version: clientConfig.version || "0.1.0", + }, + { + capabilities: { + sampling: {}, + }, + } + ); + + // Set up auth flow - check if we have tokens + const tokens = await authProviderRef.current.tokens(); + + // Create SSE transport + setState('connecting'); + addLog('info', 'Creating transport...'); + + const serverUrl = new URL(url); + transportRef.current = new SSEClientTransport(serverUrl, { + authProvider: authProviderRef.current + }); + + // Set up transport handlers + transportRef.current.onmessage = (message: JSONRPCMessage) => { + addLog('debug', `Received message: ${message.method || message.id}`); + }; + + transportRef.current.onerror = (err: Error) => { + addLog('error', `Transport error: ${err.message}`); + + if (err.message.includes('Unauthorized')) { + setState('authenticating'); + handleAuthentication().catch(authErr => { + addLog('error', `Authentication error: ${authErr.message}`); + setState('failed'); + setError(`Authentication failed: ${authErr.message}`); + connectingRef.current = false; + }); + } else { + setState('failed'); + setError(`Connection error: ${err.message}`); + connectingRef.current = false; + } + }; + + transportRef.current.onclose = () => { + addLog('info', 'Connection closed'); + // If we were previously connected, try to reconnect + if (state === 'ready' && autoReconnect) { + const delay = typeof autoReconnect === 'number' ? autoReconnect : 3000; + addLog('info', `Will reconnect in ${delay}ms...`); + setTimeout(() => { + disconnect().then(() => connect()); + }, delay); + } + }; + + // Connect transport + try { + addLog('info', 'Starting transport...'); + await transportRef.current.start(); + } catch (err) { + addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`); + + if (err instanceof Error && err.message.includes('Unauthorized')) { + setState('authenticating'); + // Start authentication process + await handleAuthentication(); + // After successful auth, retry connection + return connect(); + } else { + setState('failed'); + setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`); + connectingRef.current = false; + return; + } + } + + // Connect client + try { + addLog('info', 'Connecting client...'); + setState('loading'); + await clientRef.current.connect(transportRef.current); + addLog('info', 'Client connected'); + + // Load tools + try { + addLog('info', 'Loading tools...'); + const toolsResponse = await clientRef.current.request( + { method: "tools/list" }, + ListToolsResultSchema + ); + setTools(toolsResponse.tools); + addLog('info', `Loaded ${toolsResponse.tools.length} tools`); + + // Connection completed successfully + setState('ready'); + connectingRef.current = false; + } catch (toolErr) { + addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`); + // We're still connected, just couldn't load tools + setState('ready'); + connectingRef.current = false; + } + } catch (connectErr) { + addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); + setState('failed'); + setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); + connectingRef.current = false; + } + } catch (err) { + addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`); + setState('failed'); + setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`); + connectingRef.current = false; + } + }, [url, clientName, clientUri, callbackUrl, storageKeyPrefix, clientConfig, debug, autoReconnect, addLog, handleAuthentication, disconnect]); + + // Handle authentication flow + handleAuthentication = useCallback(async () => { + if (!authProviderRef.current || !metadataRef.current) { + throw new Error("Auth provider or metadata not available"); + } + + addLog('info', 'Starting authentication flow...'); + + // Check if we have client info + let clientInfo = await authProviderRef.current.clientInformation(); + + if (!clientInfo) { + // Register client dynamically + addLog('info', 'No client information found, registering...'); + // Note: In a complete implementation, you'd register the client here + // This would be done server-side in a real application + throw new Error("Dynamic client registration not implemented in this example"); + } + + // Start authorization flow + addLog('info', 'Starting authorization...'); + const {authorizationUrl, codeVerifier} = await startAuthorization(url, { + metadata: metadataRef.current, + clientInformation: clientInfo, + redirectUrl: authProviderRef.current.redirectUrl + }); + + // Save code verifier + await authProviderRef.current.saveCodeVerifier(codeVerifier); + + // Set up listener for post-auth message + const authPromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + window.removeEventListener('message', messageHandler); + reject(new Error("Authentication timeout after 5 minutes")); + }, 5 * 60 * 1000); + + const messageHandler = (event: MessageEvent) => { + // Verify origin for security + if (event.origin !== window.location.origin) return; + + if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { + window.removeEventListener('message', messageHandler); + clearTimeout(timeoutId); + resolve(event.data.code); + } + }; + + window.addEventListener('message', messageHandler); + }); + + // Redirect to authorization + await authProviderRef.current.redirectToAuthorization(authorizationUrl); + + // Wait for auth to complete + addLog('info', 'Waiting for authorization...'); + const code = await authPromise; + addLog('info', 'Authorization code received'); + + return code; + }, [url, addLog]); + + // Handle auth completion - this is called when we receive a message from the popup + const handleAuthCompletion = useCallback(async (code: string) => { + if (!authProviderRef.current || !transportRef.current || !metadataRef.current) { + throw new Error("Authentication context not available"); + } + + try { + addLog('info', 'Finishing authorization...'); + await transportRef.current.finishAuth(code); + addLog('info', 'Authorization completed'); + + // Reconnect with the new auth token + await disconnect(); + connect(); + } catch (err) { + addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`); + setState('failed'); + setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, [addLog, disconnect, connect]); + + // Retry connection + const retry = useCallback(() => { + if (state === 'failed') { + disconnect().then(() => connect()); + } + }, [state, disconnect, connect]); + + // Set up message listener for auth callback + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + // Verify origin for security + if (event.origin !== window.location.origin) return; + + if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { + handleAuthCompletion(event.data.code).catch(err => { + addLog('error', `Auth callback error: ${err.message}`); + }); + } + }; + + window.addEventListener('message', messageHandler); + return () => { + window.removeEventListener('message', messageHandler); + }; + }, [handleAuthCompletion, addLog]); + + // Initial connection and auto-retry + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + connect(); + } else if (state === 'failed' && autoRetry) { + const delay = typeof autoRetry === 'number' ? autoRetry : 5000; + const timeoutId = setTimeout(() => { + addLog('info', 'Auto-retrying connection...'); + disconnect().then(() => connect()); + }, delay); + + return () => { + clearTimeout(timeoutId); + }; + } + }, [state, autoRetry, connect, disconnect, addLog]); + + // Clean up on unmount + useEffect(() => { + return () => { + if (clientRef.current || transportRef.current) { + disconnect(); + } + }; + }, [disconnect]); + + return { + state, + tools, + error, + log, + callTool, + retry, + disconnect + }; } /** @@ -51,5 +567,127 @@ export function useMcp( * window to inform any running `useMcp` hooks that the auth flow is complete. */ export async function onMcpAuthorization(query: Record) { - // TODO: implement + try { + // Extract the authorization code and state + const code = query.code; + const state = query.state; + + if (!code) { + throw new Error("No authorization code received"); + } + + if (!state) { + throw new Error("No state parameter received"); + } + + // Find the matching auth state in localStorage + const storageKeys = Object.keys(localStorage).filter(key => + key.includes('_auth_state') && localStorage.getItem(key) === state + ); + + if (storageKeys.length === 0) { + throw new Error("No matching auth state found in storage"); + } + + const storageKey = storageKeys[0]; + const keyParts = storageKey.split('_'); + const serverUrlHash = keyParts[1]; + const storageKeyPrefix = keyParts[0]; + + // Find all related auth data with the same prefix and server hash + const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`; + const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`; + + const clientInfoStr = localStorage.getItem(clientInfoKey); + const codeVerifier = localStorage.getItem(codeVerifierKey); + + if (!clientInfoStr) { + throw new Error("No client information found in storage"); + } + + if (!codeVerifier) { + throw new Error("No code verifier found in storage"); + } + + // Parse client info + const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation; + + // Find the server URL from other keys in localStorage + const serverUrlKeys = Object.keys(localStorage).filter(key => + key.startsWith(`${storageKeyPrefix}_server_`) && key.includes(serverUrlHash) + ); + + let serverUrl: string; + if (serverUrlKeys.length > 0) { + serverUrl = localStorage.getItem(serverUrlKeys[0]) || ''; + } else { + // If we can't find the server URL, try to construct it from the current URL + // This is a fallback and may not always work + const currentUrl = new URL(window.location.href); + serverUrl = `${currentUrl.protocol}//${currentUrl.host}`; + } + + if (!serverUrl) { + throw new Error("Could not determine server URL"); + } + + // Exchange the code for tokens + const metadata = await discoverOAuthMetadata(serverUrl); + + const tokens = await exchangeAuthorization(serverUrl, { + metadata, + clientInformation: clientInfo, + authorizationCode: code, + codeVerifier, + }); + + // Save the tokens + const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`; + localStorage.setItem(tokensKey, JSON.stringify(tokens)); + + // Post message back to the parent window + if (window.opener && !window.opener.closed) { + window.opener.postMessage({ + type: 'mcp_auth_callback', + code + }, window.location.origin); + // Close the popup + window.close(); + } else { + // If no parent window, we're in a redirect flow + // Redirect back to the main page + window.location.href = '/'; + } + + return { success: true }; + } catch (error) { + console.error('Error in MCP authorization:', error); + + // Create a readable error message for display + const errorMessage = error instanceof Error ? error.message : String(error); + + // If the popup is still open, show the error + const errorHtml = ` + + + Authentication Error + + + +

Authentication Error

+
+

${errorMessage}

+
+

You can close this window and try again.

+ + + `; + + document.body.innerHTML = errorHtml; + + return { success: false, error: errorMessage }; + } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e66a602..b562002 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,9 @@ "rootDir": "./src", "strict": true, "esModuleInterop": true, - "lib": ["ES2022"], - "types": ["node"], + + "lib": ["ES2022", "DOM"], + "types": ["node", "react"], "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, diff --git a/tsup.config.ts b/tsup.config.ts index 68da9d3..a203cbb 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,5 +6,5 @@ export default defineConfig({ dts: true, clean: true, outDir: 'dist', - // external: ['typescript'], + external: ['react'], })