Gearing up for Claude to write this react hook

This commit is contained in:
Glen Maddern 2025-03-22 21:24:39 +11:00
parent cb322d877d
commit eca1e45363
9 changed files with 210 additions and 134 deletions

View file

@ -2,12 +2,19 @@
"name": "mcp-remote", "name": "mcp-remote",
"version": "0.0.4", "version": "0.0.4",
"type": "module", "type": "module",
"bin": "dist/proxy.js", "bin": "dist/cli/proxy.js",
"files": [ "files": [
"dist", "dist",
"README.md", "README.md",
"LICENSE" "LICENSE"
], ],
"exports": {
"./react": {
"types": "./dist/react/index.d.ts",
"require": "./dist/react/index.js",
"import": "./dist/react/index.js"
}
},
"scripts": { "scripts": {
"build": "tsup" "build": "tsup"
}, },
@ -20,6 +27,7 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"react": "^19.0.0",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.8.2" "typescript": "^5.8.2"

9
pnpm-lock.yaml generated
View file

@ -27,6 +27,9 @@ importers:
prettier: prettier:
specifier: ^3.5.3 specifier: ^3.5.3
version: 3.5.3 version: 3.5.3
react:
specifier: ^19.0.0
version: 19.0.0
tsup: tsup:
specifier: ^8.4.0 specifier: ^8.4.0
version: 8.4.0(tsx@4.19.3)(typescript@5.8.2) version: 8.4.0(tsx@4.19.3)(typescript@5.8.2)
@ -903,6 +906,10 @@ packages:
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
readdirp@4.1.2: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@ -1904,6 +1911,8 @@ snapshots:
iconv-lite: 0.6.3 iconv-lite: 0.6.3
unpipe: 1.0.0 unpipe: 1.0.0
react@19.0.0: {}
readdirp@4.1.2: {} readdirp@4.1.2: {}
resolve-from@5.0.0: {} resolve-from@5.0.0: {}

View file

@ -14,7 +14,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.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 * Main function to run the client

View file

@ -14,11 +14,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { import {
NodeOAuthClientProvider, NodeOAuthClientProvider,
setupOAuthCallbackServer, setupOAuthCallbackServer,
connectToRemoteServer,
mcpProxy,
parseCommandLineArgs, parseCommandLineArgs,
setupSignalHandlers, setupSignalHandlers,
} from './shared' } from './shared.js'
import {connectToRemoteServer, mcpProxy} from "../lib/utils.js";
/** /**
* Main function to run the proxy * Main function to run the proxy

View file

@ -9,11 +9,8 @@ import fs from 'fs/promises'
import path from 'path' import path from 'path'
import os from 'os' import os from 'os'
import crypto from 'crypto' import crypto from 'crypto'
import { EventEmitter } from 'events'
import net from 'net' import net from 'net'
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import {OAuthClientProvider} from '@modelcontextprotocol/sdk/client/auth.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { import {
OAuthClientInformation, OAuthClientInformation,
OAuthClientInformationFull, OAuthClientInformationFull,
@ -21,24 +18,7 @@ import {
OAuthTokens, OAuthTokens,
OAuthTokensSchema, OAuthTokensSchema,
} from '@modelcontextprotocol/sdk/shared/auth.js' } from '@modelcontextprotocol/sdk/shared/auth.js'
import {OAuthCallbackServerOptions, OAuthProviderOptions} from "../lib/types.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
}
/** /**
* Implements the OAuthClientProvider interface for Node.js environments. * 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 * Sets up an Express server to handle OAuth callbacks
* @param options The server options * @param options The server options
@ -284,100 +252,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
return { server, authCode, waitForAuthCode } 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<string>,
): Promise<SSEClientTransport> {
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 * Finds an available port on the local machine
* @param preferredPort Optional preferred port to try first * @param preferredPort Optional preferred port to try first

31
src/lib/types.ts Normal file
View file

@ -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
}

100
src/lib/utils.ts Normal file
View file

@ -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<string>,
): Promise<SSEClientTransport> {
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
}
}
}

55
src/react/index.ts Normal file
View file

@ -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<string, string>) {
// TODO: implement
}

View file

@ -1,7 +1,7 @@
import { defineConfig } from 'tsup' import { defineConfig } from 'tsup'
export default defineConfig({ 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'], format: ['esm'],
dts: true, dts: true,
clean: true, clean: true,