Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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": {
Expand All @@ -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"
}
Expand Down
4 changes: 4 additions & 0 deletions src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down
31 changes: 10 additions & 21 deletions src/bridge/proxy-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*/
Expand Down
38 changes: 38 additions & 0 deletions test/e2e/lib/framework.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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...]
Expand Down Expand Up @@ -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
Expand Down
49 changes: 18 additions & 31 deletions test/e2e/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Server>();

// Create the MCP server
// Create a new MCP server instance (one per session)
function createMcpServer(): Server {
const server = new Server(
{
Expand Down Expand Up @@ -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<string, StreamableHTTPServerTransport>();

const httpServer = http.createServer(async (req, res) => {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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);
Expand All @@ -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<typeof mcpServer.connect>[0]);
await sessionServer.connect(transport as Parameters<typeof sessionServer.connect>[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' });
Expand Down
21 changes: 21 additions & 0 deletions test/e2e/server/proxy-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Simple HTTP proxy server for E2E testing
* Uses proxy-chain to forward requests.
*
* Outputs to stdout once ready:
* PROXY_PORT=<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);
});
Loading
Loading