diff --git a/package.json b/package.json index d4c6443..739a86c 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,6 @@ "name": "use-mcp", "version": "0.0.4", "type": "module", - "bin": { - "mcp-remote": "dist/cli/proxy.js" - }, "files": [ "dist", "README.md", diff --git a/src/cli/client.ts b/src/cli/client.ts deleted file mode 100644 index f79e917..0000000 --- a/src/cli/client.ts +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env node - -/** - * MCP Client with OAuth support - * A command-line client that connects to an MCP server using SSE with OAuth authentication. - * - * Run with: npx tsx client.ts https://example.remote/server [callback-port] - * - * If callback-port is not specified, an available port will be automatically selected. - */ - -import { EventEmitter } from 'events' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' -import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' - -/** - * Main function to run the client - */ -async function runClient(serverUrl: string, callbackPort: number) { - // Set up event emitter for auth flow - const events = new EventEmitter() - - // Create the OAuth client provider - const authProvider = new NodeOAuthClientProvider({ - serverUrl, - callbackPort, - clientName: 'MCP CLI Client', - }) - - // Create the client - const client = new Client( - { - name: 'mcp-cli', - version: '0.1.0', - }, - { - capabilities: { - sampling: {}, - }, - }, - ) - - // Create the transport factory - const url = new URL(serverUrl) - function initTransport() { - const transport = new SSEClientTransport(url, { authProvider }) - - // Set up message and error handlers - transport.onmessage = (message) => { - console.log('Received message:', JSON.stringify(message, null, 2)) - } - - transport.onerror = (error) => { - console.error('Transport error:', error) - } - - transport.onclose = () => { - console.log('Connection closed.') - process.exit(0) - } - return transport - } - - const transport = initTransport() - - // Set up an HTTP server to handle OAuth callback - const { server, waitForAuthCode } = setupOAuthCallbackServer({ - port: callbackPort, - path: '/oauth/callback', - events, - }) - - // Set up cleanup handler - const cleanup = async () => { - console.log('\nClosing connection...') - await client.close() - server.close() - } - setupSignalHandlers(cleanup) - - // Try to connect - try { - console.log('Connecting to server...') - await client.connect(transport) - console.log('Connected successfully!') - } catch (error) { - if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { - console.log('Authentication required. Waiting for authorization...') - - // Wait for the authorization code from the callback - const code = await waitForAuthCode() - - try { - console.log('Completing authorization...') - await transport.finishAuth(code) - - // Reconnect after authorization with a new transport - console.log('Connecting after authorization...') - await client.connect(initTransport()) - - console.log('Connected successfully!') - - // Request tools list after auth - console.log('Requesting tools list...') - const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) - console.log('Tools:', JSON.stringify(tools, null, 2)) - - // Request resources list after auth - console.log('Requesting resource list...') - const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) - console.log('Resources:', JSON.stringify(resources, null, 2)) - - console.log('Listening for messages. Press Ctrl+C to exit.') - } catch (authError) { - console.error('Authorization error:', authError) - server.close() - process.exit(1) - } - } else { - console.error('Connection error:', error) - server.close() - process.exit(1) - } - } - - try { - // Request tools list - console.log('Requesting tools list...') - const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) - console.log('Tools:', JSON.stringify(tools, null, 2)) - } catch (e) { - console.log('Error requesting tools list:', e) - } - - try { - // Request resources list - console.log('Requesting resource list...') - const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) - console.log('Resources:', JSON.stringify(resources, null, 2)) - } catch (e) { - console.log('Error requesting resources list:', e) - } - - console.log('Listening for messages. Press Ctrl+C to exit.') -} - -// Parse command-line arguments and run the client -parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [callback-port]') - .then(({ serverUrl, callbackPort }) => { - return runClient(serverUrl, callbackPort) - }) - .catch((error) => { - console.error('Fatal error:', error) - process.exit(1) - }) diff --git a/src/cli/proxy.ts b/src/cli/proxy.ts deleted file mode 100644 index d705b35..0000000 --- a/src/cli/proxy.ts +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node - -/** - * MCP Proxy with OAuth support - * A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication. - * - * Run with: npx tsx proxy.ts https://example.remote/server [callback-port] - * - * If callback-port is not specified, an available port will be automatically selected. - */ - -import { EventEmitter } from 'events' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' -import { connectToRemoteServer, mcpProxy } from '../lib/utils.js' - -/** - * Main function to run the proxy - */ -async function runProxy(serverUrl: string, callbackPort: number) { - // Set up event emitter for auth flow - const events = new EventEmitter() - - // Create the OAuth client provider - const authProvider = new NodeOAuthClientProvider({ - serverUrl, - callbackPort, - clientName: 'MCP CLI Proxy', - }) - - // Create the STDIO transport for local connections - const localTransport = new StdioServerTransport() - - // Set up an HTTP server to handle OAuth callback - const { server, waitForAuthCode } = setupOAuthCallbackServer({ - port: callbackPort, - path: '/oauth/callback', - events, - }) - - try { - // Connect to remote server with authentication - const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode) - - // Set up bidirectional proxy between local and remote transports - mcpProxy({ - transportToClient: localTransport, - transportToServer: remoteTransport, - }) - - // Start the local STDIO server - await localTransport.start() - console.error('Local STDIO server running') - console.error('Proxy established successfully between local STDIO and remote SSE') - console.error('Press Ctrl+C to exit') - - // Setup cleanup handler - const cleanup = async () => { - await remoteTransport.close() - await localTransport.close() - server.close() - } - setupSignalHandlers(cleanup) - } catch (error) { - console.error('Fatal error:', error) - if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) { - console.error(`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 -to the CA certificate file. If using claude_desktop_config.json, this might look like: - -{ - "mcpServers": { - "\${mcpServerName}": { - "command": "npx", - "args": [ - "mcp-remote", - "https://remote.mcp.server/sse" - ], - "env": { - "NODE_EXTRA_CA_CERTS": "\${your CA certificate file path}.pem" - } - } - } -} - `) - } - server.close() - process.exit(1) - } -} - -// Parse command-line arguments and run the proxy -parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [callback-port]') - .then(({ serverUrl, callbackPort }) => { - return runProxy(serverUrl, callbackPort) - }) - .catch((error) => { - console.error('Fatal error:', error) - process.exit(1) - }) diff --git a/src/cli/shared.ts b/src/cli/shared.ts deleted file mode 100644 index a4101aa..0000000 --- a/src/cli/shared.ts +++ /dev/null @@ -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://localhost:${this.options.callbackPort}${this.callbackPath}` - } - - get clientMetadata() { - return { - redirect_uris: [this.redirectUrl], - token_endpoint_auth_method: 'none', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - client_name: this.clientName, - client_uri: this.clientUri, - } - } - - /** - * Ensures the configuration directory exists - * @private - */ - private async ensureConfigDir() { - try { - await fs.mkdir(this.configDir, { recursive: true }) - } catch (error) { - console.error('Error creating config directory:', error) - throw error - } - } - - /** - * Reads a JSON file and parses it with the provided schema - * @param filename The name of the file to read - * @param schema The schema to validate against - * @returns The parsed file content or undefined if the file doesn't exist - * @private - */ - private async readFile(filename: string, schema: any): Promise { - 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 { - 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 { - return this.readFile('client_info.json', OAuthClientInformationSchema) - } - - /** - * Saves client information - * @param clientInformation The client information to save - */ - async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise { - await this.writeFile('client_info.json', clientInformation) - } - - /** - * Gets the OAuth tokens if they exist - * @returns The OAuth tokens or undefined - */ - async tokens(): Promise { - return this.readFile('tokens.json', OAuthTokensSchema) - } - - /** - * Saves OAuth tokens - * @param tokens The tokens to save - */ - async saveTokens(tokens: OAuthTokens): Promise { - 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 { - 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 { - await this.writeTextFile('code_verifier.txt', codeVerifier) - } - - /** - * Gets the PKCE code verifier - * @returns The code verifier - */ - async codeVerifier(): Promise { - 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://localhost:${options.port}`) - }) - - /** - * Waits for the OAuth authorization code - * @returns A promise that resolves with the authorization code - */ - const waitForAuthCode = (): Promise => { - 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 { - return new Promise((resolve, reject) => { - const server = net.createServer() - - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - // If preferred port is in use, get a random port - server.listen(0) - } else { - reject(err) - } - }) - - server.on('listening', () => { - const { port } = server.address() as net.AddressInfo - server.close(() => { - resolve(port) - }) - }) - - // Try preferred port first, or get a random port - server.listen(preferredPort || 0) - }) -} - -/** - * Parses command line arguments for MCP clients and proxies - * @param args Command line arguments - * @param defaultPort Default port for the callback server if specified port is unavailable - * @param usage Usage message to show on error - * @returns A promise that resolves to an object with parsed serverUrl and callbackPort - */ -export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) { - const serverUrl = args[0] - const specifiedPort = args[1] ? parseInt(args[1]) : undefined - - if (!serverUrl) { - console.error(usage) - process.exit(1) - } - - const url = new URL(serverUrl) - const isLocalhost = url.hostname === 'localhost' && url.protocol === 'http:' - - if (!(url.protocol == 'https:' || isLocalhost)) { - console.error(usage) - process.exit(1) - } - - // Use the specified port, or find an available one - const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) - - if (specifiedPort) { - console.error(`Using specified callback port: ${callbackPort}`) - } else { - console.error(`Using automatically selected callback port: ${callbackPort}`) - } - - return { serverUrl, callbackPort } -} - -/** - * Sets up signal handlers for graceful shutdown - * @param cleanup Cleanup function to run on shutdown - */ -export function setupSignalHandlers(cleanup: () => Promise) { - process.on('SIGINT', async () => { - console.error('\nShutting down...') - await cleanup() - process.exit(0) - }) - - // Keep the process alive - process.stdin.resume() -}