Merge pull request #12 from geelen/refactor

Refactor, expanded README, better logging, etc
This commit is contained in:
Glen Maddern 2025-03-31 16:32:43 +11:00 committed by GitHub
commit 743b6b207f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 606 additions and 1682 deletions

135
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,16 +164,17 @@ 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`
* 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`
MacOS / Linux: ### "Client" mode
`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log` Run the following on the command line (not from an MCP server):
For bash on WSL: ```shell
npx -p mcp-remote@latest mcp-remote-client https://remote.mcp.server/sse
```
`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"` 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.
or Powershell:
`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20`

View file

@ -1,21 +1,26 @@
{ {
"name": "mcp-remote", "name": "mcp-remote",
"version": "0.0.10", "version": "0.0.13",
"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/remote-mcp",
"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,12 +17,13 @@ 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, setupOAuthCallbackServer, setupSignalHandlers, MCP_REMOTE_VERSION } from './lib/utils'
/** /**
* 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()
@ -28,18 +32,17 @@ async function runClient(serverUrl: string, callbackPort: number) {
serverUrl, serverUrl,
callbackPort, callbackPort,
clientName: 'MCP CLI Client', clientName: 'MCP CLI Client',
clean,
}) })
// 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: {},
},
}, },
) )
@ -148,9 +151,9 @@ async function runClient(serverUrl: string, callbackPort: number) {
} }
// 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)

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

@ -0,0 +1,199 @@
import crypto from 'crypto'
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']
/**
* 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
}
}
/**
* 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')
}
/**
* 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')
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 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,118 @@
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 { getServerUrlHash, readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config'
import { 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> {
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> {
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> {
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> {
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> {
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier)
}
/**
* Gets the PKCE code verifier
* @returns The code verifier
*/
async codeVerifier(): Promise<string> {
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,15 @@
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'
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 +21,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 +52,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)
} }
} }
@ -65,37 +72,180 @@ export async function connectToRemoteServer(
authProvider: OAuthClientProvider, authProvider: OAuthClientProvider,
waitForAuthCode: () => Promise<string>, waitForAuthCode: () => Promise<string>,
): 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...') 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 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, () => {
log(`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, 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()
}
export const MCP_REMOTE_VERSION = require('../../package.json').version

View file

@ -4,20 +4,23 @@
* 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, setupOAuthCallbackServer, setupSignalHandlers } from './lib/utils'
import { connectToRemoteServer, mcpProxy } from '../lib/utils.js' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
/** /**
* 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()
@ -26,6 +29,7 @@ async function runProxy(serverUrl: string, callbackPort: number) {
serverUrl, serverUrl,
callbackPort, callbackPort,
clientName: 'MCP CLI Proxy', clientName: 'MCP CLI Proxy',
clean,
}) })
// Create the STDIO transport for local connections // Create the STDIO transport for local connections
@ -50,9 +54,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 +66,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 +95,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