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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Fixed auth loss when reconnecting an unauthorized session via `mcpc connect` — the `unauthorized` status was not cleared, causing all subsequent operations to fail with "Authentication required by server" even after successful reconnection
- HTTP proxy support (`HTTP_PROXY`/`HTTPS_PROXY` env vars) now works for MCP server connections, OAuth token refresh, and x402 payment signing — previously the MCP SDK transport and OAuth calls bypassed the global proxy dispatcher

### Changed

Expand Down
8 changes: 3 additions & 5 deletions src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* It communicates with the CLI via Unix domain sockets
*/

import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import { initProxy, proxyFetch } from '../lib/proxy.js';
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 @@ -526,7 +526,7 @@ class BridgeProcess {
// McpClient caches tools in-memory after the first listAllTools call
return this.client?.getCachedTools()?.find((t: Tool) => t.name === name);
};
customFetch = createX402FetchMiddleware(fetch, { wallet, getToolByName });
customFetch = createX402FetchMiddleware(proxyFetch as FetchLike, { wallet, getToolByName });
}

const clientConfig: CreateMcpClientOptions = {
Expand Down Expand Up @@ -1420,9 +1420,7 @@ async function main(): Promise<void> {

// Set up HTTP proxy from environment variables (HTTPS_PROXY, HTTP_PROXY, NO_PROXY, and lowercase variants)
// Also handle --insecure flag to disable TLS certificate verification
setGlobalDispatcher(
new EnvHttpProxyAgent(insecure ? { connect: { rejectUnauthorized: false } } : {})
);
initProxy({ insecure });

try {
const bridgeOptions: BridgeOptions = {
Expand Down
6 changes: 2 additions & 4 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */

import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import { initProxy } from '../lib/proxy.js';
import { Command } from 'commander';
import { setVerbose, setJsonMode, closeFileLogger } from '../lib/index.js';
import { isMcpError, formatHumanError, ClientError } from '../lib/index.js';
Expand Down Expand Up @@ -48,9 +48,7 @@ const { version: mcpcVersion } = createRequire(import.meta.url)('../../package.j
// Also handle --insecure flag to disable TLS certificate verification (for self-signed certs)
{
const insecure = process.argv.includes('--insecure');
setGlobalDispatcher(
new EnvHttpProxyAgent(insecure ? { connect: { rejectUnauthorized: false } } : {})
);
initProxy({ insecure });
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/core/transports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { createLogger, getVerbose } from '../lib/logger.js';
import type { ServerConfig } from '../lib/types.js';
import { ClientError } from '../lib/errors.js';
import { proxyFetch } from '../lib/proxy.js';

/**
* Create a stdio transport for a local MCP server
Expand Down Expand Up @@ -79,9 +80,16 @@ export function createStreamableHttpTransport(
maxRetries: 10, // Max 10 reconnection attempts
};

// Explicitly pass proxy-aware fetch so the MCP SDK transport respects
// HTTP_PROXY/HTTPS_PROXY env vars (its internal fetch ignores the global dispatcher).
// Custom fetch (e.g. x402 middleware) takes priority if provided.
const fetchFn =
options.fetch ?? (proxyFetch as NonNullable<StreamableHTTPClientTransportOptions['fetch']>);

const transport = new StreamableHTTPClientTransport(new URL(url), {
reconnectionOptions: defaultReconnectionOptions,
...options,
fetch: fetchFn,
});

// Verify authProvider is correctly attached
Expand Down
5 changes: 3 additions & 2 deletions src/lib/auth/oauth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { createLogger } from '../logger.js';
import { AuthError } from '../errors.js';
import { proxyFetch } from '../proxy.js';

const logger = createLogger('oauth-utils');

Expand Down Expand Up @@ -50,7 +51,7 @@ export async function discoverTokenEndpoint(serverUrl: string): Promise<string |
for (const url of discoveryUrls) {
try {
logger.debug(`Trying OAuth discovery at: ${url}`);
const response = await fetch(url, {
const response = await proxyFetch(url, {
headers: { Accept: 'application/json' },
});

Expand Down Expand Up @@ -94,7 +95,7 @@ export async function refreshAccessToken(
client_id: clientId,
});

const response = await fetch(tokenEndpoint, {
const response = await proxyFetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Expand Down
56 changes: 56 additions & 0 deletions src/lib/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Proxy-aware fetch and global HTTP proxy setup
*
* Node.js native fetch (powered by undici) does not respect HTTP_PROXY/HTTPS_PROXY
* environment variables, and undici's setGlobalDispatcher() is not honored by
* libraries that manage their own HTTP connections (e.g., the MCP SDK's
* StreamableHTTPClientTransport).
*
* This module provides:
* 1. `initProxy()` — sets up the global undici dispatcher for proxy support
* and initializes the proxy-aware fetch. Must be called once at process startup.
* 2. `proxyFetch()` — a fetch function that explicitly routes through the
* EnvHttpProxyAgent dispatcher, for use in code that bypasses the global dispatcher.
*/

import {
EnvHttpProxyAgent,
setGlobalDispatcher,
fetch as undiciFetch,
type Dispatcher,
} from 'undici';

let proxyAgent: Dispatcher | undefined;

/**
* Initialize HTTP proxy support from environment variables
* (HTTPS_PROXY, HTTP_PROXY, NO_PROXY, and lowercase variants).
*
* Sets the global undici dispatcher AND initializes the proxy-aware fetch agent.
* Must be called once at process startup (in CLI and bridge entry points).
*
* @param options.insecure - Disable TLS certificate verification (for self-signed certs)
*/
export function initProxy(options?: { insecure?: boolean }): void {
proxyAgent = new EnvHttpProxyAgent(
options?.insecure ? { connect: { rejectUnauthorized: false } } : {}
);
setGlobalDispatcher(proxyAgent);
}

/**
* A fetch function that explicitly routes through the HTTP proxy configured via
* environment variables. Use this where the global dispatcher is not respected
* (e.g., MCP SDK transport, OAuth calls).
*
* Falls back to a default EnvHttpProxyAgent if initProxy() was not called.
*/
export function proxyFetch(
input: Parameters<typeof undiciFetch>[0],
init?: Parameters<typeof undiciFetch>[1]
): ReturnType<typeof undiciFetch> {
if (!proxyAgent) {
proxyAgent = new EnvHttpProxyAgent();
}
return undiciFetch(input, { ...init, dispatcher: proxyAgent });
}
22 changes: 18 additions & 4 deletions test/e2e/lib/framework.sh
Original file line number Diff line number Diff line change
Expand Up @@ -682,10 +682,11 @@ start_proxy_server() {
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)
# Wait for PROXY_CONTROL_PORT= line in log (up to 10 seconds)
# (PROXY_CONTROL_PORT is output after PROXY_PORT, so both are ready)
local max_wait=50
local waited=0
while ! grep -q "^PROXY_PORT=" "$log" 2>/dev/null; do
while ! grep -q "^PROXY_CONTROL_PORT=" "$log" 2>/dev/null; do
sleep 0.2
((waited++)) || true
if [[ $waited -ge $max_wait ]]; then
Expand All @@ -696,11 +697,24 @@ start_proxy_server() {
fi
done

local proxy_port
local proxy_port control_port
proxy_port=$(grep "^PROXY_PORT=" "$log" | cut -d= -f2 | tr -d '[:space:]')
control_port=$(grep "^PROXY_CONTROL_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)"
export PROXY_CONTROL_URL="http://127.0.0.1:${control_port}"
echo "# Proxy server started at $PROXY_URL (control: $PROXY_CONTROL_URL, PID: $_PROXY_SERVER_PID)"
}

# Get the number of requests that have been routed through the proxy server.
# Usage: count=$(proxy_request_count)
proxy_request_count() {
curl -s "$PROXY_CONTROL_URL/request-count" | grep -o '"count":[0-9]*' | cut -d: -f2
}

# Reset the proxy request counter to zero.
proxy_reset_count() {
curl -s -X POST "$PROXY_CONTROL_URL/reset" >/dev/null
}

# Start an HTTPS wrapper around the test server using a self-signed certificate.
Expand Down
39 changes: 37 additions & 2 deletions test/e2e/server/proxy-server.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,56 @@
/**
* Simple HTTP proxy server for E2E testing
* HTTP proxy server with request counting for E2E testing
* Uses proxy-chain to forward requests.
*
* Outputs to stdout once ready:
* PROXY_PORT=<port>
* PROXY_CONTROL_PORT=<port>
*
* Control API (plain HTTP on PROXY_CONTROL_PORT):
* GET /request-count → { "count": <number> }
* POST /reset → resets count to 0
*/

import { Server } from 'proxy-chain';
import { createServer } from 'http';

let requestCount = 0;

const proxyServer = new Server({ port: 0, verbose: false });
const proxyServer = new Server({
port: 0,
verbose: false,
prepareRequestFunction: () => {
requestCount++;
return {};
},
});

await proxyServer.listen();

// Tiny control server to query request count
const controlServer = createServer((req, res) => {
if (req.url === '/request-count' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ count: requestCount }));
} else if (req.url === '/reset' && req.method === 'POST') {
requestCount = 0;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ count: 0 }));
} else {
res.writeHead(404);
res.end();
}
});

await new Promise<void>((resolve) => controlServer.listen(0, '127.0.0.1', resolve));
const controlPort = (controlServer.address() as import('net').AddressInfo).port;

// Signal readiness to the bash framework
process.stdout.write(`PROXY_PORT=${proxyServer.port}\n`);
process.stdout.write(`PROXY_CONTROL_PORT=${controlPort}\n`);

process.on('SIGTERM', () => {
controlServer.close();
proxyServer.close();
process.exit(0);
});
28 changes: 28 additions & 0 deletions test/unit/core/transports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/

import { createTransportFromConfig } from '../../../src/core/transports.js';
import { StreamableHTTPClientTransport } from '../../../src/core/transports.js';
import { ClientError } from '../../../src/lib/errors.js';
import { proxyFetch } from '../../../src/lib/proxy.js';

// Mock the SDK transports
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
Expand Down Expand Up @@ -68,4 +70,30 @@ describe('createTransportFromConfig', () => {

expect(transport).toBeDefined();
});

it('should inject proxyFetch into HTTP transport when no custom fetch is provided', () => {
const mock = StreamableHTTPClientTransport as jest.Mock;
mock.mockClear();
createTransportFromConfig({
url: 'https://mcp.example.com',
});

expect(mock).toHaveBeenCalledTimes(1);
const [, options] = mock.mock.calls[0];
expect(options.fetch).toBe(proxyFetch);
});

it('should preserve custom fetch when provided (e.g. x402 middleware)', () => {
const mock = StreamableHTTPClientTransport as jest.Mock;
mock.mockClear();
const customFetch = jest.fn();
createTransportFromConfig(
{ url: 'https://mcp.example.com' },
{ customFetch: customFetch as any }
);

expect(mock).toHaveBeenCalledTimes(1);
const [, options] = mock.mock.calls[0];
expect(options.fetch).toBe(customFetch);
});
});
3 changes: 2 additions & 1 deletion test/unit/lib/auth/oauth-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { discoverTokenEndpoint } from '../../../../src/lib/auth/oauth-utils.js';
import * as proxyModule from '../../../../src/lib/proxy.js';

// Helper to create a mock fetch Response
function mockResponse(body: object | null, ok = true): Response {
Expand All @@ -16,7 +17,7 @@ describe('discoverTokenEndpoint', () => {
let fetchSpy: jest.SpyInstance;

beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
fetchSpy = jest.spyOn(proxyModule, 'proxyFetch');
});

afterEach(() => {
Expand Down
Loading