Merge pull request #1 from geelen/use-mcp
`import { useMcp } from 'mcp-remote/react'`
This commit is contained in:
commit
426c6139f2
11 changed files with 1022 additions and 153 deletions
|
@ -1,9 +1,9 @@
|
||||||
# `mcp-remote`
|
# `mcp-remote`
|
||||||
|
|
||||||
**EXPERIMENTAL PROOF OF CONCEPT**
|
|
||||||
|
|
||||||
Connect an MCP Client that only supports local (stdio) servers to a Remote MCP Server, with auth support:
|
Connect an MCP Client that only supports local (stdio) servers to a Remote MCP Server, with auth support:
|
||||||
|
|
||||||
|
**Note: this is a working proof-of-concept** but should be considered **experimental**
|
||||||
|
|
||||||
E.g: Claude Desktop or Windsurf
|
E.g: Claude Desktop or Windsurf
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
15
package.json
15
package.json
|
@ -1,25 +1,34 @@
|
||||||
{
|
{
|
||||||
"name": "mcp-remote",
|
"name": "mcp-remote",
|
||||||
"version": "0.0.4",
|
"version": "0.0.5-0",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"open": "^10.1.0"
|
"open": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/react": "^19.0.12",
|
||||||
"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"
|
||||||
|
|
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
|
@ -8,9 +8,6 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@modelcontextprotocol/sdk':
|
|
||||||
specifier: ^1.7.0
|
|
||||||
version: 1.7.0
|
|
||||||
express:
|
express:
|
||||||
specifier: ^4.21.2
|
specifier: ^4.21.2
|
||||||
version: 4.21.2
|
version: 4.21.2
|
||||||
|
@ -18,15 +15,24 @@ importers:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@modelcontextprotocol/sdk':
|
||||||
|
specifier: ^1.7.0
|
||||||
|
version: 1.7.0
|
||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.10
|
specifier: ^22.13.10
|
||||||
version: 22.13.10
|
version: 22.13.10
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.0.12
|
||||||
|
version: 19.0.12
|
||||||
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)
|
||||||
|
@ -344,6 +350,9 @@ packages:
|
||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
|
'@types/react@19.0.12':
|
||||||
|
resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==}
|
||||||
|
|
||||||
'@types/send@0.17.4':
|
'@types/send@0.17.4':
|
||||||
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
|
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
|
||||||
|
|
||||||
|
@ -470,6 +479,9 @@ packages:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
csstype@3.1.3:
|
||||||
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -751,8 +763,8 @@ packages:
|
||||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
mime-db@1.53.0:
|
mime-db@1.54.0:
|
||||||
resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==}
|
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
|
@ -903,6 +915,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'}
|
||||||
|
@ -1111,8 +1127,8 @@ packages:
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
zod-to-json-schema@3.24.4:
|
zod-to-json-schema@3.24.5:
|
||||||
resolution: {integrity: sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==}
|
resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.24.1
|
zod: ^3.24.1
|
||||||
|
|
||||||
|
@ -1232,7 +1248,7 @@ snapshots:
|
||||||
pkce-challenge: 4.1.0
|
pkce-challenge: 4.1.0
|
||||||
raw-body: 3.0.0
|
raw-body: 3.0.0
|
||||||
zod: 3.24.2
|
zod: 3.24.2
|
||||||
zod-to-json-schema: 3.24.4(zod@3.24.2)
|
zod-to-json-schema: 3.24.5(zod@3.24.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -1333,6 +1349,10 @@ snapshots:
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
|
|
||||||
|
'@types/react@19.0.12':
|
||||||
|
dependencies:
|
||||||
|
csstype: 3.1.3
|
||||||
|
|
||||||
'@types/send@0.17.4':
|
'@types/send@0.17.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mime': 1.3.5
|
'@types/mime': 1.3.5
|
||||||
|
@ -1469,6 +1489,8 @@ snapshots:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
@ -1789,7 +1811,7 @@ snapshots:
|
||||||
|
|
||||||
mime-db@1.52.0: {}
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
mime-db@1.53.0: {}
|
mime-db@1.54.0: {}
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -1797,7 +1819,7 @@ snapshots:
|
||||||
|
|
||||||
mime-types@3.0.0:
|
mime-types@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.53.0
|
mime-db: 1.54.0
|
||||||
|
|
||||||
mime@1.6.0: {}
|
mime@1.6.0: {}
|
||||||
|
|
||||||
|
@ -1904,6 +1926,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: {}
|
||||||
|
@ -1967,7 +1991,7 @@ snapshots:
|
||||||
|
|
||||||
send@1.1.0:
|
send@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.6
|
debug: 4.4.0
|
||||||
destroy: 1.2.0
|
destroy: 1.2.0
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
|
@ -2179,7 +2203,7 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
zod-to-json-schema@3.24.4(zod@3.24.2):
|
zod-to-json-schema@3.24.5(zod@3.24.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 3.24.2
|
zod: 3.24.2
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
831
src/react/index.ts
Normal file
831
src/react/index.ts
Normal file
|
@ -0,0 +1,831 @@
|
||||||
|
import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
|
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'
|
||||||
|
|
||||||
|
export type UseMcpOptions = {
|
||||||
|
/** The /sse URL of your remote MCP server */
|
||||||
|
url: string
|
||||||
|
/** 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
|
||||||
|
/** Popup window features (dimensions and behavior) for OAuth */
|
||||||
|
popupFeatures?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/**
|
||||||
|
* If authorization was blocked, this will contain the URL to authorize manually
|
||||||
|
* The app can render this as a link with target="_blank" so the user can complete
|
||||||
|
* authorization without leaving the app
|
||||||
|
*/
|
||||||
|
authUrl?: 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<string, unknown>) => Promise<any>
|
||||||
|
/** Manually retry connection if it's in a failed state */
|
||||||
|
retry: () => void
|
||||||
|
/** Manually disconnect from the MCP server */
|
||||||
|
disconnect: () => void
|
||||||
|
/**
|
||||||
|
* Manually trigger authentication
|
||||||
|
* @returns Auth URL that can be used to manually open a new window
|
||||||
|
*/
|
||||||
|
authenticate: () => Promise<string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredState = {
|
||||||
|
authorizationUrl: string
|
||||||
|
metadata: OAuthMetadata
|
||||||
|
serverUrlHash: string
|
||||||
|
expiry: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<OAuthClientInformation | undefined> {
|
||||||
|
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<void> {
|
||||||
|
const key = this.getKey('client_info')
|
||||||
|
localStorage.setItem(key, JSON.stringify(clientInformation))
|
||||||
|
}
|
||||||
|
|
||||||
|
async tokens(): Promise<OAuthTokens | undefined> {
|
||||||
|
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<void> {
|
||||||
|
const key = this.getKey('tokens')
|
||||||
|
localStorage.setItem(key, JSON.stringify(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
async redirectToAuthorization(
|
||||||
|
authorizationUrl: URL,
|
||||||
|
metadata: OAuthMetadata,
|
||||||
|
options?: {
|
||||||
|
popupFeatures?: string
|
||||||
|
},
|
||||||
|
): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> {
|
||||||
|
// Store the auth state for the popup flow
|
||||||
|
const state = Math.random().toString(36).substring(2)
|
||||||
|
const stateKey = `${this.storageKeyPrefix}:state_${state}`
|
||||||
|
localStorage.setItem(
|
||||||
|
stateKey,
|
||||||
|
JSON.stringify({
|
||||||
|
authorizationUrl: authorizationUrl.toString(),
|
||||||
|
metadata,
|
||||||
|
serverUrlHash: this.serverUrlHash,
|
||||||
|
expiry: +new Date() + 1000 * 60 * 5 /* 5 minutes */,
|
||||||
|
} as StoredState),
|
||||||
|
)
|
||||||
|
authorizationUrl.searchParams.set('state', state)
|
||||||
|
|
||||||
|
const authUrl = authorizationUrl.toString()
|
||||||
|
const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes'
|
||||||
|
|
||||||
|
// Store the auth URL in case we need it for manual authentication
|
||||||
|
localStorage.setItem(this.getKey('auth_url'), authUrl)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Open the authorization URL in a popup window
|
||||||
|
const popup = window.open(authUrl, 'mcp_auth', popupFeatures)
|
||||||
|
|
||||||
|
// Check if popup was blocked or closed immediately
|
||||||
|
if (!popup || popup.closed || popup.closed === undefined) {
|
||||||
|
console.warn('Popup blocked. Returning error.')
|
||||||
|
return { success: false, popupBlocked: true, url: authUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to access the popup to confirm it's not blocked
|
||||||
|
try {
|
||||||
|
// Just accessing any property will throw if popup is blocked
|
||||||
|
const popupLocation = popup.location
|
||||||
|
// If we can read location.href, the popup is definitely working
|
||||||
|
if (popupLocation.href) {
|
||||||
|
// Successfully opened popup
|
||||||
|
return { success: true, url: authUrl }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Access to the popup was denied, indicating it's blocked
|
||||||
|
console.warn('Popup blocked (security exception).')
|
||||||
|
return { success: false, popupBlocked: true, url: authUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got here, popup is working
|
||||||
|
return { success: true, url: authUrl }
|
||||||
|
} catch (e) {
|
||||||
|
// Error opening popup
|
||||||
|
console.warn('Error opening popup:', e)
|
||||||
|
return { success: false, popupBlocked: true, url: authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||||
|
const key = this.getKey('code_verifier')
|
||||||
|
localStorage.setItem(key, codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
async codeVerifier(): Promise<string> {
|
||||||
|
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`).
|
||||||
|
*/
|
||||||
|
export function useMcp(options: UseMcpOptions): UseMcpResult {
|
||||||
|
const [state, setState] = useState<UseMcpResult['state']>('discovering')
|
||||||
|
const [tools, setTools] = useState<Tool[]>([])
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined)
|
||||||
|
const [log, setLog] = useState<UseMcpResult['log']>([])
|
||||||
|
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const clientRef = useRef<Client | null>(null)
|
||||||
|
const transportRef = useRef<SSEClientTransport | null>(null)
|
||||||
|
const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null)
|
||||||
|
const metadataRef = useRef<OAuthMetadata | undefined>(undefined)
|
||||||
|
const authUrlRef = useRef<URL | undefined>(undefined)
|
||||||
|
const codeVerifierRef = useRef<string | undefined>(undefined)
|
||||||
|
const connectingRef = useRef<boolean>(false)
|
||||||
|
const isInitialMount = useRef<boolean>(true)
|
||||||
|
let handleAuthentication: () => Promise<string>
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes',
|
||||||
|
} = 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<string, unknown>) => {
|
||||||
|
if (!clientRef.current || state !== 'ready') {
|
||||||
|
throw new Error('MCP client not ready')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('CALLING TOOL')
|
||||||
|
const result = await clientRef.current.request(
|
||||||
|
{
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name, arguments: args },
|
||||||
|
},
|
||||||
|
CallToolResultSchema,
|
||||||
|
)
|
||||||
|
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])
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Provide public authenticate method
|
||||||
|
const authenticate = useCallback(async (): Promise<string | undefined> => {
|
||||||
|
if (!authUrlRef.current) {
|
||||||
|
await startAuthFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authUrlRef.current) {
|
||||||
|
return authUrlRef.current.toString()
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Start the auth flow and get the auth URL
|
||||||
|
const startAuthFlow = useCallback(async (): Promise<URL | undefined> => {
|
||||||
|
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<string>((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) => {
|
||||||
|
if (!authProviderRef.current || !transportRef.current) {
|
||||||
|
throw new Error('Authentication context not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
addLog('info', 'Finishing authorization...')
|
||||||
|
await transportRef.current.finishAuth(code)
|
||||||
|
addLog('info', 'Authorization completed')
|
||||||
|
|
||||||
|
// Reset auth URL state
|
||||||
|
authUrlRef.current = undefined
|
||||||
|
setAuthUrl(undefined)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
authUrl,
|
||||||
|
callTool,
|
||||||
|
retry,
|
||||||
|
disconnect,
|
||||||
|
authenticate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>,
|
||||||
|
{
|
||||||
|
storageKeyPrefix = 'mcp:auth',
|
||||||
|
}: {
|
||||||
|
storageKeyPrefix?: string
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
const stateKey = `${storageKeyPrefix}:state_${state}`
|
||||||
|
const storedState = localStorage.getItem(stateKey)
|
||||||
|
console.log({ stateKey, storedState })
|
||||||
|
if (!storedState) {
|
||||||
|
throw new Error('No matching auth state found in storage')
|
||||||
|
}
|
||||||
|
const { authorizationUrl, serverUrlHash, metadata, expiry } = JSON.parse(storedState)
|
||||||
|
if (expiry < Date.now()) {
|
||||||
|
throw new Error('Auth state has expired')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all related auth data with the same prefix and server hash
|
||||||
|
const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`
|
||||||
|
const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`
|
||||||
|
console.log({ authorizationUrl, clientInfoKey, codeVerifierKey })
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const tokens = await exchangeAuthorization(new URL('/', authorizationUrl), {
|
||||||
|
metadata,
|
||||||
|
clientInformation: clientInfo,
|
||||||
|
authorizationCode: code,
|
||||||
|
codeVerifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save the tokens
|
||||||
|
const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`
|
||||||
|
console.log({ tokensKey, 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',
|
||||||
|
},
|
||||||
|
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 = `
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; padding: 2rem; line-height: 1.5; }
|
||||||
|
.error { color: #e53e3e; background: #fed7d7; padding: 1rem; border-radius: 0.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Authentication Error</h1>
|
||||||
|
<div class="error">
|
||||||
|
<p>${errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<p>You can close this window and try again.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
document.body.innerHTML = errorHtml
|
||||||
|
|
||||||
|
return { success: false, error: errorMessage }
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,8 +7,9 @@
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"lib": ["ES2022"],
|
|
||||||
"types": ["node"],
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": ["node", "react"],
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
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,
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
// external: ['typescript'],
|
external: ['react'],
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue