From eca1e453637bc0f0741abeeb93b0c97a3787bb78 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Sat, 22 Mar 2025 21:24:39 +1100 Subject: [PATCH] Gearing up for Claude to write this react hook --- package.json | 10 +++- pnpm-lock.yaml | 9 +++ src/{ => cli}/client.ts | 2 +- src/{ => cli}/proxy.ts | 5 +- src/{ => cli}/shared.ts | 130 +--------------------------------------- src/lib/types.ts | 31 ++++++++++ src/lib/utils.ts | 100 +++++++++++++++++++++++++++++++ src/react/index.ts | 55 +++++++++++++++++ tsup.config.ts | 2 +- 9 files changed, 210 insertions(+), 134 deletions(-) rename src/{ => cli}/client.ts (98%) rename src/{ => cli}/proxy.ts (96%) rename src/{ => cli}/shared.ts (71%) create mode 100644 src/lib/types.ts create mode 100644 src/lib/utils.ts create mode 100644 src/react/index.ts diff --git a/package.json b/package.json index bd33bef..4ea39d4 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,19 @@ "name": "mcp-remote", "version": "0.0.4", "type": "module", - "bin": "dist/proxy.js", + "bin": "dist/cli/proxy.js", "files": [ "dist", "README.md", "LICENSE" ], + "exports": { + "./react": { + "types": "./dist/react/index.d.ts", + "require": "./dist/react/index.js", + "import": "./dist/react/index.js" + } + }, "scripts": { "build": "tsup" }, @@ -20,6 +27,7 @@ "@types/express": "^5.0.0", "@types/node": "^22.13.10", "prettier": "^3.5.3", + "react": "^19.0.0", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fed362..6821d5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: prettier: specifier: ^3.5.3 version: 3.5.3 + react: + specifier: ^19.0.0 + version: 19.0.0 tsup: specifier: ^8.4.0 version: 8.4.0(tsx@4.19.3)(typescript@5.8.2) @@ -903,6 +906,10 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} + react@19.0.0: + resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} + engines: {node: '>=0.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1904,6 +1911,8 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + react@19.0.0: {} + readdirp@4.1.2: {} resolve-from@5.0.0: {} diff --git a/src/client.ts b/src/cli/client.ts similarity index 98% rename from src/client.ts rename to src/cli/client.ts index 245f6e9..f79e917 100644 --- a/src/client.ts +++ b/src/cli/client.ts @@ -14,7 +14,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared' +import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' /** * Main function to run the client diff --git a/src/proxy.ts b/src/cli/proxy.ts similarity index 96% rename from src/proxy.ts rename to src/cli/proxy.ts index a6e2388..763a9b1 100644 --- a/src/proxy.ts +++ b/src/cli/proxy.ts @@ -14,11 +14,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { NodeOAuthClientProvider, setupOAuthCallbackServer, - connectToRemoteServer, - mcpProxy, parseCommandLineArgs, setupSignalHandlers, -} from './shared' +} from './shared.js' +import {connectToRemoteServer, mcpProxy} from "../lib/utils.js"; /** * Main function to run the proxy diff --git a/src/shared.ts b/src/cli/shared.ts similarity index 71% rename from src/shared.ts rename to src/cli/shared.ts index 933ce4a..28bc1b1 100644 --- a/src/shared.ts +++ b/src/cli/shared.ts @@ -9,11 +9,8 @@ import fs from 'fs/promises' import path from 'path' import os from 'os' import crypto from 'crypto' -import { EventEmitter } from 'events' import net from 'net' -import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import {OAuthClientProvider} from '@modelcontextprotocol/sdk/client/auth.js' import { OAuthClientInformation, OAuthClientInformationFull, @@ -21,24 +18,7 @@ import { OAuthTokens, OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' - -/** - * Options for creating an OAuth client provider - */ -export interface OAuthProviderOptions { - /** Server URL to connect to */ - serverUrl: string - /** Port for the OAuth callback server */ - callbackPort: number - /** Path for the OAuth callback endpoint */ - callbackPath?: string - /** Directory to store OAuth credentials */ - configDir?: string - /** Client name to use for OAuth registration */ - clientName?: string - /** Client URI to use for OAuth registration */ - clientUri?: string -} +import {OAuthCallbackServerOptions, OAuthProviderOptions} from "../lib/types.js"; /** * Implements the OAuthClientProvider interface for Node.js environments. @@ -225,18 +205,6 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { } } -/** - * OAuth callback server setup options - */ -export interface OAuthCallbackServerOptions { - /** Port for the callback server */ - port: number - /** Path for the callback endpoint */ - path: string - /** Event emitter to signal when auth code is received */ - events: EventEmitter -} - /** * Sets up an Express server to handle OAuth callbacks * @param options The server options @@ -284,100 +252,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { return { server, authCode, waitForAuthCode } } -/** - * Creates and connects to a remote SSE server with OAuth authentication - * @param serverUrl The URL of the remote server - * @param authProvider The OAuth client provider - * @param waitForAuthCode Function to wait for the auth code - * @returns The connected SSE client transport - */ -export async function connectToRemoteServer( - serverUrl: string, - authProvider: OAuthClientProvider, - waitForAuthCode: () => Promise, -): Promise { - console.error('Connecting to remote server:', serverUrl) - const url = new URL(serverUrl) - const transport = new SSEClientTransport(url, { authProvider }) - - try { - await transport.start() - console.error('Connected to remote server') - return transport - } catch (error) { - if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { - console.error('Authentication required. Waiting for authorization...') - - // Wait for the authorization code from the callback - const code = await waitForAuthCode() - - try { - console.error('Completing authorization...') - await transport.finishAuth(code) - - // Create a new transport after auth - const newTransport = new SSEClientTransport(url, { authProvider }) - await newTransport.start() - console.error('Connected to remote server after authentication') - return newTransport - } catch (authError) { - console.error('Authorization error:', authError) - throw authError - } - } else { - console.error('Connection error:', error) - throw error - } - } -} - -/** - * Creates a bidirectional proxy between two transports - * @param params The transport connections to proxy between - */ -export function mcpProxy({ transportToClient, transportToServer }: { transportToClient: Transport; transportToServer: Transport }) { - let transportToClientClosed = false - let transportToServerClosed = false - - transportToClient.onmessage = (message) => { - console.error('[Local→Remote]', message.method || message.id) - transportToServer.send(message).catch(onServerError) - } - - transportToServer.onmessage = (message) => { - console.error('[Remote→Local]', message.method || message.id) - transportToClient.send(message).catch(onClientError) - } - - transportToClient.onclose = () => { - if (transportToServerClosed) { - return - } - - transportToClientClosed = true - transportToServer.close().catch(onServerError) - } - - transportToServer.onclose = () => { - if (transportToClientClosed) { - return - } - transportToServerClosed = true - transportToClient.close().catch(onClientError) - } - - transportToClient.onerror = onClientError - transportToServer.onerror = onServerError - - function onClientError(error: Error) { - console.error('Error from local client:', error) - } - - function onServerError(error: Error) { - console.error('Error from remote server:', error) - } -} - /** * Finds an available port on the local machine * @param preferredPort Optional preferred port to try first diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..2201eb5 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,31 @@ +import {EventEmitter} from "events"; + +/** + * Options for creating an OAuth client provider + */ +export interface OAuthProviderOptions { + /** Server URL to connect to */ + serverUrl: string + /** Port for the OAuth callback server */ + callbackPort: number + /** Path for the OAuth callback endpoint */ + callbackPath?: string + /** Directory to store OAuth credentials */ + configDir?: string + /** Client name to use for OAuth registration */ + clientName?: string + /** Client URI to use for OAuth registration */ + clientUri?: string +} + +/** + * OAuth callback server setup options + */ +export interface OAuthCallbackServerOptions { + /** Port for the callback server */ + port: number + /** Path for the callback endpoint */ + path: string + /** Event emitter to signal when auth code is received */ + events: EventEmitter +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..2a51a47 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,100 @@ +import { OAuthClientProvider, UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +/** + * Creates a bidirectional proxy between two transports + * @param params The transport connections to proxy between + */ +export function mcpProxy({transportToClient, transportToServer}: { + transportToClient: Transport; + transportToServer: Transport +}) { + let transportToClientClosed = false + let transportToServerClosed = false + + transportToClient.onmessage = (message) => { + console.error('[Local→Remote]', message.method || message.id) + transportToServer.send(message).catch(onServerError) + } + + transportToServer.onmessage = (message) => { + console.error('[Remote→Local]', message.method || message.id) + transportToClient.send(message).catch(onClientError) + } + + transportToClient.onclose = () => { + if (transportToServerClosed) { + return + } + + transportToClientClosed = true + transportToServer.close().catch(onServerError) + } + + transportToServer.onclose = () => { + if (transportToClientClosed) { + return + } + transportToServerClosed = true + transportToClient.close().catch(onClientError) + } + + transportToClient.onerror = onClientError + transportToServer.onerror = onServerError + + function onClientError(error: Error) { + console.error('Error from local client:', error) + } + + function onServerError(error: Error) { + console.error('Error from remote server:', error) + } +} + +/** + * Creates and connects to a remote SSE server with OAuth authentication + * @param serverUrl The URL of the remote server + * @param authProvider The OAuth client provider + * @param waitForAuthCode Function to wait for the auth code + * @returns The connected SSE client transport + */ +export async function connectToRemoteServer( + serverUrl: string, + authProvider: OAuthClientProvider, + waitForAuthCode: () => Promise, +): Promise { + console.error('Connecting to remote server:', serverUrl) + const url = new URL(serverUrl) + const transport = new SSEClientTransport(url, {authProvider}) + + try { + await transport.start() + console.error('Connected to remote server') + return transport + } catch (error) { + if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { + console.error('Authentication required. Waiting for authorization...') + + // Wait for the authorization code from the callback + const code = await waitForAuthCode() + + try { + console.error('Completing authorization...') + await transport.finishAuth(code) + + // Create a new transport after auth + const newTransport = new SSEClientTransport(url, {authProvider}) + await newTransport.start() + console.error('Connected to remote server after authentication') + return newTransport + } catch (authError) { + console.error('Authorization error:', authError) + throw authError + } + } else { + console.error('Connection error:', error) + throw error + } + } +} \ No newline at end of file diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 0000000..39c3139 --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,55 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { useCallback, useState } from "react"; + +export type UseMcpOptions = { + /** The /sse URL of your remote MCP server */ + url: string, + + // more options here as I think of them +} + +export type UseMcpResult = { + tools: Tool[], + /** + * The current state of the MCP connection. This will be one of: + * - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are + * - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete + * - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials + * - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools + * - 'ready': The MCP server is connected and ready to be used + * - 'failed': The connection to the MCP server failed + * */ + state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed', + /** If the state is 'failed', this will be the error message */ + error?: string, + /** All internal log messages */ + log: {level: 'debug' | 'info' | 'warn' | 'error', message: string}[], + + // more as i think of them +} + +/** + * 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 +} + +/** + * onMcpAuthorization is invoked when the oauth flow completes. This is usually mounted + * on /oauth/callback, and passed the entire URL query parameters. This first uses the state + * parameter to look up in LocalStorage the context for the current auth flow, and then + * completes the flow by exchanging the authorization code for an access token. + * + * Once it's updated LocalStorage with the auth token, it will post a message back to the original + * window to inform any running `useMcp` hooks that the auth flow is complete. + */ +export async function onMcpAuthorization(query: Record) { + // TODO: implement +} \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index c1849c0..68da9d3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/client.ts', 'src/proxy.ts'], + entry: ['src/cli/client.ts', 'src/cli/proxy.ts', 'src/react/index.ts'], format: ['esm'], dts: true, clean: true,