From 1f4bebf1430931aeeaabb56861f7b83eb747f6a7 Mon Sep 17 00:00:00 2001 From: Jesse Sanford <108698+jessesanford@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:30:38 +0000 Subject: [PATCH] initial working version Signed-off-by: Jesse Sanford <108698+jessesanford@users.noreply.github.com> --- .gitignore | 1 + .pkg-pr-new.json | 16 ++++ README.md | 52 +++++++++++ src/client.ts | 16 +++- src/lib/utils.test.ts | 212 +++++++++++++++++++++++++++++++++++++++++- src/lib/utils.ts | 88 ++++++++++++++++-- src/proxy.ts | 14 ++- 7 files changed, 386 insertions(+), 13 deletions(-) create mode 100644 .pkg-pr-new.json diff --git a/.gitignore b/.gitignore index 0cd204c..7e9e7e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .mcp-cli dist +.pnpm-store diff --git a/.pkg-pr-new.json b/.pkg-pr-new.json new file mode 100644 index 0000000..b525bd7 --- /dev/null +++ b/.pkg-pr-new.json @@ -0,0 +1,16 @@ +{ + "packages": { + ".": { + "name": "mcp-remote", + "template": { + "name": "mcp-remote-example", + "label": "Try mcp-remote", + "description": "Test the mcp-remote package with this example" + } + } + }, + "comment": { + "header": "📦 **Package Preview Available**", + "footer": "Install this preview: `npm install https://pkg.pr.new/mcp-remote@{{pr}}`" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 8cce8d6..26ed583 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # `mcp-remote` +[![pkg.pr.new](https://pkg.pr.new/badge/geelen/mcp-remote)](https://pkg.pr.new/~/geelen/mcp-remote) + Connect an MCP Client that only supports local (stdio) servers to a Remote MCP Server, with auth support: **Note: this is a working proof-of-concept** but should be considered **experimental**. @@ -135,6 +137,23 @@ To bypass authentication, or to emit custom headers on all requests to your remo ] ``` +* To allow connections to servers with self-signed or invalid TLS certificates, add the `--insecure` flag. **⚠️ Warning**: This disables certificate verification and should only be used in development environments or trusted networks. Do not use this flag when connecting to untrusted servers as it makes your connection vulnerable to man-in-the-middle attacks. + +```json + "args": [ + "mcp-remote", + "https://self-signed-server.example.com/sse", + "--insecure" + ] +``` + +**Important**: The `--insecure` flag will conflict with the `NODE_TLS_REJECT_UNAUTHORIZED` environment variable if it's set to enable certificate verification (any value other than `0`). If you encounter an error about this conflict, either: +- Remove the `--insecure` flag, or +- Set `NODE_TLS_REJECT_UNAUTHORIZED=0` in your environment, or +- Unset the `NODE_TLS_REJECT_UNAUTHORIZED` environment variable entirely + +When `NODE_TLS_REJECT_UNAUTHORIZED` is unset and you use `--insecure`, mcp-remote will temporarily set it to `0` during the connection and restore it afterwards. + ### Transport Strategies MCP Remote supports different transport strategies when connecting to an MCP server. This allows you to control whether it uses Server-Sent Events (SSE) or HTTP transport, and in what order it tries them. @@ -271,6 +290,10 @@ this might look like: } ``` +Alternatively, for development or trusted internal servers with self-signed certificates, you can use the `--insecure` flag to bypass certificate validation entirely. **Note**: This should only be used when you trust the server and network. + +If you have `NODE_TLS_REJECT_UNAUTHORIZED` set in your environment, ensure it's compatible with the `--insecure` flag (see the `--insecure` flag documentation above for details). + ### Check the logs * [Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop) @@ -294,6 +317,35 @@ For troubleshooting complex issues, especially with token refreshing or authenti This creates detailed logs in `~/.mcp-auth/{server_hash}_debug.log` with timestamps and complete information about every step of the connection and authentication process. When you find issues with token refreshing, laptop sleep/resume issues, or auth problems, provide these logs when seeking support. +### NODE_TLS_REJECT_UNAUTHORIZED Conflicts + +If you see an error message like: + +``` +Error: Cannot use --insecure flag while NODE_TLS_REJECT_UNAUTHORIZED environment variable is set to enable certificate verification. +``` + +This means your environment has `NODE_TLS_REJECT_UNAUTHORIZED` set to a value other than `0`, which conflicts with the `--insecure` flag. To resolve this: + +1. **Remove the `--insecure` flag** if you want to keep certificate verification enabled, or +2. **Set `NODE_TLS_REJECT_UNAUTHORIZED=0`** in your MCP client configuration to disable certificate verification globally, or +3. **Unset the environment variable** by removing it from your shell profile or MCP client configuration + +Example of setting it to `0` in Claude Desktop config: +```json +{ + "mcpServers": { + "remote-example": { + "command": "npx", + "args": ["mcp-remote", "https://example.com/sse", "--insecure"], + "env": { + "NODE_TLS_REJECT_UNAUTHORIZED": "0" + } + } + } +} +``` + ### Authentication Errors If you encounter the following error, returned by the `/callback` URL: diff --git a/src/client.ts b/src/client.ts index d9a7343..cd8b06a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -36,6 +36,7 @@ async function runClient( host: string, staticOAuthClientMetadata: StaticOAuthClientMetadata, staticOAuthClientInfo: StaticOAuthClientInformationFull, + insecure: boolean = false, ) { // Set up event emitter for auth flow const events = new EventEmitter() @@ -93,7 +94,16 @@ async function runClient( try { // Connect to remote server with lazy authentication - const transport = await connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy) + const transport = await connectToRemoteServer( + client, + serverUrl, + authProvider, + headers, + authInitializer, + transportStrategy, + new Set(), + insecure, + ) // Set up message and error handlers transport.onmessage = (message) => { @@ -159,8 +169,8 @@ async function runClient( // Parse command-line arguments and run the client parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts [callback-port] [--debug]') - .then(({ serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo }) => { - return runClient(serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo) + .then(({ serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo, insecure }) => { + return runClient(serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo, insecure) }) .catch((error) => { console.error('Fatal error:', error) diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 7e819a1..0c1e88b 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,3 +1,213 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { parseCommandLineArgs, connectToRemoteServer } from './utils' +import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' // All sanitizeUrl tests have been moved to the strict-url-sanitise package + +describe('parseCommandLineArgs', () => { + it('should parse --insecure flag correctly', async () => { + const args = ['https://example.com', '--insecure'] + const result = await parseCommandLineArgs(args, 'Test usage') + + expect(result.insecure).toBe(true) + expect(result.serverUrl).toBe('https://example.com') + }) + + it('should default insecure to false when not provided', async () => { + const args = ['https://example.com'] + const result = await parseCommandLineArgs(args, 'Test usage') + + expect(result.insecure).toBe(false) + expect(result.serverUrl).toBe('https://example.com') + }) + + it('should work with multiple flags including --insecure', async () => { + const args = ['https://example.com', '--debug', '--insecure', '--allow-http'] + const result = await parseCommandLineArgs(args, 'Test usage') + + expect(result.insecure).toBe(true) + expect(result.debug).toBe(true) + expect(result.serverUrl).toBe('https://example.com') + }) +}) + +describe('connectToRemoteServer insecure flag', () => { + let originalTlsReject: string | undefined + let mockExit: any + let mockLog: any + + beforeEach(() => { + // Save original environment variable + originalTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED + + // Mock process.exit to prevent actual exits during tests + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called') + }) + + // Mock console.error to capture log messages + mockLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + // Restore original environment variable + if (originalTlsReject !== undefined) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalTlsReject + } else { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + } + + // Restore mocks + mockExit.mockRestore() + mockLog.mockRestore() + }) + + it('should fail when --insecure conflicts with NODE_TLS_REJECT_UNAUTHORIZED=1', async () => { + // Set environment variable to enable cert verification (conflicts with --insecure) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1' + + const mockAuthProvider = {} as OAuthClientProvider + const mockAuthInitializer = async () => ({ waitForAuthCode: async () => 'test', skipBrowserAuth: false }) + + await expect( + connectToRemoteServer( + null, + 'https://example.com', + mockAuthProvider, + {}, + mockAuthInitializer, + 'http-first', + new Set(), + true // insecure flag + ) + ).rejects.toThrow('process.exit called') + + // Check that error message was logged + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Cannot use --insecure flag while NODE_TLS_REJECT_UNAUTHORIZED') + ) + }) + + it('should fail when --insecure conflicts with NODE_TLS_REJECT_UNAUTHORIZED=true', async () => { + // Set environment variable to enable cert verification (conflicts with --insecure) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = 'true' + + const mockAuthProvider = {} as OAuthClientProvider + const mockAuthInitializer = async () => ({ waitForAuthCode: async () => 'test', skipBrowserAuth: false }) + + await expect( + connectToRemoteServer( + null, + 'https://example.com', + mockAuthProvider, + {}, + mockAuthInitializer, + 'http-first', + new Set(), + true // insecure flag + ) + ).rejects.toThrow('process.exit called') + + // Check that error message was logged + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Cannot use --insecure flag while NODE_TLS_REJECT_UNAUTHORIZED') + ) + }) + + it('should proceed when --insecure is compatible with NODE_TLS_REJECT_UNAUTHORIZED=0', async () => { + // Set environment variable to disable cert verification (compatible with --insecure) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + const mockAuthProvider = {} as OAuthClientProvider + const mockAuthInitializer = async () => ({ waitForAuthCode: async () => 'test', skipBrowserAuth: false }) + + // This test will likely fail due to network issues, but we're just testing that + // the conflict detection doesn't trigger process.exit + try { + await connectToRemoteServer( + null, + 'https://example.com', + mockAuthProvider, + {}, + mockAuthInitializer, + 'http-first', + new Set(), + true // insecure flag + ) + } catch (error) { + // We expect this to fail due to network/connection issues, not conflict detection + expect(error).not.toEqual(new Error('process.exit called')) + } + + // Should not have called process.exit + expect(mockExit).not.toHaveBeenCalled() + }) + + it('should set and restore NODE_TLS_REJECT_UNAUTHORIZED when unset with --insecure', async () => { + // Ensure environment variable is unset + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + + const mockAuthProvider = {} as OAuthClientProvider + const mockAuthInitializer = async () => ({ waitForAuthCode: async () => 'test', skipBrowserAuth: false }) + + // This test will likely fail due to network issues, but we're testing env var handling + try { + await connectToRemoteServer( + null, + 'https://example.com', + mockAuthProvider, + {}, + mockAuthInitializer, + 'http-first', + new Set(), + true // insecure flag + ) + } catch (error) { + // We expect this to fail due to network/connection issues + expect(error).not.toEqual(new Error('process.exit called')) + } + + // Environment variable should be restored to unset state + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined() + + // Should not have called process.exit + expect(mockExit).not.toHaveBeenCalled() + + // Should have logged that we're setting the env var + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Setting NODE_TLS_REJECT_UNAUTHORIZED=0 for --insecure connection') + ) + }) + + it('should not modify NODE_TLS_REJECT_UNAUTHORIZED when --insecure is false', async () => { + // Set a specific value + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1' + const originalValue = process.env.NODE_TLS_REJECT_UNAUTHORIZED + + const mockAuthProvider = {} as OAuthClientProvider + const mockAuthInitializer = async () => ({ waitForAuthCode: async () => 'test', skipBrowserAuth: false }) + + // This test will likely fail due to network issues + try { + await connectToRemoteServer( + null, + 'https://example.com', + mockAuthProvider, + {}, + mockAuthInitializer, + 'http-first', + new Set(), + false // insecure flag is false + ) + } catch (error) { + // We expect this to fail due to network/connection issues + expect(error).not.toEqual(new Error('process.exit called')) + } + + // Environment variable should remain unchanged + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe(originalValue) + + // Should not have called process.exit + expect(mockExit).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5aa4c84..b6a23f4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -13,6 +13,7 @@ import crypto from 'crypto' import fs from 'fs' import { readFile, rm } from 'fs/promises' import path from 'path' +import https from 'https' import { version as MCP_REMOTE_VERSION } from '../../package.json' // Global type declaration for typescript @@ -191,15 +192,40 @@ export async function connectToRemoteServer( authInitializer: AuthInitializer, transportStrategy: TransportStrategy = 'http-first', recursionReasons: Set = new Set(), + insecure: boolean = false, ): Promise { log(`[${pid}] Connecting to remote server: ${serverUrl}`) const url = new URL(serverUrl) + + // Handle NODE_TLS_REJECT_UNAUTHORIZED environment variable for insecure connections + const originalTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED + let shouldRestoreTlsEnv = false + + if (insecure && url.protocol === 'https:') { + if (originalTlsReject !== undefined) { + // Environment variable is already set, check for conflicts + if (originalTlsReject !== '0') { + // User has cert verification enabled but wants --insecure, this is a conflict + log('Error: Cannot use --insecure flag while NODE_TLS_REJECT_UNAUTHORIZED environment variable is set to enable certificate verification.') + log('Either remove the --insecure flag or set NODE_TLS_REJECT_UNAUTHORIZED=0') + process.exit(1) + } + // originalTlsReject === '0', compatible with --insecure, proceed without changes + if (DEBUG) debugLog('NODE_TLS_REJECT_UNAUTHORIZED already set to 0, compatible with --insecure flag') + } else { + // Environment variable is unset, we can safely set it temporarily + log('Setting NODE_TLS_REJECT_UNAUTHORIZED=0 for --insecure connection (will be restored after connection)') + if (DEBUG) debugLog('Setting NODE_TLS_REJECT_UNAUTHORIZED=0 for insecure connection') + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + shouldRestoreTlsEnv = true + } + } // Create transport with eventSourceInit to pass Authorization header if present const eventSourceInit = { fetch: (url: string | URL, init?: RequestInit) => { - return Promise.resolve(authProvider?.tokens?.()).then((tokens) => - fetch(url, { + return Promise.resolve(authProvider?.tokens?.()).then((tokens) => { + const requestInit: RequestInit = { ...init, headers: { ...(init?.headers as Record | undefined), @@ -207,8 +233,12 @@ export async function connectToRemoteServer( ...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}), Accept: 'text/event-stream', } as Record, - }), - ) + } + + // Note: insecure TLS handling is done via NODE_TLS_REJECT_UNAUTHORIZED environment variable + + return fetch(url, requestInit) + }) }, } @@ -219,15 +249,19 @@ export async function connectToRemoteServer( // Create transport instance based on the strategy const sseTransport = transportStrategy === 'sse-only' || transportStrategy === 'sse-first' + + // Create requestInit with headers + const requestInit: RequestInit = { headers } + const transport = sseTransport ? new SSEClientTransport(url, { authProvider, - requestInit: { headers }, + requestInit, eventSourceInit, }) : new StreamableHTTPClientTransport(url, { authProvider, - requestInit: { headers }, + requestInit, }) try { @@ -245,13 +279,19 @@ export async function connectToRemoteServer( // the client is already connected. So let's just create a one-off client to make a single request and figure // out if we're actually talking to an HTTP server or not. if (DEBUG) debugLog('Creating test transport for HTTP-only connection test') - const testTransport = new StreamableHTTPClientTransport(url, { authProvider, requestInit: { headers } }) + const testTransport = new StreamableHTTPClientTransport(url, { authProvider, requestInit }) const testClient = new Client({ name: 'mcp-remote-fallback-test', version: '0.0.0' }, { capabilities: {} }) await testClient.connect(testTransport) } } log(`Connected to remote server using ${transport.constructor.name}`) + // Restore original TLS settings if we modified them + if (shouldRestoreTlsEnv) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + if (DEBUG) debugLog('Restored NODE_TLS_REJECT_UNAUTHORIZED to unset state') + } + return transport } catch (error: any) { // Check if it's a protocol error and we should attempt fallback @@ -269,6 +309,10 @@ export async function connectToRemoteServer( if (recursionReasons.has(REASON_TRANSPORT_FALLBACK)) { const errorMessage = `Already attempted transport fallback. Giving up.` log(errorMessage) + // Restore original TLS settings before throwing + if (shouldRestoreTlsEnv) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + } throw new Error(errorMessage) } @@ -286,6 +330,7 @@ export async function connectToRemoteServer( authInitializer, sseTransport ? 'http-only' : 'sse-only', recursionReasons, + insecure, ) } else if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { log('Authentication required. Initializing auth...') @@ -324,6 +369,14 @@ export async function connectToRemoteServer( debugLog('Already attempted auth reconnection, giving up', { recursionReasons: Array.from(recursionReasons), }) + // Restore original TLS settings before throwing + if (insecure && url.protocol === 'https:') { + if (originalTlsReject !== undefined) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalTlsReject + } else { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + } + } throw new Error(errorMessage) } @@ -333,7 +386,16 @@ export async function connectToRemoteServer( if (DEBUG) debugLog('Recursively reconnecting after auth', { recursionReasons: Array.from(recursionReasons) }) // Recursively call connectToRemoteServer with the updated recursion tracking - return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, recursionReasons) + return connectToRemoteServer( + client, + serverUrl, + authProvider, + headers, + authInitializer, + transportStrategy, + recursionReasons, + insecure, + ) } catch (authError: any) { log('Authorization error:', authError) if (DEBUG) @@ -341,6 +403,10 @@ export async function connectToRemoteServer( errorMessage: authError.message, stack: authError.stack, }) + // Restore original TLS settings before throwing + if (shouldRestoreTlsEnv) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + } throw authError } } else { @@ -351,6 +417,10 @@ export async function connectToRemoteServer( stack: error.stack, transportType: transport.constructor.name, }) + // Restore original TLS settings before throwing + if (shouldRestoreTlsEnv) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + } throw error } } @@ -550,6 +620,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) { const serverUrl = args[0] const specifiedPort = args[1] ? parseInt(args[1]) : undefined const allowHttp = args.includes('--allow-http') + const insecure = args.includes('--insecure') // Check for debug flag const debug = args.includes('--debug') @@ -691,6 +762,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) { staticOAuthClientMetadata, staticOAuthClientInfo, authorizeResource, + insecure, } } diff --git a/src/proxy.ts b/src/proxy.ts index 6627bf8..99a2e68 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -36,6 +36,7 @@ async function runProxy( staticOAuthClientMetadata: StaticOAuthClientMetadata, staticOAuthClientInfo: StaticOAuthClientInformationFull, authorizeResource: string, + insecure: boolean = false, ) { // Set up event emitter for auth flow const events = new EventEmitter() @@ -86,7 +87,16 @@ async function runProxy( try { // Connect to remote server with lazy authentication - const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy) + const remoteTransport = await connectToRemoteServer( + null, + serverUrl, + authProvider, + headers, + authInitializer, + transportStrategy, + new Set(), + insecure, + ) // Set up bidirectional proxy between local and remote transports mcpProxy({ @@ -155,6 +165,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts { return runProxy( serverUrl, @@ -165,6 +176,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts