Skip to content
Open
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
8 changes: 4 additions & 4 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"keytar": "^7.9.0",
"ora": "^9.0.0",
"proper-lockfile": "^4.1.2",
"undici": "^7.19.2",
"uuid": "^13.0.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion src/core/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export async function createMcpClient(options: CreateMcpClientOptions): Promise<
if (options.mcpSessionId) {
transportOptions.mcpSessionId = options.mcpSessionId;
}
const transport = createTransportFromConfig(options.serverConfig, transportOptions);
const transport = await createTransportFromConfig(options.serverConfig, transportOptions);
await client.connect(transport);
}

Expand Down
65 changes: 58 additions & 7 deletions src/core/transports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export {
export type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';

import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import {
StdioClientTransport,
Expand All @@ -40,6 +41,45 @@ import { createLogger, getVerbose } from '../lib/logger.js';
import type { ServerConfig } from '../lib/types.js';
import { ClientError } from '../lib/errors.js';

/**
* Create a proxy-aware fetch function if HTTPS_PROXY or https_proxy is set.
* This allows mcpc to work in environments where network access is routed through
* a proxy (e.g., Claude Code's sandbox, corporate proxies).
*
* Node.js native fetch (undici) does not respect proxy environment variables,
* so we need to explicitly configure a ProxyAgent dispatcher.
*/
async function createProxyAwareFetch(): Promise<FetchLike | undefined> {
const proxyUrl = process.env.https_proxy || process.env.HTTPS_PROXY;
if (!proxyUrl) {
return undefined;
}

const logger = createLogger('ProxyFetch');
logger.debug(`Configuring proxy-aware fetch with proxy: ${proxyUrl}`);

try {
// Dynamically import undici to create a ProxyAgent
// undici is the HTTP client that powers Node.js native fetch
const { ProxyAgent, fetch: undiciFetch } = await import('undici');
const proxyAgent = new ProxyAgent(proxyUrl);

// Return a fetch function that uses the proxy dispatcher
const proxyFetch: FetchLike = (input, init) => {
return undiciFetch(input, {
...init,
dispatcher: proxyAgent,
}) as Promise<Response>;
};

logger.debug('Proxy-aware fetch configured successfully');
return proxyFetch;
} catch (error) {
logger.debug(`Failed to configure proxy-aware fetch: ${error}`);
return undefined;
}
}

/**
* Create a stdio transport for a local MCP server
*/
Expand All @@ -60,10 +100,10 @@ export function createStdioTransport(config: StdioServerParameters): Transport {
/**
* Create a Streamable HTTP transport for a remote MCP server
*/
export function createStreamableHttpTransport(
export async function createStreamableHttpTransport(
url: string,
options: Omit<StreamableHTTPClientTransportOptions, 'fetch'> = {}
): Transport {
): Promise<Transport> {
const logger = createLogger('StreamableHttpTransport');
logger.debug('Creating Streamable HTTP transport', { url });
logger.debug('Transport options:', {
Expand All @@ -79,10 +119,21 @@ export function createStreamableHttpTransport(
maxRetries: 10, // Max 10 reconnection attempts
};

const transport = new StreamableHTTPClientTransport(new URL(url), {
// Create proxy-aware fetch if proxy environment variables are set
const proxyFetch = await createProxyAwareFetch();

const transportOptions: StreamableHTTPClientTransportOptions = {
reconnectionOptions: defaultReconnectionOptions,
...options,
});
};

// Use proxy-aware fetch if available
if (proxyFetch) {
transportOptions.fetch = proxyFetch;
logger.debug('Using proxy-aware fetch for HTTP transport');
}

const transport = new StreamableHTTPClientTransport(new URL(url), transportOptions);

// Verify authProvider is correctly attached
// @ts-expect-error accessing private property for debugging
Expand Down Expand Up @@ -124,10 +175,10 @@ export interface CreateTransportOptions {
/**
* Create a transport from a generic transport configuration
*/
export function createTransportFromConfig(
export async function createTransportFromConfig(
config: ServerConfig,
options: CreateTransportOptions = {}
): Transport {
): Promise<Transport> {
// Stdio transport
if (config.command) {
const stdioConfig: StdioServerParameters = {
Expand Down Expand Up @@ -178,7 +229,7 @@ export function createTransportFromConfig(
};
}

return createStreamableHttpTransport(config.url, transportOptions);
return await createStreamableHttpTransport(config.url, transportOptions);
}

throw new ClientError('Invalid ServerConfig: must have either url or command');
Expand Down
4 changes: 2 additions & 2 deletions test/unit/core/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import { McpClient } from '../../../src/core/mcp-client.js';
import { createMcpClient } from '../../../src/core/factory.js';

// Mock the transports
// Mock the transports (now async)
jest.mock('../../../src/core/transports', () => ({
createTransportFromConfig: jest.fn().mockReturnValue({
createTransportFromConfig: jest.fn().mockResolvedValue({
start: jest.fn().mockResolvedValue(undefined),
send: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
Expand Down
127 changes: 116 additions & 11 deletions test/unit/core/transports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { createTransportFromConfig } from '../../../src/core/transports';
import { ClientError } from '../../../src/lib/errors';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

// Mock the SDK transports
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
Expand All @@ -24,32 +25,52 @@ jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPError: class StreamableHTTPError extends Error {},
}));

// Mock undici for proxy tests
jest.mock('undici', () => ({
ProxyAgent: jest.fn().mockImplementation((url: string) => ({ proxyUrl: url })),
fetch: jest.fn().mockResolvedValue({ ok: true }),
}));

describe('createTransportFromConfig', () => {
it('should create stdio transport from config', () => {
const transport = createTransportFromConfig({
const originalEnv = process.env;

beforeEach(() => {
jest.clearAllMocks();
// Reset environment variables before each test
process.env = { ...originalEnv };
delete process.env.https_proxy;
delete process.env.HTTPS_PROXY;
});

afterAll(() => {
process.env = originalEnv;
});

it('should create stdio transport from config', async () => {
const transport = await createTransportFromConfig({
command: 'node',
args: ['server.js'],
});

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

it('should create http transport from config', () => {
const transport = createTransportFromConfig({
it('should create http transport from config', async () => {
const transport = await createTransportFromConfig({
url: 'https://mcp.example.com',
});

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

it('should throw error for config without url or command', () => {
expect(() =>
it('should throw error for config without url or command', async () => {
await expect(
createTransportFromConfig({} as any)
).toThrow(ClientError);
).rejects.toThrow(ClientError);
});

it('should pass headers to http transport', () => {
const transport = createTransportFromConfig({
it('should pass headers to http transport', async () => {
const transport = await createTransportFromConfig({
url: 'https://mcp.example.com',
headers: {
Authorization: 'Bearer token',
Expand All @@ -59,8 +80,8 @@ describe('createTransportFromConfig', () => {
expect(transport).toBeDefined();
});

it('should pass environment variables to stdio transport', () => {
const transport = createTransportFromConfig({
it('should pass environment variables to stdio transport', async () => {
const transport = await createTransportFromConfig({
command: 'node',
args: ['server.js'],
env: {
Expand All @@ -71,3 +92,87 @@ describe('createTransportFromConfig', () => {
expect(transport).toBeDefined();
});
});

describe('proxy-aware fetch', () => {
const originalEnv = process.env;
const MockedStreamableHTTPClientTransport = StreamableHTTPClientTransport as jest.MockedClass<typeof StreamableHTTPClientTransport>;

beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.https_proxy;
delete process.env.HTTPS_PROXY;
});

afterAll(() => {
process.env = originalEnv;
});

it('should not use proxy fetch when no proxy env var is set', async () => {
await createTransportFromConfig({
url: 'https://mcp.example.com',
});

// Check that StreamableHTTPClientTransport was called without a custom fetch
expect(MockedStreamableHTTPClientTransport).toHaveBeenCalledTimes(1);
const callArgs = MockedStreamableHTTPClientTransport.mock.calls[0];
expect(callArgs[1]).not.toHaveProperty('fetch');
});

it('should use proxy fetch when https_proxy is set', async () => {
process.env.https_proxy = 'http://localhost:8080';

await createTransportFromConfig({
url: 'https://mcp.example.com',
});

// Check that StreamableHTTPClientTransport was called with a custom fetch
expect(MockedStreamableHTTPClientTransport).toHaveBeenCalledTimes(1);
const callArgs = MockedStreamableHTTPClientTransport.mock.calls[0];
expect(callArgs[1]).toHaveProperty('fetch');
expect(typeof callArgs[1]?.fetch).toBe('function');
});

it('should use proxy fetch when HTTPS_PROXY is set', async () => {
process.env.HTTPS_PROXY = 'http://localhost:8080';

await createTransportFromConfig({
url: 'https://mcp.example.com',
});

// Check that StreamableHTTPClientTransport was called with a custom fetch
expect(MockedStreamableHTTPClientTransport).toHaveBeenCalledTimes(1);
const callArgs = MockedStreamableHTTPClientTransport.mock.calls[0];
expect(callArgs[1]).toHaveProperty('fetch');
expect(typeof callArgs[1]?.fetch).toBe('function');
});

it('should prefer https_proxy over HTTPS_PROXY when both are set', async () => {
process.env.https_proxy = 'http://localhost:8080';
process.env.HTTPS_PROXY = 'http://localhost:9090';

await createTransportFromConfig({
url: 'https://mcp.example.com',
});

// The proxy fetch should be configured (we can't easily verify which URL was used
// without more complex mocking, but we can verify a fetch was provided)
expect(MockedStreamableHTTPClientTransport).toHaveBeenCalledTimes(1);
const callArgs = MockedStreamableHTTPClientTransport.mock.calls[0];
expect(callArgs[1]).toHaveProperty('fetch');
});

it('should not affect stdio transport when proxy is set', async () => {
process.env.https_proxy = 'http://localhost:8080';

const transport = await createTransportFromConfig({
command: 'node',
args: ['server.js'],
});

// Stdio transport should still work normally
expect(transport).toBeDefined();
// StreamableHTTPClientTransport should not have been called
expect(MockedStreamableHTTPClientTransport).not.toHaveBeenCalled();
});
});