Gearing up for Claude to write this react hook
This commit is contained in:
parent
cb322d877d
commit
eca1e45363
9 changed files with 210 additions and 134 deletions
10
package.json
10
package.json
|
@ -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
9
pnpm-lock.yaml
generated
|
@ -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: {}
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
31
src/lib/types.ts
Normal 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
100
src/lib/utils.ts
Normal 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
55
src/react/index.ts
Normal 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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue