diff --git a/package.json b/package.json index d09e160..2093274 100644 --- a/package.json +++ b/package.json @@ -32,5 +32,21 @@ "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2" + }, + "tsup": { + "entry": [ + "src/cli/client.ts", + "src/cli/proxy.ts", + "src/react/index.ts" + ], + "format": [ + "esm" + ], + "dts": true, + "clean": true, + "outDir": "dist", + "external": [ + "react" + ] } } diff --git a/src/react/index.ts b/src/react/index.ts index 1dc3d25..9538c8f 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -5,6 +5,12 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { discoverOAuthMetadata, exchangeAuthorization, startAuthorization } from '@modelcontextprotocol/sdk/client/auth.js' import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message) + } +} + export type UseMcpOptions = { /** The /sse URL of your remote MCP server */ url: string @@ -259,7 +265,6 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { const codeVerifierRef = useRef(undefined) const connectingRef = useRef(false) const isInitialMount = useRef(true) - let handleAuthentication: () => Promise // Set up default options const { @@ -338,6 +343,114 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { setError(undefined) }, [addLog]) + // Start the auth flow and get the auth URL + const startAuthFlow = useCallback(async (): Promise => { + 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', 'Preparing authorization...') + const { authorizationUrl, codeVerifier } = await startAuthorization(url, { + metadata: metadataRef.current, + clientInformation: clientInfo, + redirectUrl: authProviderRef.current.redirectUrl, + }) + + // Save code verifier and auth URL for later use + await authProviderRef.current.saveCodeVerifier(codeVerifier) + codeVerifierRef.current = codeVerifier + authUrlRef.current = authorizationUrl + setAuthUrl(authorizationUrl.toString()) + + return authorizationUrl + }, [url, addLog]) + + // Handle authentication flow + const handleAuthentication = useCallback(async () => { + if (!authProviderRef.current) { + throw new Error('Auth provider not available') + } + + // Get or create the auth URL + if (!authUrlRef.current) { + try { + await startAuthFlow() + } catch (err) { + addLog('error', `Failed to start auth flow: ${err instanceof Error ? err.message : String(err)}`) + throw err + } + } + + if (!authUrlRef.current) { + throw new Error('Failed to create authorization URL') + } + + // 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) + + // TODO: not this, obviously + // reload window, we should find the token in local storage + window.location.reload() + // resolve(event.data.code) + } + } + + window.addEventListener('message', messageHandler) + }) + + // Redirect to authorization + addLog('info', 'Opening authorization window...') + assert(metadataRef.current, 'Metadata not available') + const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, metadataRef.current, { + popupFeatures, + }) + + if (!redirectResult.success) { + // Popup was blocked + setState('failed') + setError('Authentication popup was blocked by the browser. Please click the link to authenticate in a new window.') + setAuthUrl(redirectResult.url) + addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') + throw new Error('Authentication popup blocked') + } + + // Wait for auth to complete + addLog('info', 'Waiting for authorization...') + const code = await authPromise + addLog('info', 'Authorization code received') + + return code + }, [url, addLog, popupFeatures, startAuthFlow]) + // Initialize connection to MCP server const connect = useCallback(async () => { // Prevent multiple simultaneous connection attempts @@ -387,11 +500,13 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { const serverUrl = new URL(url) transportRef.current = new SSEClientTransport(serverUrl, { + // @ts-expect-error TODO: fix this type, expect BrowserOAuthClientProvider authProvider: authProviderRef.current, }) // Set up transport handlers transportRef.current.onmessage = (message: JSONRPCMessage) => { + // @ts-expect-error TODO: fix this type addLog('debug', `Received message: ${message.method || message.id}`) } @@ -507,111 +622,6 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { return undefined }, []) - // Start the auth flow and get the auth URL - const startAuthFlow = useCallback(async (): Promise => { - 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', 'Preparing authorization...') - const { authorizationUrl, codeVerifier } = await startAuthorization(url, { - metadata: metadataRef.current, - clientInformation: clientInfo, - redirectUrl: authProviderRef.current.redirectUrl, - }) - - // Save code verifier and auth URL for later use - await authProviderRef.current.saveCodeVerifier(codeVerifier) - codeVerifierRef.current = codeVerifier - authUrlRef.current = authorizationUrl - setAuthUrl(authorizationUrl.toString()) - - return authorizationUrl - }, [url, addLog]) - - // Handle authentication flow - handleAuthentication = useCallback(async () => { - if (!authProviderRef.current) { - throw new Error('Auth provider not available') - } - - // Get or create the auth URL - if (!authUrlRef.current) { - try { - await startAuthFlow() - } catch (err) { - addLog('error', `Failed to start auth flow: ${err instanceof Error ? err.message : String(err)}`) - throw err - } - } - - if (!authUrlRef.current) { - throw new Error('Failed to create authorization URL') - } - - // 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) - - // TODO: not this, obviously - // reload window, we should find the token in local storage - window.location.reload() - // resolve(event.data.code) - } - } - - window.addEventListener('message', messageHandler) - }) - - // Redirect to authorization - addLog('info', 'Opening authorization window...') - const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, metadataRef.current, { popupFeatures }) - - if (!redirectResult.success) { - // Popup was blocked - setState('failed') - setError('Authentication popup was blocked by the browser. Please click the link to authenticate in a new window.') - setAuthUrl(redirectResult.url) - addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') - throw new Error('Authentication popup blocked') - } - - // Wait for auth to complete - addLog('info', 'Waiting for authorization...') - const code = await authPromise - addLog('info', 'Authorization code received') - - return code - }, [url, addLog, popupFeatures, startAuthFlow]) - // Handle auth completion - this is called when we receive a message from the popup const handleAuthCompletion = useCallback( async (code: string) => { diff --git a/tsconfig.json b/tsconfig.json index b562002..cd9cfa1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,14 @@ { "compilerOptions": { "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", + "module": "ES2022", + "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, - + "noEmit": true, "lib": ["ES2022", "DOM"], "types": ["node", "react"], "forceConsistentCasingInFileNames": true, "resolveJsonModule": true - }, - "include": ["*.ts","src/**/*"], - "exclude": ["node_modules", "packages", "**/*.spec.ts"] + } } diff --git a/tsup.config.ts b/tsup.config.ts deleted file mode 100644 index a203cbb..0000000 --- a/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/cli/client.ts', 'src/cli/proxy.ts', 'src/react/index.ts'], - format: ['esm'], - dts: true, - clean: true, - outDir: 'dist', - external: ['react'], -})