diff --git a/README.md b/README.md index d9baec8..e26bba8 100644 --- a/README.md +++ b/README.md @@ -967,6 +967,8 @@ Config files support environment variable substitution using `${VAR_NAME}` synta - `MCPC_HOME_DIR` - Directory for session and authentication profiles data (default is `~/.mcpc`) - `MCPC_VERBOSE` - Enable verbose logging (set to `1`, `true`, or `yes`, case-insensitive) - `MCPC_JSON` - Enable JSON output (set to `1`, `true`, or `yes`, case-insensitive) +- `HTTPS_PROXY` / `https_proxy` / `HTTP_PROXY` / `http_proxy` - Proxy URL for outbound connections (e.g. `http://proxy.example.com:8080`); `HTTPS_PROXY` takes precedence +- `NO_PROXY` / `no_proxy` - Comma-separated list of hostnames/IPs to bypass the proxy (e.g. `localhost,127.0.0.1`) ### Cleanup diff --git a/package-lock.json b/package-lock.json index dfe1e35..adc579d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "commander": "^14.0.2", "ora": "^9.0.0", "proper-lockfile": "^4.1.2", + "undici": "^7.22.0", "uuid": "^13.0.0" }, "bin": { @@ -37,6 +38,7 @@ "markdown-link-check": "^3.14.2", "nyc": "^18.0.0", "prettier": "^3.7.4", + "proxy-chain": "^2.7.1", "ts-jest": "^29.4.6", "typescript": "^5.9.3" }, @@ -10146,6 +10148,21 @@ "node": ">=12" } }, + "node_modules/proxy-chain": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/proxy-chain/-/proxy-chain-2.7.1.tgz", + "integrity": "sha512-LtXu0miohJYrHWJxv8wA6EoGreRcX1hxKb7qlE1pMFH+BXE7bqMvpyhzR/JvR6M5SzYKzyHFpvfmYJrZeMtwAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "socks": "^2.8.3", + "socks-proxy-agent": "^8.0.3", + "tslib": "^2.3.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -11881,7 +11898,6 @@ "version": "7.22.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 25eb5ea..fcf7125 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "description": "Universal command-line client for the Model Context Protocol (MCP).", "type": "module", "keywords": [ - "mcp", - "model-context-protocol", - "cli", - "llm", - "ai" + "Model Context Protocol", + "MCP", + "CLI", + "Code mode", + "LLM", + "AI" ], "license": "Apache-2.0", "author": "Jan Curn (@jancurn)", @@ -59,6 +60,7 @@ "commander": "^14.0.2", "ora": "^9.0.0", "proper-lockfile": "^4.1.2", + "undici": "^7.22.0", "uuid": "^13.0.0" }, "devDependencies": { @@ -75,6 +77,7 @@ "markdown-link-check": "^3.14.2", "nyc": "^18.0.0", "prettier": "^3.7.4", + "proxy-chain": "^2.7.1", "ts-jest": "^29.4.6", "typescript": "^5.9.3" } diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 5356725..4440f83 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -5,6 +5,7 @@ * It communicates with the CLI via Unix domain sockets */ +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import { createServer, type Server as NetServer, type Socket } from 'net'; import { unlink } from 'fs/promises'; import { createMcpClient, CreateMcpClientOptions } from '../core/index.js'; @@ -35,6 +36,9 @@ const { version: mcpcVersion } = createRequire(import.meta.url)('../../package.j import { ProxyServer } from './proxy-server.js'; import type { ProxyConfig } from '../lib/types.js'; +// Set up HTTP proxy from environment variables (HTTPS_PROXY, HTTP_PROXY, NO_PROXY, and lowercase variants) +setGlobalDispatcher(new EnvHttpProxyAgent()); + // Keepalive ping interval in milliseconds (30 seconds) const KEEPALIVE_INTERVAL_MS = 30_000; diff --git a/src/bridge/proxy-server.ts b/src/bridge/proxy-server.ts index 5bbbaed..9f486a5 100644 --- a/src/bridge/proxy-server.ts +++ b/src/bridge/proxy-server.ts @@ -12,6 +12,7 @@ import { type IncomingMessage, type ServerResponse, } from 'http'; +import { randomUUID } from 'crypto'; import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; @@ -83,6 +84,12 @@ export class ProxyServer { // Register handlers that forward to upstream client this.registerHandlers(client); + // Create transport with session management and connect MCP server once + this.transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + await this.mcpServer.connect(this.transport as unknown as Transport); + // Create HTTP server this.httpServer = createServer((req, res) => { this.handleRequest(req, res, bearerToken).catch((error) => { @@ -146,27 +153,9 @@ export class ProxyServer { } } - // Handle MCP requests - if (method === 'POST') { - // Create transport for this request (stateless - no session management) - this.transport = new StreamableHTTPServerTransport({}); - - // Connect transport to MCP server (cast needed due to exactOptionalPropertyTypes) - await this.mcpServer!.connect(this.transport as unknown as Transport); - - // Handle the request - await this.transport.handleRequest(req, res); - return; - } - - // Handle GET for SSE (if needed) - if (method === 'GET') { - // Create transport for this request (stateless - no session management) - this.transport = new StreamableHTTPServerTransport({}); - - // Connect transport to MCP server (cast needed due to exactOptionalPropertyTypes) - await this.mcpServer!.connect(this.transport as unknown as Transport); - await this.transport.handleRequest(req, res); + // Handle MCP requests (POST and GET delegate to the transport) + if (method === 'POST' || method === 'GET') { + await this.transport!.handleRequest(req, res); return; } diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 346bd37..1818d9c 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -443,7 +443,7 @@ export async function closeSession( // Success! if (options.outputMode === 'human') { - console.log(formatSuccess(`Session ${name} closed successfully`)); + console.log(formatSuccess(`Session ${name} closed successfully\n`)); } else { console.log( formatOutput( diff --git a/src/cli/index.ts b/src/cli/index.ts index 8f88196..94f0fd3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import { Command } from 'commander'; import { setVerbose, setJsonMode, closeFileLogger } from '../lib/index.js'; import { isMcpError, formatHumanError, NetworkError } from '../lib/index.js'; @@ -39,6 +40,9 @@ const { version: mcpcVersion } = createRequire(import.meta.url)('../../package.j version: string; }; +// Set up HTTP proxy from environment variables (HTTPS_PROXY, HTTP_PROXY, NO_PROXY, and lowercase variants) +setGlobalDispatcher(new EnvHttpProxyAgent()); + /** * Options passed to command handlers */ diff --git a/test/e2e/lib/framework.sh b/test/e2e/lib/framework.sh index 640cc40..e14d6bf 100755 --- a/test/e2e/lib/framework.sh +++ b/test/e2e/lib/framework.sh @@ -146,6 +146,14 @@ _test_cleanup() { done fi + # Stop proxy server if started + if [[ -n "${_PROXY_SERVER_PID:-}" ]]; then + pkill -P "$_PROXY_SERVER_PID" 2>/dev/null || true + kill "$_PROXY_SERVER_PID" 2>/dev/null || true + wait "$_PROXY_SERVER_PID" 2>/dev/null || true + _PROXY_SERVER_PID="" + fi + # Clean up keychain entries for our sessions _cleanup_keychain @@ -548,6 +556,7 @@ assert_stderr_empty() { # Default test server port TEST_SERVER_PORT="${TEST_SERVER_PORT:-0}" # 0 = random port _TEST_SERVER_PID="" +_PROXY_SERVER_PID="" # Start test MCP server # Usage: start_test_server [env_vars...] @@ -665,6 +674,35 @@ EOF fi } +# Start a local HTTP proxy server (proxy-chain) for testing HTTPS_PROXY / HTTP_PROXY support. +# Sets PROXY_URL and PROXY_CONTROL_URL in the environment. +start_proxy_server() { + local log="$_TEST_RUN_DIR/proxy-server.log" + cd "$PROJECT_ROOT" + npx tsx test/e2e/server/proxy-server.ts >"$log" 2>&1 & + _PROXY_SERVER_PID=$! + + # Wait for PROXY_PORT= line in log (up to 10 seconds) + local max_wait=50 + local waited=0 + while ! grep -q "^PROXY_PORT=" "$log" 2>/dev/null; do + sleep 0.2 + ((waited++)) || true + if [[ $waited -ge $max_wait ]]; then + echo "Error: Proxy server failed to start" >&2 + cat "$log" >&2 + kill $_PROXY_SERVER_PID 2>/dev/null || true + exit 1 + fi + done + + local proxy_port + proxy_port=$(grep "^PROXY_PORT=" "$log" | cut -d= -f2 | tr -d '[:space:]') + + export PROXY_URL="http://127.0.0.1:${proxy_port}" + echo "# Proxy server started at $PROXY_URL (PID: $_PROXY_SERVER_PID)" +} + # Stop test server stop_test_server() { if [[ -n "$_TEST_SERVER_PID" ]]; then diff --git a/test/e2e/server/index.ts b/test/e2e/server/index.ts index 141ad04..8a0e072 100644 --- a/test/e2e/server/index.ts +++ b/test/e2e/server/index.ts @@ -195,10 +195,10 @@ function shouldFail(): boolean { return false; } -// MCP server instance (accessible for sending notifications) -let mcpServer: Server | null = null; +// Active MCP server instances, keyed by session ID +const mcpServers = new Map(); -// Create the MCP server +// Create a new MCP server instance (one per session) function createMcpServer(): Server { const server = new Server( { @@ -387,7 +387,6 @@ function createMcpServer(): Server { // Create HTTP server with MCP transport and control endpoints async function main() { - mcpServer = createMcpServer(); const transports = new Map(); const httpServer = http.createServer(async (req, res) => { @@ -451,36 +450,21 @@ async function main() { return; case 'notify-tools-changed': - if (mcpServer) { - await mcpServer.sendToolListChanged(); - res.writeHead(200); - res.end('Sent tools/list_changed notification'); - } else { - res.writeHead(500); - res.end('MCP server not initialized'); - } + await Promise.all([...mcpServers.values()].map((s) => s.sendToolListChanged())); + res.writeHead(200); + res.end('Sent tools/list_changed notification'); return; case 'notify-prompts-changed': - if (mcpServer) { - await mcpServer.sendPromptListChanged(); - res.writeHead(200); - res.end('Sent prompts/list_changed notification'); - } else { - res.writeHead(500); - res.end('MCP server not initialized'); - } + await Promise.all([...mcpServers.values()].map((s) => s.sendPromptListChanged())); + res.writeHead(200); + res.end('Sent prompts/list_changed notification'); return; case 'notify-resources-changed': - if (mcpServer) { - await mcpServer.sendResourceListChanged(); - res.writeHead(200); - res.end('Sent resources/list_changed notification'); - } else { - res.writeHead(500); - res.end('MCP server not initialized'); - } + await Promise.all([...mcpServers.values()].map((s) => s.sendResourceListChanged())); + res.writeHead(200); + res.end('Sent resources/list_changed notification'); return; default: @@ -518,6 +502,7 @@ async function main() { const oldTransport = transports.get(mcpSessionId)!; await oldTransport.close(); transports.delete(mcpSessionId); + mcpServers.delete(mcpSessionId); deletedSessions.push(mcpSessionId); } res.writeHead(200); @@ -530,19 +515,21 @@ async function main() { if (mcpSessionId && transports.has(mcpSessionId)) { transport = transports.get(mcpSessionId)!; } else if (req.method === 'POST' && !mcpSessionId) { - // New session - create transport + // New session - create a fresh Server + transport per connection + const sessionServer = createMcpServer(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => `e2e-session-${Date.now()}-${Math.random().toString(36).slice(2)}`, onsessioninitialized: (newSessionId) => { transports.set(newSessionId, transport); + mcpServers.set(newSessionId, sessionServer); }, }); - // Connect to MCP server + // Connect the fresh server instance to the transport // Type assertion needed due to exactOptionalPropertyTypes incompatibility with MCP SDK // @ts-ignore - await mcpServer.connect(transport as Parameters[0]); + await sessionServer.connect(transport as Parameters[0]); } else if (mcpSessionId && !transports.has(mcpSessionId)) { // Session ID provided but not found - per MCP spec, return 404 res.writeHead(404, { 'Content-Type': 'application/json' }); diff --git a/test/e2e/server/proxy-server.ts b/test/e2e/server/proxy-server.ts new file mode 100644 index 0000000..9bd8519 --- /dev/null +++ b/test/e2e/server/proxy-server.ts @@ -0,0 +1,21 @@ +/** + * Simple HTTP proxy server for E2E testing + * Uses proxy-chain to forward requests. + * + * Outputs to stdout once ready: + * PROXY_PORT= + */ + +import { Server } from 'proxy-chain'; + +const proxyServer = new Server({ port: 0, verbose: false }); + +await proxyServer.listen(); + +// Signal readiness to the bash framework +process.stdout.write(`PROXY_PORT=${proxyServer.port}\n`); + +process.on('SIGTERM', () => { + proxyServer.close(); + process.exit(0); +}); diff --git a/test/e2e/suites/basic/env-proxy.test.sh b/test/e2e/suites/basic/env-proxy.test.sh new file mode 100644 index 0000000..b3be2c9 --- /dev/null +++ b/test/e2e/suites/basic/env-proxy.test.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Test: HTTPS_PROXY / HTTP_PROXY environment variable support + +source "$(dirname "$0")/../../lib/framework.sh" +test_init "basic/env-proxy" + +start_test_server +start_proxy_server + +# ============================================================================= +# HTTP_PROXY routes requests through proxy +# ============================================================================= + +test_case "HTTP_PROXY routes requests through proxy" +HTTP_PROXY="$PROXY_URL" run_mcpc "$TEST_SERVER_URL" tools-list +assert_success +assert_contains "$STDOUT" "echo" +test_pass + +# ============================================================================= +# HTTPS_PROXY does not affect HTTP connections (scheme-specific proxy selection) +# ============================================================================= + +test_case "HTTPS_PROXY does not affect HTTP connections" +# HTTPS_PROXY points to a dead port; HTTP_PROXY points to working proxy +# Since MCP server URL is HTTP, only HTTP_PROXY should be used — should succeed +HTTPS_PROXY="http://127.0.0.1:1" HTTP_PROXY="$PROXY_URL" run_mcpc "$TEST_SERVER_URL" tools-list +assert_success +assert_contains "$STDOUT" "echo" +test_pass + +# ============================================================================= +# Invalid proxy causes connection failure (proves requests are actually proxied) +# ============================================================================= + +test_case "invalid proxy causes connection failure" +HTTP_PROXY="http://127.0.0.1:1" run_xmcpc "$TEST_SERVER_URL" tools-list +assert_failure +test_pass + +test_done diff --git a/test/e2e/suites/sessions/proxy.test.sh b/test/e2e/suites/sessions/proxy.test.sh index 0ffae99..07bb910 100755 --- a/test/e2e/suites/sessions/proxy.test.sh +++ b/test/e2e/suites/sessions/proxy.test.sh @@ -174,13 +174,13 @@ http_code=$(echo "$response" | tail -1) assert_eq "$http_code" "403" "should return 403 for wrong token" test_pass -# Test: MCP request with correct token succeeds +# Test: MCP request with correct token succeeds (send initialize as proper MCP clients do) test_case "proxy accepts correct bearer token" response=$(curl -s -w "\n%{http_code}" -X POST "http://127.0.0.1:$PROXY_PORT_AUTH/" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $BEARER_TOKEN" \ - -d '{"jsonrpc":"2.0","method":"ping","id":1}' 2>/dev/null) + -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' 2>/dev/null) http_code=$(echo "$response" | tail -1) # Should be 200 (success) or 202 (accepted for streaming) if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then diff --git a/test/playground/snippets.txt b/test/playground/snippets.txt index 41e0d4d..4610e34 100644 --- a/test/playground/snippets.txt +++ b/test/playground/snippets.txt @@ -1,9 +1,9 @@ -mcpc --config ./test/fixtures/mcp-config.json fs -mcpc --config ./test/fixtures/mcp-config.json fs session @fs +mcpc --config ./docs/examples/mcp-config.json fs +mcpc --config ./docs/examples/mcp-config.json fs connect @fs -mcpc --config ./test/fixtures/mcp-config.json fs tools-list +mcpc --config ./docs/examples/mcp-config.json fs tools-list -mcpc mcp.apify.com --headers "Blah: Test" session @test5 +mcpc mcp.apify.com --headers "Blah: Test" connect @test5