Merge branch 'main' into patch-1

This commit is contained in:
Jeremy Morrell (Cloudflare) 2025-03-31 07:57:58 -07:00 committed by GitHub
commit 7ddf9cba25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 982 additions and 1727 deletions

144
README.md
View file

@ -16,17 +16,7 @@ That's where `mcp-remote` comes in. As soon as your chosen MCP client supports r
## Usage ## Usage
### Claude Desktop All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the following config format:
[Official Docs](https://modelcontextprotocol.io/quickstart/user)
In order to add an MCP server to Claude Desktop you need to edit the configuration file located at:
macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
Windows: `%APPDATA%\Claude\claude_desktop_config.json`
If it does not exist yet, [you may need to enable it under Settings > Developer](https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server).
```json ```json
{ {
@ -34,7 +24,6 @@ If it does not exist yet, [you may need to enable it under Settings > Developer]
"remote-example": { "remote-example": {
"command": "npx", "command": "npx",
"args": [ "args": [
"-y",
"mcp-remote", "mcp-remote",
"https://remote.mcp.server/sse" "https://remote.mcp.server/sse"
] ]
@ -43,51 +32,72 @@ If it does not exist yet, [you may need to enable it under Settings > Developer]
} }
``` ```
### Flags
* If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package.
```json
"command": "npx",
"args": [
"-y"
"mcp-remote",
"https://remote.mcp.server/sse"
]
```
* To force `npx` to always check for an updated version of `mcp-remote`, add the `@latest` flag:
```json
"args": [
"mcp-remote@latest",
"https://remote.mcp.server/sse"
]
```
* To force `mcp-remote` to ignore any existing access tokens and begin the authorization flow anew, pass `--clean`.
```json
"args": [
"mcp-remote",
"https://remote.mcp.server/sse",
"--clean"
]
```
* To change which port `mcp-remote` listens for an OAuth redirect (by default `3334`), add an additional argument after the server URL. Note that whatever port you specify, if it is unavailable an open port will be chosen at random.
```json
"args": [
"mcp-remote",
"https://remote.mcp.server/sse",
"9696"
]
```
### Claude Desktop
[Official Docs](https://modelcontextprotocol.io/quickstart/user)
In order to add an MCP server to Claude Desktop you need to edit the configuration file located at:
* macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
* Windows: `%APPDATA%\Claude\claude_desktop_config.json`
If it does not exist yet, [you may need to enable it under Settings > Developer](https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server).
Restart Claude Desktop to pick up the changes in the configuration file. Restart Claude Desktop to pick up the changes in the configuration file.
Upon restarting, you should see a hammer icon in the bottom right corner Upon restarting, you should see a hammer icon in the bottom right corner
of the input box. of the input box.
### Cursor ### Cursor
[Official Docs](https://docs.cursor.com/context/model-context-protocol) [Official Docs](https://docs.cursor.com/context/model-context-protocol). The configuration file is located at `~/.cursor/mcp.json`.
Add the following configuration to `~/.cursor/mcp.json`: As of version `0.48.0`, Cursor supports unauthed SSE servers directly. If your MCP server is using the official MCP OAuth authorization protocol, you still need to add a **"command"** server and call `mcp-remote`.
```json
{
"mcpServers": {
"remote-example": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://remote.mcp.server/sse"
]
}
}
}
```
### Windsurf ### Windsurf
[Official Docs](https://docs.codeium.com/windsurf/mcp) [Official Docs](https://docs.codeium.com/windsurf/mcp). The configuration file is located at `~/.codeium/windsurf/mcp_config.json`.
Add the following configuration to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"remote-example": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://remote.mcp.server/sse"
]
}
}
}
```
## Building Remote MCP Servers ## Building Remote MCP Servers
@ -106,7 +116,17 @@ For more information about testing these servers, see also:
Know of more resources you'd like to share? Please add them to this Readme and send a PR! Know of more resources you'd like to share? Please add them to this Readme and send a PR!
## Debugging ## Troubleshooting
### Clear your `~/.mcp-auth` directory
`mcp-remote` stores all the credential information inside `~/.mcp-auth` (or wherever your `MCP_REMOTE_CONFIG_DIR` points to). If you're having persistent issues, try running:
```sh
rm -rf ~/.mcp-auth
```
Then restarting your MCP client.
### Check your Node version ### Check your Node version
@ -144,20 +164,10 @@ this might look like:
### Check the logs ### Check the logs
[Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop) * [Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop)
* MacOS / Linux:<br/>`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log`
MacOS / Linux: * For bash on WSL:<br/>`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"`
* Powershell: <br/>`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20`
`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log`
For bash on WSL:
`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"`
or Powershell:
`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20`
## Debugging ## Debugging
@ -169,3 +179,13 @@ Token exchange failed: HTTP 400
``` ```
You can run `rm -rf ~/.mcp-auth` to clear any locally stored state and tokens. You can run `rm -rf ~/.mcp-auth` to clear any locally stored state and tokens.
### "Client" mode
Run the following on the command line (not from an MCP server):
```shell
npx -p mcp-remote@latest mcp-remote-client https://remote.mcp.server/sse
```
This will run through the entire authorization flow and attempt to list the tools & resources at the remote URL. Pair this with `--clean` or after running `rm -rf ~/.mcp-auth` to see if stale credentials are your problem, otherwise hopefully the issue will be more obvious in these logs than those in your MCP client.

View file

@ -1,21 +1,26 @@
{ {
"name": "mcp-remote", "name": "mcp-remote",
"version": "0.0.10", "version": "0.0.15",
"description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth",
"keywords": [
"mcp",
"stdio",
"sse",
"remote",
"oauth"
],
"author": "Glen Maddern <glen@cloudflare.com>",
"repository": "https://github.com/geelen/mcp-remote",
"type": "module", "type": "module",
"bin": {
"mcp-remote": "dist/cli/proxy.js"
},
"files": [ "files": [
"dist", "dist",
"README.md", "README.md",
"LICENSE" "LICENSE"
], ],
"exports": { "main": "dist/index.js",
"./react": { "bin": {
"types": "./dist/react/index.d.ts", "mcp-remote": "dist/proxy.js",
"require": "./dist/react/index.js", "mcp-remote-client": "dist/client.js"
"import": "./dist/react/index.js"
}
}, },
"scripts": { "scripts": {
"dev": "tsup --watch", "dev": "tsup --watch",
@ -39,9 +44,8 @@
}, },
"tsup": { "tsup": {
"entry": [ "entry": [
"src/cli/client.ts", "src/client.ts",
"src/cli/proxy.ts", "src/proxy.ts"
"src/react/index.ts"
], ],
"format": [ "format": [
"esm" "esm"

View file

@ -1,334 +0,0 @@
/**
* 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 net from 'net'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientInformationSchema,
OAuthTokens,
OAuthTokensSchema,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import { OAuthCallbackServerOptions, OAuthProviderOptions } from '../lib/types.js'
/**
* 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://127.0.0.1:${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')
}
}
/**
* 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://127.0.0.1:${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 }
}
/**
* 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.hostname === '127.0.0.1') && 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()
}

View file

@ -4,7 +4,10 @@
* MCP Client with OAuth support * MCP Client with OAuth support
* A command-line client that connects to an MCP server using SSE with OAuth authentication. * 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] * Run with: npx tsx client.ts [--clean] https://example.remote/server [callback-port]
*
* Options:
* --clean: Deletes stored configuration before reading, ensuring a fresh session
* *
* If callback-port is not specified, an available port will be automatically selected. * If callback-port is not specified, an available port will be automatically selected.
*/ */
@ -14,32 +17,47 @@ 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.js' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils'
import { coordinateAuth } from './lib/coordination'
/** /**
* Main function to run the client * Main function to run the client
*/ */
async function runClient(serverUrl: string, callbackPort: number) { async function runClient(serverUrl: string, callbackPort: number, clean: boolean = false) {
// Set up event emitter for auth flow // Set up event emitter for auth flow
const events = new EventEmitter() const events = new EventEmitter()
// Get the server URL hash for lockfile operations
const serverUrlHash = getServerUrlHash(serverUrl)
// Coordinate authentication with other instances
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
// Create the OAuth client provider // Create the OAuth client provider
const authProvider = new NodeOAuthClientProvider({ const authProvider = new NodeOAuthClientProvider({
serverUrl, serverUrl,
callbackPort, callbackPort,
clientName: 'MCP CLI Client', clientName: 'MCP CLI Client',
clean,
}) })
// If auth was completed by another instance, just log that we'll use the auth from disk
if (skipBrowserAuth) {
log('Authentication was completed by another instance - will use tokens from disk...')
// TODO: remove, the callback is happening before the tokens are exchanged
// so we're slightly too early
await new Promise((res) => setTimeout(res, 1_000))
}
// Create the client // Create the client
const client = new Client( const client = new Client(
{ {
name: 'mcp-cli', name: 'mcp-remote',
version: '0.1.0', version: MCP_REMOTE_VERSION,
}, },
{ {
capabilities: { capabilities: {},
sampling: {},
},
}, },
) )
@ -50,15 +68,15 @@ async function runClient(serverUrl: string, callbackPort: number) {
// Set up message and error handlers // Set up message and error handlers
transport.onmessage = (message) => { transport.onmessage = (message) => {
console.log('Received message:', JSON.stringify(message, null, 2)) log('Received message:', JSON.stringify(message, null, 2))
} }
transport.onerror = (error) => { transport.onerror = (error) => {
console.error('Transport error:', error) log('Transport error:', error)
} }
transport.onclose = () => { transport.onclose = () => {
console.log('Connection closed.') log('Connection closed.')
process.exit(0) process.exit(0)
} }
return transport return transport
@ -66,16 +84,9 @@ async function runClient(serverUrl: string, callbackPort: number) {
const transport = initTransport() 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 // Set up cleanup handler
const cleanup = async () => { const cleanup = async () => {
console.log('\nClosing connection...') log('\nClosing connection...')
await client.close() await client.close()
server.close() server.close()
} }
@ -83,44 +94,44 @@ async function runClient(serverUrl: string, callbackPort: number) {
// Try to connect // Try to connect
try { try {
console.log('Connecting to server...') log('Connecting to server...')
await client.connect(transport) await client.connect(transport)
console.log('Connected successfully!') log('Connected successfully!')
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
console.log('Authentication required. Waiting for authorization...') log('Authentication required. Waiting for authorization...')
// Wait for the authorization code from the callback // Wait for the authorization code from the callback or another instance
const code = await waitForAuthCode() const code = await waitForAuthCode()
try { try {
console.log('Completing authorization...') log('Completing authorization...')
await transport.finishAuth(code) await transport.finishAuth(code)
// Reconnect after authorization with a new transport // Reconnect after authorization with a new transport
console.log('Connecting after authorization...') log('Connecting after authorization...')
await client.connect(initTransport()) await client.connect(initTransport())
console.log('Connected successfully!') log('Connected successfully!')
// Request tools list after auth // Request tools list after auth
console.log('Requesting tools list...') log('Requesting tools list...')
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
console.log('Tools:', JSON.stringify(tools, null, 2)) log('Tools:', JSON.stringify(tools, null, 2))
// Request resources list after auth // Request resources list after auth
console.log('Requesting resource list...') log('Requesting resource list...')
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
console.log('Resources:', JSON.stringify(resources, null, 2)) log('Resources:', JSON.stringify(resources, null, 2))
console.log('Listening for messages. Press Ctrl+C to exit.') log('Listening for messages. Press Ctrl+C to exit.')
} catch (authError) { } catch (authError) {
console.error('Authorization error:', authError) log('Authorization error:', authError)
server.close() server.close()
process.exit(1) process.exit(1)
} }
} else { } else {
console.error('Connection error:', error) log('Connection error:', error)
server.close() server.close()
process.exit(1) process.exit(1)
} }
@ -128,29 +139,29 @@ async function runClient(serverUrl: string, callbackPort: number) {
try { try {
// Request tools list // Request tools list
console.log('Requesting tools list...') log('Requesting tools list...')
const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema)
console.log('Tools:', JSON.stringify(tools, null, 2)) log('Tools:', JSON.stringify(tools, null, 2))
} catch (e) { } catch (e) {
console.log('Error requesting tools list:', e) log('Error requesting tools list:', e)
} }
try { try {
// Request resources list // Request resources list
console.log('Requesting resource list...') log('Requesting resource list...')
const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema)
console.log('Resources:', JSON.stringify(resources, null, 2)) log('Resources:', JSON.stringify(resources, null, 2))
} catch (e) { } catch (e) {
console.log('Error requesting resources list:', e) log('Error requesting resources list:', e)
} }
console.log('Listening for messages. Press Ctrl+C to exit.') log('Listening for messages. Press Ctrl+C to exit.')
} }
// Parse command-line arguments and run the client // Parse command-line arguments and run the client
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port]') parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [--clean] <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort }) => { .then(({ serverUrl, callbackPort, clean }) => {
return runClient(serverUrl, callbackPort) return runClient(serverUrl, callbackPort, clean)
}) })
.catch((error) => { .catch((error) => {
console.error('Fatal error:', error) console.error('Fatal error:', error)

188
src/lib/coordination.ts Normal file
View file

@ -0,0 +1,188 @@
import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, LockfileData } from './mcp-auth-config'
import { EventEmitter } from 'events'
import { Server } from 'http'
import express from 'express'
import { AddressInfo } from 'net'
import { log, setupOAuthCallbackServerWithLongPoll } from './utils'
/**
* Checks if a process with the given PID is running
* @param pid The process ID to check
* @returns True if the process is running, false otherwise
*/
export async function isPidRunning(pid: number): Promise<boolean> {
try {
process.kill(pid, 0) // Doesn't kill the process, just checks if it exists
return true
} catch {
return false
}
}
/**
* Checks if a lockfile is valid (process running and endpoint accessible)
* @param lockData The lockfile data
* @returns True if the lockfile is valid, false otherwise
*/
export async function isLockValid(lockData: LockfileData): Promise<boolean> {
// Check if the lockfile is too old (over 30 minutes)
const MAX_LOCK_AGE = 30 * 60 * 1000 // 30 minutes
if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) {
log('Lockfile is too old')
return false
}
// Check if the process is still running
if (!(await isPidRunning(lockData.pid))) {
log('Process from lockfile is not running')
return false
}
// Check if the endpoint is accessible
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 1000)
const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, {
signal: controller.signal,
})
clearTimeout(timeout)
return response.status === 200 || response.status === 202
} catch (error) {
log(`Error connecting to auth server: ${(error as Error).message}`)
return false
}
}
/**
* Waits for authentication from another server instance
* @param port The port to connect to
* @returns True if authentication completed successfully, false otherwise
*/
export async function waitForAuthentication(port: number): Promise<boolean> {
log(`Waiting for authentication from the server on port ${port}...`)
try {
while (true) {
const url = `http://127.0.0.1:${port}/wait-for-auth`
log(`Querying: ${url}`)
const response = await fetch(url)
if (response.status === 200) {
// Auth completed, but we don't return the code anymore
log(`Authentication completed by other instance`)
return true
} else if (response.status === 202) {
// Continue polling
log(`Authentication still in progress`)
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
log(`Unexpected response status: ${response.status}`)
return false
}
}
} catch (error) {
log(`Error waiting for authentication: ${(error as Error).message}`)
return false
}
}
/**
* Coordinates authentication between multiple instances of the client/proxy
* @param serverUrlHash The hash of the server URL
* @param callbackPort The port to use for the callback server
* @param events The event emitter to use for signaling
* @returns An object with the server, waitForAuthCode function, and a flag indicating if browser auth can be skipped
*/
export async function coordinateAuth(
serverUrlHash: string,
callbackPort: number,
events: EventEmitter,
): Promise<{ server: Server; waitForAuthCode: () => Promise<string>; skipBrowserAuth: boolean }> {
// Check for a lockfile
const lockData = await checkLockfile(serverUrlHash)
// If there's a valid lockfile, try to use the existing auth process
if (lockData && (await isLockValid(lockData))) {
log(`Another instance is handling authentication on port ${lockData.port}`)
try {
// Try to wait for the authentication to complete
const authCompleted = await waitForAuthentication(lockData.port)
if (authCompleted) {
log('Authentication completed by another instance')
// Setup a dummy server - the client will use tokens directly from disk
const dummyServer = express().listen(0) // Listen on any available port
// This shouldn't actually be called in normal operation, but provide it for API compatibility
const dummyWaitForAuthCode = () => {
log('WARNING: waitForAuthCode called in secondary instance - this is unexpected')
// Return a promise that never resolves - the client should use the tokens from disk instead
return new Promise<string>(() => {})
}
return {
server: dummyServer,
waitForAuthCode: dummyWaitForAuthCode,
skipBrowserAuth: true,
}
} else {
log('Taking over authentication process...')
}
} catch (error) {
log(`Error waiting for authentication: ${error}`)
}
// If we get here, the other process didn't complete auth successfully
await deleteLockfile(serverUrlHash)
} else if (lockData) {
// Invalid lockfile, delete its
log('Found invalid lockfile, deleting it')
await deleteLockfile(serverUrlHash)
}
// Create our own lockfile
const { server, waitForAuthCode, authCompletedPromise } = setupOAuthCallbackServerWithLongPoll({
port: callbackPort,
path: '/oauth/callback',
events,
})
// Get the actual port the server is running on
const address = server.address() as AddressInfo
const actualPort = address.port
log(`Creating lockfile for server ${serverUrlHash} with process ${process.pid} on port ${actualPort}`)
await createLockfile(serverUrlHash, process.pid, actualPort)
// Make sure lockfile is deleted on process exit
const cleanupHandler = async () => {
try {
log(`Cleaning up lockfile for server ${serverUrlHash}`)
await deleteLockfile(serverUrlHash)
} catch (error) {
log(`Error cleaning up lockfile: ${error}`)
}
}
process.once('exit', () => {
try {
// Synchronous version for 'exit' event since we can't use async here
const configPath = getConfigFilePath(serverUrlHash, 'lock.json')
require('fs').unlinkSync(configPath)
} catch {}
})
// Also handle SIGINT separately
process.once('SIGINT', async () => {
await cleanupHandler()
})
return {
server,
waitForAuthCode,
skipBrowserAuth: false
}
}

247
src/lib/mcp-auth-config.ts Normal file
View file

@ -0,0 +1,247 @@
import path from 'path'
import os from 'os'
import fs from 'fs/promises'
import { log, MCP_REMOTE_VERSION } from './utils'
/**
* MCP Remote Authentication Configuration
*
* This module handles the storage and retrieval of authentication-related data for MCP Remote.
*
* Configuration directory structure:
* - The config directory is determined by MCP_REMOTE_CONFIG_DIR env var or defaults to ~/.mcp-auth
* - Each file is prefixed with a hash of the server URL to separate configurations for different servers
*
* Files stored in the config directory:
* - {server_hash}_client_info.json: Contains OAuth client registration information
* - Format: OAuthClientInformation object with client_id and other registration details
* - {server_hash}_tokens.json: Contains OAuth access and refresh tokens
* - Format: OAuthTokens object with access_token, refresh_token, and expiration information
* - {server_hash}_code_verifier.txt: Contains the PKCE code verifier for the current OAuth flow
* - Format: Plain text string used for PKCE verification
*
* All JSON files are stored with 2-space indentation for readability.
*/
/**
* Known configuration file names that might need to be cleaned
*/
export const knownConfigFiles = ['client_info.json', 'tokens.json', 'code_verifier.txt', 'lock.json']
/**
* Lockfile data structure
*/
export interface LockfileData {
pid: number
port: number
timestamp: number
}
/**
* Creates a lockfile for the given server
* @param serverUrlHash The hash of the server URL
* @param pid The process ID
* @param port The port the server is running on
*/
export async function createLockfile(serverUrlHash: string, pid: number, port: number): Promise<void> {
const lockData: LockfileData = {
pid,
port,
timestamp: Date.now(),
}
await writeJsonFile(serverUrlHash, 'lock.json', lockData)
}
/**
* Checks if a lockfile exists for the given server
* @param serverUrlHash The hash of the server URL
* @returns The lockfile data or null if it doesn't exist
*/
export async function checkLockfile(serverUrlHash: string): Promise<LockfileData | null> {
try {
const lockfile = await readJsonFile<LockfileData>(serverUrlHash, 'lock.json', {
async parseAsync(data: any) {
if (typeof data !== 'object' || data === null) return null
if (typeof data.pid !== 'number' || typeof data.port !== 'number' || typeof data.timestamp !== 'number') {
return null
}
return data as LockfileData
},
})
return lockfile || null
} catch {
return null
}
}
/**
* Deletes the lockfile for the given server
* @param serverUrlHash The hash of the server URL
*/
export async function deleteLockfile(serverUrlHash: string): Promise<void> {
await deleteConfigFile(serverUrlHash, 'lock.json')
}
/**
* Deletes all known configuration files for a specific server
* @param serverUrlHash The hash of the server URL
*/
export async function cleanServerConfig(serverUrlHash: string): Promise<void> {
log(`Cleaning configuration files for server: ${serverUrlHash}`)
for (const filename of knownConfigFiles) {
await deleteConfigFile(serverUrlHash, filename)
}
}
/**
* Gets the configuration directory path
* @returns The path to the configuration directory
*/
export function getConfigDir(): string {
const baseConfigDir = process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.mcp-auth')
// Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet
return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`)
}
/**
* Ensures the configuration directory exists
*/
export async function ensureConfigDir(): Promise<void> {
try {
const configDir = getConfigDir()
await fs.mkdir(configDir, { recursive: true })
} catch (error) {
log('Error creating config directory:', error)
throw error
}
}
/**
* Gets the file path for a config file
* @param serverUrlHash The hash of the server URL
* @param filename The name of the file
* @returns The absolute file path
*/
export function getConfigFilePath(serverUrlHash: string, filename: string): string {
const configDir = getConfigDir()
return path.join(configDir, `${serverUrlHash}_${filename}`)
}
/**
* Deletes a config file if it exists
* @param serverUrlHash The hash of the server URL
* @param filename The name of the file to delete
*/
export async function deleteConfigFile(serverUrlHash: string, filename: string): Promise<void> {
try {
const filePath = getConfigFilePath(serverUrlHash, filename)
await fs.unlink(filePath)
} catch (error) {
// Ignore if file doesn't exist
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
log(`Error deleting ${filename}:`, error)
}
}
}
/**
* Reads a JSON file and parses it with the provided schema
* @param serverUrlHash The hash of the server URL
* @param filename The name of the file to read
* @param schema The schema to validate against
* @param clean Whether to clean (delete) before reading
* @returns The parsed file content or undefined if the file doesn't exist
*/
export async function readJsonFile<T>(
serverUrlHash: string,
filename: string,
schema: any,
clean: boolean = false,
): Promise<T | undefined> {
try {
await ensureConfigDir()
// If clean flag is set, delete the file before trying to read it
if (clean) {
await deleteConfigFile(serverUrlHash, filename)
return undefined
}
const filePath = getConfigFilePath(serverUrlHash, filename)
const content = await fs.readFile(filePath, 'utf-8')
const result = await schema.parseAsync(JSON.parse(content))
// console.log({ filename: result })
return result
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// console.log(`File ${filename} does not exist`)
return undefined
}
log(`Error reading ${filename}:`, error)
return undefined
}
}
/**
* Writes a JSON object to a file
* @param serverUrlHash The hash of the server URL
* @param filename The name of the file to write
* @param data The data to write
*/
export async function writeJsonFile(serverUrlHash: string, filename: string, data: any): Promise<void> {
try {
await ensureConfigDir()
const filePath = getConfigFilePath(serverUrlHash, filename)
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
} catch (error) {
log(`Error writing ${filename}:`, error)
throw error
}
}
/**
* Reads a text file
* @param serverUrlHash The hash of the server URL
* @param filename The name of the file to read
* @param errorMessage Optional custom error message
* @param clean Whether to clean (delete) before reading
* @returns The file content as a string
*/
export async function readTextFile(
serverUrlHash: string,
filename: string,
errorMessage?: string,
clean: boolean = false,
): Promise<string> {
try {
await ensureConfigDir()
// If clean flag is set, delete the file before trying to read it
if (clean) {
await deleteConfigFile(serverUrlHash, filename)
throw new Error('File deleted due to clean flag')
}
const filePath = getConfigFilePath(serverUrlHash, filename)
return await fs.readFile(filePath, 'utf-8')
} catch (error) {
throw new Error(errorMessage || `Error reading ${filename}`)
}
}
/**
* Writes a text string to a file
* @param serverUrlHash The hash of the server URL
* @param filename The name of the file to write
* @param text The text to write
*/
export async function writeTextFile(serverUrlHash: string, filename: string, text: string): Promise<void> {
try {
await ensureConfigDir()
const filePath = getConfigFilePath(serverUrlHash, filename)
await fs.writeFile(filePath, text, 'utf-8')
} catch (error) {
log(`Error writing ${filename}:`, error)
throw error
}
}

View file

@ -0,0 +1,125 @@
import open from 'open'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientInformationSchema,
OAuthTokens,
OAuthTokensSchema,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import type { OAuthProviderOptions } from './types'
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config'
import { getServerUrlHash, log } from './utils'
/**
* Implements the OAuthClientProvider interface for Node.js environments.
* Handles OAuth flow and token storage for MCP clients.
*/
export class NodeOAuthClientProvider implements OAuthClientProvider {
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 = getServerUrlHash(options.serverUrl)
this.callbackPath = options.callbackPath || '/oauth/callback'
this.clientName = options.clientName || 'MCP CLI Client'
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
// If clean flag is set, proactively clean all config files for this server
if (options.clean) {
cleanServerConfig(this.serverUrlHash).catch((err) => {
log('Error cleaning server config:', err)
})
}
}
get redirectUrl(): string {
return `http://127.0.0.1:${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,
}
}
/**
* Gets the client information if it exists
* @returns The client information or undefined
*/
async clientInformation(): Promise<OAuthClientInformation | undefined> {
// log('Reading client info')
return readJsonFile<OAuthClientInformation>(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema, this.options.clean)
}
/**
* Saves client information
* @param clientInformation The client information to save
*/
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
// log('Saving client info')
await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation)
}
/**
* Gets the OAuth tokens if they exist
* @returns The OAuth tokens or undefined
*/
async tokens(): Promise<OAuthTokens | undefined> {
// log('Reading tokens')
// console.log(new Error().stack)
return readJsonFile<OAuthTokens>(this.serverUrlHash, 'tokens.json', OAuthTokensSchema, this.options.clean)
}
/**
* Saves OAuth tokens
* @param tokens The tokens to save
*/
async saveTokens(tokens: OAuthTokens): Promise<void> {
// log('Saving tokens')
await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens)
}
/**
* Redirects the user to the authorization URL
* @param authorizationUrl The URL to redirect to
*/
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
log(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`)
try {
await open(authorizationUrl.toString())
log('Browser opened automatically.')
} catch (error) {
log('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> {
// log('Saving code verifier')
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier)
}
/**
* Gets the PKCE code verifier
* @returns The code verifier
*/
async codeVerifier(): Promise<string> {
// log('Reading code verifier')
return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session', this.options.clean)
}
}

View file

@ -16,6 +16,8 @@ export interface OAuthProviderOptions {
clientName?: string clientName?: string
/** Client URI to use for OAuth registration */ /** Client URI to use for OAuth registration */
clientUri?: string clientUri?: string
/** Whether to clean stored configuration before reading */
clean?: boolean
} }
/** /**

View file

@ -1,8 +1,19 @@
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { OAuthCallbackServerOptions } from './types'
import express from 'express'
import net from 'net'
import crypto from 'crypto'
// Package version from package.json
export const MCP_REMOTE_VERSION = require('../../package.json').version
const pid = process.pid const pid = process.pid
export function log(str: string, ...rest: unknown[]) {
// Using stderr so that it doesn't interfere with stdout
console.error(`[${pid}] ${str}`, ...rest)
}
/** /**
* Creates a bidirectional proxy between two transports * Creates a bidirectional proxy between two transports
@ -14,13 +25,13 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
transportToClient.onmessage = (message) => { transportToClient.onmessage = (message) => {
// @ts-expect-error TODO // @ts-expect-error TODO
console.error('[Local→Remote]', message.method || message.id) log('[Local→Remote]', message.method || message.id)
transportToServer.send(message).catch(onServerError) transportToServer.send(message).catch(onServerError)
} }
transportToServer.onmessage = (message) => { transportToServer.onmessage = (message) => {
// @ts-expect-error TODO: fix this type // @ts-expect-error TODO: fix this type
console.error('[Remote→Local]', message.method || message.id) log('[Remote→Local]', message.method || message.id)
transportToClient.send(message).catch(onClientError) transportToClient.send(message).catch(onClientError)
} }
@ -45,11 +56,11 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
transportToServer.onerror = onServerError transportToServer.onerror = onServerError
function onClientError(error: Error) { function onClientError(error: Error) {
console.error('Error from local client:', error) log('Error from local client:', error)
} }
function onServerError(error: Error) { function onServerError(error: Error) {
console.error('Error from remote server:', error) log('Error from remote server:', error)
} }
} }
@ -58,44 +69,256 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo
* @param serverUrl The URL of the remote server * @param serverUrl The URL of the remote server
* @param authProvider The OAuth client provider * @param authProvider The OAuth client provider
* @param waitForAuthCode Function to wait for the auth code * @param waitForAuthCode Function to wait for the auth code
* @param skipBrowserAuth Whether to skip browser auth and use shared auth
* @returns The connected SSE client transport * @returns The connected SSE client transport
*/ */
export async function connectToRemoteServer( export async function connectToRemoteServer(
serverUrl: string, serverUrl: string,
authProvider: OAuthClientProvider, authProvider: OAuthClientProvider,
waitForAuthCode: () => Promise<string>, waitForAuthCode: () => Promise<string>,
skipBrowserAuth: boolean = false,
): Promise<SSEClientTransport> { ): Promise<SSEClientTransport> {
console.error(`[${pid}] Connecting to remote server: ${serverUrl}`) log(`[${pid}] Connecting to remote server: ${serverUrl}`)
const url = new URL(serverUrl) const url = new URL(serverUrl)
const transport = new SSEClientTransport(url, { authProvider }) const transport = new SSEClientTransport(url, { authProvider })
try { try {
await transport.start() await transport.start()
console.error('Connected to remote server') log('Connected to remote server')
return transport return transport
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
console.error('Authentication required. Waiting for authorization...') if (skipBrowserAuth) {
log('Authentication required but skipping browser auth - using shared auth')
} else {
log('Authentication required. Waiting for authorization...')
}
// Wait for the authorization code from the callback // Wait for the authorization code from the callback
const code = await waitForAuthCode() const code = await waitForAuthCode()
try { try {
console.error('Completing authorization...') log('Completing authorization...')
await transport.finishAuth(code) await transport.finishAuth(code)
// Create a new transport after auth // Create a new transport after auth
const newTransport = new SSEClientTransport(url, { authProvider }) const newTransport = new SSEClientTransport(url, { authProvider })
await newTransport.start() await newTransport.start()
console.error('Connected to remote server after authentication') log('Connected to remote server after authentication')
return newTransport return newTransport
} catch (authError) { } catch (authError) {
console.error('Authorization error:', authError) log('Authorization error:', authError)
throw authError throw authError
} }
} else { } else {
console.error('Connection error:', error) log('Connection error:', error)
throw error throw error
} }
} }
} }
/**
* 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 setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServerOptions) {
let authCode: string | null = null
const app = express()
// Create a promise to track when auth is completed
let authCompletedResolve: (code: string) => void
const authCompletedPromise = new Promise<string>((resolve) => {
authCompletedResolve = resolve
})
// Long-polling endpoint
app.get('/wait-for-auth', (req, res) => {
if (authCode) {
// Auth already completed - just return 200 without the actual code
// Secondary instances will read tokens from disk
log('Auth already completed, returning 200')
res.status(200).send('Authentication completed')
return
}
if (req.query.poll === 'false') {
log('Client requested no long poll, responding with 202')
res.status(202).send('Authentication in progress')
return
}
// Long poll - wait for up to 30 seconds
const longPollTimeout = setTimeout(() => {
log('Long poll timeout reached, responding with 202')
res.status(202).send('Authentication in progress')
}, 30000)
// If auth completes while we're waiting, send the response immediately
authCompletedPromise
.then(() => {
clearTimeout(longPollTimeout)
if (!res.headersSent) {
log('Auth completed during long poll, responding with 200')
res.status(200).send('Authentication completed')
}
})
.catch(() => {
clearTimeout(longPollTimeout)
if (!res.headersSent) {
log('Auth failed during long poll, responding with 500')
res.status(500).send('Authentication failed')
}
})
})
// OAuth callback endpoint
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
log('Auth code received, resolving promise')
authCompletedResolve(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, () => {
log(`OAuth callback server running at http://127.0.0.1:${options.port}`)
})
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, authCompletedPromise }
}
/**
* 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) {
const { server, authCode, waitForAuthCode } = setupOAuthCallbackServerWithLongPoll(options)
return { server, authCode, waitForAuthCode }
}
/**
* 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, callbackPort, and clean flag
*/
export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) {
// Check for --clean flag
const cleanIndex = args.indexOf('--clean')
const clean = cleanIndex !== -1
// Remove the flag from args if it exists
if (clean) {
args.splice(cleanIndex, 1)
}
const serverUrl = args[0]
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
if (!serverUrl) {
log(usage)
process.exit(1)
}
const url = new URL(serverUrl)
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:'
if (!(url.protocol == 'https:' || isLocalhost)) {
log(usage)
process.exit(1)
}
// Use the specified port, or find an available one
const callbackPort = specifiedPort || (await findAvailablePort(defaultPort))
if (specifiedPort) {
log(`Using specified callback port: ${callbackPort}`)
} else {
log(`Using automatically selected callback port: ${callbackPort}`)
}
if (clean) {
log('Clean mode enabled: config files will be reset before reading')
}
return { serverUrl, callbackPort, clean }
}
/**
* 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 () => {
log('\nShutting down...')
await cleanup()
process.exit(0)
})
// Keep the process alive
process.stdin.resume()
}
/**
* Generates a hash for the server URL to use in filenames
* @param serverUrl The server URL to hash
* @returns The hashed server URL
*/
export function getServerUrlHash(serverUrl: string): string {
return crypto.createHash('md5').update(serverUrl).digest('hex')
}

View file

@ -4,43 +4,55 @@
* MCP Proxy with OAuth support * MCP Proxy with OAuth support
* A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication. * 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] * Run with: npx tsx proxy.ts [--clean] https://example.remote/server [callback-port]
*
* Options:
* --clean: Deletes stored configuration before reading, ensuring a fresh session
* *
* If callback-port is not specified, an available port will be automatically selected. * If callback-port is not specified, an available port will be automatically selected.
*/ */
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils'
import { connectToRemoteServer, mcpProxy } from '../lib/utils.js' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
import { coordinateAuth } from './lib/coordination'
/** /**
* Main function to run the proxy * Main function to run the proxy
*/ */
async function runProxy(serverUrl: string, callbackPort: number) { async function runProxy(serverUrl: string, callbackPort: number, clean: boolean = false) {
// Set up event emitter for auth flow // Set up event emitter for auth flow
const events = new EventEmitter() const events = new EventEmitter()
// Get the server URL hash for lockfile operations
const serverUrlHash = getServerUrlHash(serverUrl)
// Coordinate authentication with other instances
const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events)
// Create the OAuth client provider // Create the OAuth client provider
const authProvider = new NodeOAuthClientProvider({ const authProvider = new NodeOAuthClientProvider({
serverUrl, serverUrl,
callbackPort, callbackPort,
clientName: 'MCP CLI Proxy', clientName: 'MCP CLI Proxy',
clean,
}) })
// If auth was completed by another instance, just log that we'll use the auth from disk
if (skipBrowserAuth) {
log('Authentication was completed by another instance - will use tokens from disk')
// TODO: remove, the callback is happening before the tokens are exchanged
// so we're slightly too early
await new Promise((res) => setTimeout(res, 1_000))
}
// Create the STDIO transport for local connections // Create the STDIO transport for local connections
const localTransport = new StdioServerTransport() const localTransport = new StdioServerTransport()
// Set up an HTTP server to handle OAuth callback
const { server, waitForAuthCode } = setupOAuthCallbackServer({
port: callbackPort,
path: '/oauth/callback',
events,
})
try { try {
// Connect to remote server with authentication // Connect to remote server with authentication
const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode) const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode, skipBrowserAuth)
// Set up bidirectional proxy between local and remote transports // Set up bidirectional proxy between local and remote transports
mcpProxy({ mcpProxy({
@ -50,9 +62,9 @@ async function runProxy(serverUrl: string, callbackPort: number) {
// Start the local STDIO server // Start the local STDIO server
await localTransport.start() await localTransport.start()
console.error('Local STDIO server running') log('Local STDIO server running')
console.error('Proxy established successfully between local STDIO and remote SSE') log('Proxy established successfully between local STDIO and remote SSE')
console.error('Press Ctrl+C to exit') log('Press Ctrl+C to exit')
// Setup cleanup handler // Setup cleanup handler
const cleanup = async () => { const cleanup = async () => {
@ -62,9 +74,9 @@ async function runProxy(serverUrl: string, callbackPort: number) {
} }
setupSignalHandlers(cleanup) setupSignalHandlers(cleanup)
} catch (error) { } catch (error) {
console.error('Fatal error:', error) log('Fatal error:', error)
if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) { if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) {
console.error(`You may be behind a VPN! log(`You may be behind a VPN!
If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point
to the CA certificate file. If using claude_desktop_config.json, this might look like: to the CA certificate file. If using claude_desktop_config.json, this might look like:
@ -91,11 +103,11 @@ to the CA certificate file. If using claude_desktop_config.json, this might look
} }
// Parse command-line arguments and run the proxy // Parse command-line arguments and run the proxy
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]') parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [--clean] <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort }) => { .then(({ serverUrl, callbackPort, clean }) => {
return runProxy(serverUrl, callbackPort) return runProxy(serverUrl, callbackPort, clean)
}) })
.catch((error) => { .catch((error) => {
console.error('Fatal error:', error) log('Fatal error:', error)
process.exit(1) process.exit(1)
}) })

File diff suppressed because it is too large Load diff