Ported v0.0.4 from mcp-remote-examples
This commit is contained in:
parent
d1d7803d9a
commit
cb322d877d
11 changed files with 2986 additions and 137 deletions
137
.gitignore
vendored
137
.gitignore
vendored
|
@ -1,136 +1,3 @@
|
||||||
# Logs
|
node_modules
|
||||||
logs
|
.mcp-cli
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
dist
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
14
.prettierrc
Normal file
14
.prettierrc
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"printWidth": 140,
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"useTabs": false,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.jsonc"],
|
||||||
|
"options": {
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Glen Maddern
|
Copyright (c) 2025 Cloudflare, Inc.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
27
README.md
27
README.md
|
@ -1 +1,26 @@
|
||||||
# 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:
|
||||||
|
|
||||||
|
E.g: Claude Desktop or Windsurf
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"remote-example": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://remote.mcp.server/sse"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cursor:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
27
package.json
Normal file
27
package.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "mcp-remote",
|
||||||
|
"version": "0.0.4",
|
||||||
|
"type": "module",
|
||||||
|
"bin": "dist/proxy.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"open": "^10.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"tsup": "^8.4.0",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
2186
pnpm-lock.yaml
generated
Normal file
2186
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
158
src/client.ts
Normal file
158
src/client.ts
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Client with OAuth support
|
||||||
|
* A command-line client that connects to an MCP server using SSE with OAuth authentication.
|
||||||
|
*
|
||||||
|
* Run with: npx tsx client.ts https://example.remote/server [callback-port]
|
||||||
|
*
|
||||||
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
|
import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||||
|
import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to run the client
|
||||||
|
*/
|
||||||
|
async function runClient(serverUrl: string, callbackPort: number) {
|
||||||
|
// Set up event emitter for auth flow
|
||||||
|
const events = new EventEmitter()
|
||||||
|
|
||||||
|
// Create the OAuth client provider
|
||||||
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
|
serverUrl,
|
||||||
|
callbackPort,
|
||||||
|
clientName: 'MCP CLI Client',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the client
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'mcp-cli',
|
||||||
|
version: '0.1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
sampling: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the transport factory
|
||||||
|
const url = new URL(serverUrl)
|
||||||
|
function initTransport() {
|
||||||
|
const transport = new SSEClientTransport(url, { authProvider })
|
||||||
|
|
||||||
|
// Set up message and error handlers
|
||||||
|
transport.onmessage = (message) => {
|
||||||
|
console.log('Received message:', JSON.stringify(message, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.onerror = (error) => {
|
||||||
|
console.error('Transport error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.onclose = () => {
|
||||||
|
console.log('Connection closed.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = initTransport()
|
||||||
|
|
||||||
|
// Set up an HTTP server to handle OAuth callback
|
||||||
|
const { server, waitForAuthCode } = setupOAuthCallbackServer({
|
||||||
|
port: callbackPort,
|
||||||
|
path: '/oauth/callback',
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set up cleanup handler
|
||||||
|
const cleanup = async () => {
|
||||||
|
console.log('\nClosing connection...')
|
||||||
|
await client.close()
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
setupSignalHandlers(cleanup)
|
||||||
|
|
||||||
|
// Try to connect
|
||||||
|
try {
|
||||||
|
console.log('Connecting to server...')
|
||||||
|
await client.connect(transport)
|
||||||
|
console.log('Connected successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
||||||
|
console.log('Authentication required. Waiting for authorization...')
|
||||||
|
|
||||||
|
// Wait for the authorization code from the callback
|
||||||
|
const code = await waitForAuthCode()
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Completing authorization...')
|
||||||
|
await transport.finishAuth(code)
|
||||||
|
|
||||||
|
// Reconnect after authorization with a new transport
|
||||||
|
console.log('Connecting after authorization...')
|
||||||
|
await client.connect(initTransport())
|
||||||
|
|
||||||
|
console.log('Connected successfully!')
|
||||||
|
|
||||||
|
// Request tools list after auth
|
||||||
|
console.log('Requesting tools list...')
|
||||||
|
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||||
|
console.log('Tools:', JSON.stringify(tools, null, 2))
|
||||||
|
|
||||||
|
// Request resources list after auth
|
||||||
|
console.log('Requesting resource list...')
|
||||||
|
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||||
|
console.log('Resources:', JSON.stringify(resources, null, 2))
|
||||||
|
|
||||||
|
console.log('Listening for messages. Press Ctrl+C to exit.')
|
||||||
|
} catch (authError) {
|
||||||
|
console.error('Authorization error:', authError)
|
||||||
|
server.close()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Connection error:', error)
|
||||||
|
server.close()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request tools list
|
||||||
|
console.log('Requesting tools list...')
|
||||||
|
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||||
|
console.log('Tools:', JSON.stringify(tools, null, 2))
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error requesting tools list:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request resources list
|
||||||
|
console.log('Requesting resource list...')
|
||||||
|
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||||
|
console.log('Resources:', JSON.stringify(resources, null, 2))
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error requesting resources list:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Listening for messages. Press Ctrl+C to exit.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command-line arguments and run the client
|
||||||
|
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port]')
|
||||||
|
.then(({ serverUrl, callbackPort }) => {
|
||||||
|
return runClient(serverUrl, callbackPort)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Fatal error:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
85
src/proxy.ts
Normal file
85
src/proxy.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Proxy with OAuth support
|
||||||
|
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication.
|
||||||
|
*
|
||||||
|
* Run with: npx tsx proxy.ts https://example.remote/server [callback-port]
|
||||||
|
*
|
||||||
|
* If callback-port is not specified, an available port will be automatically selected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||||
|
import {
|
||||||
|
NodeOAuthClientProvider,
|
||||||
|
setupOAuthCallbackServer,
|
||||||
|
connectToRemoteServer,
|
||||||
|
mcpProxy,
|
||||||
|
parseCommandLineArgs,
|
||||||
|
setupSignalHandlers,
|
||||||
|
} from './shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to run the proxy
|
||||||
|
*/
|
||||||
|
async function runProxy(serverUrl: string, callbackPort: number) {
|
||||||
|
// Set up event emitter for auth flow
|
||||||
|
const events = new EventEmitter()
|
||||||
|
|
||||||
|
// Create the OAuth client provider
|
||||||
|
const authProvider = new NodeOAuthClientProvider({
|
||||||
|
serverUrl,
|
||||||
|
callbackPort,
|
||||||
|
clientName: 'MCP CLI Proxy',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the STDIO transport for local connections
|
||||||
|
const localTransport = new StdioServerTransport()
|
||||||
|
|
||||||
|
// Set up an HTTP server to handle OAuth callback
|
||||||
|
const { server, waitForAuthCode } = setupOAuthCallbackServer({
|
||||||
|
port: callbackPort,
|
||||||
|
path: '/oauth/callback',
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect to remote server with authentication
|
||||||
|
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode)
|
||||||
|
|
||||||
|
// Set up bidirectional proxy between local and remote transports
|
||||||
|
mcpProxy({
|
||||||
|
transportToClient: localTransport,
|
||||||
|
transportToServer: remoteTransport,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start the local STDIO server
|
||||||
|
await localTransport.start()
|
||||||
|
console.error('Local STDIO server running')
|
||||||
|
console.error('Proxy established successfully between local STDIO and remote SSE')
|
||||||
|
console.error('Press Ctrl+C to exit')
|
||||||
|
|
||||||
|
// Setup cleanup handler
|
||||||
|
const cleanup = async () => {
|
||||||
|
await remoteTransport.close()
|
||||||
|
await localTransport.close()
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
setupSignalHandlers(cleanup)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fatal error:', error)
|
||||||
|
server.close()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command-line arguments and run the proxy
|
||||||
|
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
|
||||||
|
.then(({ serverUrl, callbackPort }) => {
|
||||||
|
return runProxy(serverUrl, callbackPort)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Fatal error:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
460
src/shared.ts
Normal file
460
src/shared.ts
Normal file
|
@ -0,0 +1,460 @@
|
||||||
|
/**
|
||||||
|
* Shared utilities for MCP OAuth clients and proxies.
|
||||||
|
* Contains common functionality for authentication, file storage, and proxying.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express'
|
||||||
|
import open from 'open'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import net from 'net'
|
||||||
|
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
|
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||||
|
import {
|
||||||
|
OAuthClientInformation,
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthClientInformationSchema,
|
||||||
|
OAuthTokens,
|
||||||
|
OAuthTokensSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating an OAuth client provider
|
||||||
|
*/
|
||||||
|
export interface OAuthProviderOptions {
|
||||||
|
/** Server URL to connect to */
|
||||||
|
serverUrl: string
|
||||||
|
/** Port for the OAuth callback server */
|
||||||
|
callbackPort: number
|
||||||
|
/** Path for the OAuth callback endpoint */
|
||||||
|
callbackPath?: string
|
||||||
|
/** Directory to store OAuth credentials */
|
||||||
|
configDir?: string
|
||||||
|
/** Client name to use for OAuth registration */
|
||||||
|
clientName?: string
|
||||||
|
/** Client URI to use for OAuth registration */
|
||||||
|
clientUri?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the OAuthClientProvider interface for Node.js environments.
|
||||||
|
* Handles OAuth flow and token storage for MCP clients.
|
||||||
|
*/
|
||||||
|
export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||||
|
private configDir: string
|
||||||
|
private serverUrlHash: string
|
||||||
|
private callbackPath: string
|
||||||
|
private clientName: string
|
||||||
|
private clientUri: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new NodeOAuthClientProvider
|
||||||
|
* @param options Configuration options for the provider
|
||||||
|
*/
|
||||||
|
constructor(readonly options: OAuthProviderOptions) {
|
||||||
|
this.serverUrlHash = crypto.createHash('md5').update(options.serverUrl).digest('hex')
|
||||||
|
this.configDir = options.configDir || path.join(os.homedir(), '.mcp-auth')
|
||||||
|
this.callbackPath = options.callbackPath || '/oauth/callback'
|
||||||
|
this.clientName = options.clientName || 'MCP CLI Client'
|
||||||
|
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
|
||||||
|
}
|
||||||
|
|
||||||
|
get redirectUrl(): string {
|
||||||
|
return `http://localhost:${this.options.callbackPort}${this.callbackPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the configuration directory exists
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async ensureConfigDir() {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(this.configDir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating config directory:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a JSON file and parses it with the provided schema
|
||||||
|
* @param filename The name of the file to read
|
||||||
|
* @param schema The schema to validate against
|
||||||
|
* @returns The parsed file content or undefined if the file doesn't exist
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async readFile<T>(filename: string, schema: any): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
await this.ensureConfigDir()
|
||||||
|
const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`)
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
|
return await schema.parseAsync(JSON.parse(content))
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a JSON object to a file
|
||||||
|
* @param filename The name of the file to write
|
||||||
|
* @param data The data to write
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async writeFile(filename: string, data: any) {
|
||||||
|
try {
|
||||||
|
await this.ensureConfigDir()
|
||||||
|
const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`)
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error writing ${filename}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a text string to a file
|
||||||
|
* @param filename The name of the file to write
|
||||||
|
* @param text The text to write
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async writeTextFile(filename: string, text: string) {
|
||||||
|
try {
|
||||||
|
await this.ensureConfigDir()
|
||||||
|
const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`)
|
||||||
|
await fs.writeFile(filePath, text, 'utf-8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error writing ${filename}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads text from a file
|
||||||
|
* @param filename The name of the file to read
|
||||||
|
* @returns The file content as a string
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async readTextFile(filename: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
await this.ensureConfigDir()
|
||||||
|
const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`)
|
||||||
|
return await fs.readFile(filePath, 'utf-8')
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('No code verifier saved for session')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the client information if it exists
|
||||||
|
* @returns The client information or undefined
|
||||||
|
*/
|
||||||
|
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||||
|
return this.readFile<OAuthClientInformation>('client_info.json', OAuthClientInformationSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves client information
|
||||||
|
* @param clientInformation The client information to save
|
||||||
|
*/
|
||||||
|
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
|
||||||
|
await this.writeFile('client_info.json', clientInformation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the OAuth tokens if they exist
|
||||||
|
* @returns The OAuth tokens or undefined
|
||||||
|
*/
|
||||||
|
async tokens(): Promise<OAuthTokens | undefined> {
|
||||||
|
return this.readFile<OAuthTokens>('tokens.json', OAuthTokensSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves OAuth tokens
|
||||||
|
* @param tokens The tokens to save
|
||||||
|
*/
|
||||||
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
|
await this.writeFile('tokens.json', tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects the user to the authorization URL
|
||||||
|
* @param authorizationUrl The URL to redirect to
|
||||||
|
*/
|
||||||
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||||
|
console.error(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`)
|
||||||
|
try {
|
||||||
|
await open(authorizationUrl.toString())
|
||||||
|
console.error('Browser opened automatically.')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not open browser automatically. Please copy and paste the URL above into your browser.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the PKCE code verifier
|
||||||
|
* @param codeVerifier The code verifier to save
|
||||||
|
*/
|
||||||
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||||
|
await this.writeTextFile('code_verifier.txt', codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the PKCE code verifier
|
||||||
|
* @returns The code verifier
|
||||||
|
*/
|
||||||
|
async codeVerifier(): Promise<string> {
|
||||||
|
return await this.readTextFile('code_verifier.txt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth callback server setup options
|
||||||
|
*/
|
||||||
|
export interface OAuthCallbackServerOptions {
|
||||||
|
/** Port for the callback server */
|
||||||
|
port: number
|
||||||
|
/** Path for the callback endpoint */
|
||||||
|
path: string
|
||||||
|
/** Event emitter to signal when auth code is received */
|
||||||
|
events: EventEmitter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up an Express server to handle OAuth callbacks
|
||||||
|
* @param options The server options
|
||||||
|
* @returns An object with the server, authCode, and waitForAuthCode function
|
||||||
|
*/
|
||||||
|
export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) {
|
||||||
|
let authCode: string | null = null
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.get(options.path, (req, res) => {
|
||||||
|
const code = req.query.code as string | undefined
|
||||||
|
if (!code) {
|
||||||
|
res.status(400).send('Error: No authorization code received')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authCode = code
|
||||||
|
res.send('Authorization successful! You may close this window and return to the CLI.')
|
||||||
|
|
||||||
|
// Notify main flow that auth code is available
|
||||||
|
options.events.emit('auth-code-received', code)
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = app.listen(options.port, () => {
|
||||||
|
console.error(`OAuth callback server running at http://localhost:${options.port}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for the OAuth authorization code
|
||||||
|
* @returns A promise that resolves with the authorization code
|
||||||
|
*/
|
||||||
|
const waitForAuthCode = (): Promise<string> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (authCode) {
|
||||||
|
resolve(authCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.events.once('auth-code-received', (code) => {
|
||||||
|
resolve(code)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* @param preferredPort Optional preferred port to try first
|
||||||
|
* @returns A promise that resolves to an available port number
|
||||||
|
*/
|
||||||
|
export async function findAvailablePort(preferredPort?: number): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
|
||||||
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
// If preferred port is in use, get a random port
|
||||||
|
server.listen(0)
|
||||||
|
} else {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('listening', () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo
|
||||||
|
server.close(() => {
|
||||||
|
resolve(port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try preferred port first, or get a random port
|
||||||
|
server.listen(preferredPort || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses command line arguments for MCP clients and proxies
|
||||||
|
* @param args Command line arguments
|
||||||
|
* @param defaultPort Default port for the callback server if specified port is unavailable
|
||||||
|
* @param usage Usage message to show on error
|
||||||
|
* @returns A promise that resolves to an object with parsed serverUrl and callbackPort
|
||||||
|
*/
|
||||||
|
export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) {
|
||||||
|
const serverUrl = args[0]
|
||||||
|
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
|
||||||
|
|
||||||
|
if (!serverUrl) {
|
||||||
|
console.error(usage)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(serverUrl)
|
||||||
|
const isLocalhost = url.hostname === 'localhost' && url.protocol === 'http:'
|
||||||
|
|
||||||
|
if (!(url.protocol == 'https:' || isLocalhost)) {
|
||||||
|
console.error(usage)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the specified port, or find an available one
|
||||||
|
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
|
||||||
|
|
||||||
|
if (specifiedPort) {
|
||||||
|
console.error(`Using specified callback port: ${callbackPort}`)
|
||||||
|
} else {
|
||||||
|
console.error(`Using automatically selected callback port: ${callbackPort}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { serverUrl, callbackPort }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up signal handlers for graceful shutdown
|
||||||
|
* @param cleanup Cleanup function to run on shutdown
|
||||||
|
*/
|
||||||
|
export function setupSignalHandlers(cleanup: () => Promise<void>) {
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.error('\nShutting down...')
|
||||||
|
await cleanup()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep the process alive
|
||||||
|
process.stdin.resume()
|
||||||
|
}
|
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["*.ts","src/**/*"],
|
||||||
|
"exclude": ["node_modules", "packages", "**/*.spec.ts"]
|
||||||
|
}
|
10
tsup.config.ts
Normal file
10
tsup.config.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'tsup'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/client.ts', 'src/proxy.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
outDir: 'dist',
|
||||||
|
// external: ['typescript'],
|
||||||
|
})
|
Loading…
Add table
Add a link
Reference in a new issue