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
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import { Toolkit } from 'langchain/agents';
import {
createResultError,
createResultOk,
type ILoadOptionsFunctions,
type ISupplyDataFunctions,
type IDataObject,
type IExecuteFunctions,
type ILoadOptionsFunctions,
type ISupplyDataFunctions,
type Result,
} from 'n8n-workflow';
import { z } from 'zod';

import { convertJsonSchemaToZod } from '@utils/schemaParsing';

import { proxyFetch } from '@utils/httpProxyAgent';
import type {
McpAuthenticationOption,
McpServerTransport,
Expand Down Expand Up @@ -203,6 +204,7 @@ export async function connectMcpClient({
try {
const transport = new StreamableHTTPClientTransport(endpoint.result, {
requestInit: { headers },
fetch: proxyFetch,
});
await client.connect(transport);
return createResultOk(client);
Expand All @@ -229,14 +231,15 @@ export async function connectMcpClient({
const sseTransport = new SSEClientTransport(endpoint.result, {
eventSourceInit: {
fetch: async (url, init) =>
await fetch(url, {
await proxyFetch(url, {
...init,
headers: {
...headers,
Accept: 'text/event-stream',
},
}),
},
fetch: proxyFetch,
requestInit: { headers },
});
await client.connect(sseTransport);
Expand Down
12 changes: 12 additions & 0 deletions packages/@n8n/nodes-langchain/utils/httpProxyAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ export function getProxyAgent(targetUrl?: string) {
return new ProxyAgent(proxyUrl);
}

/**
* Make a fetch() request with a ProxyAgent if proxy environment variables are set.
* If no proxy is configured, use the default fetch().
*/
export async function proxyFetch(input: string | URL, init?: RequestInit): Promise<Response> {
return await fetch(input, {
...init,
// @ts-expect-error - dispatcher is an undici-specific option not in standard fetch
dispatcher: getProxyAgent(input.toString()),
});
}

/**
* Returns a Node.js HTTP/HTTPS proxy agent for use with AWS SDK v3 clients.
* AWS SDK v3 requires Node.js http.Agent/https.Agent instances (not undici ProxyAgent).
Expand Down
174 changes: 172 additions & 2 deletions packages/@n8n/nodes-langchain/utils/tests/httpProxyAgent.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { ProxyAgent } from 'undici';

import { getProxyAgent } from '../httpProxyAgent';
import { getProxyAgent, proxyFetch } from '../httpProxyAgent';

// Mock the dependencies
jest.mock('undici', () => ({
ProxyAgent: jest.fn().mockImplementation((url) => ({ proxyUrl: url })),
ProxyAgent: jest.fn().mockImplementation((url: string) => ({ proxyUrl: url })),
}));

// Mock global fetch
global.fetch = jest.fn();

describe('getProxyAgent', () => {
// Store original environment variables
const originalEnv = { ...process.env };
Expand Down Expand Up @@ -155,3 +158,170 @@ describe('getProxyAgent', () => {
});
});
});

describe('proxyFetch', () => {
// Store original environment variables
const originalEnv = { ...process.env };
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;

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

// Setup default fetch mock response
mockFetch.mockResolvedValue(
new Response('{}', {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json' },
}),
);
});

// Restore original environment after all tests
afterAll(() => {
process.env = originalEnv;
});

describe('with no proxy configured', () => {
it('should call fetch with undefined dispatcher when no proxy is set', async () => {
const url = 'https://api.openai.com/v1';
await proxyFetch(url);

expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
});
});

it('should pass through RequestInit options', async () => {
const url = 'https://api.openai.com/v1';
const init: RequestInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: 'data' }),
};

await proxyFetch(url, init);

expect(mockFetch).toHaveBeenCalledWith(url, {
...init,
dispatcher: undefined,
});
});

it('should handle URL objects', async () => {
const url = new URL('https://api.openai.com/v1');
await proxyFetch(url);

expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
});
});
});

describe('with proxy configured', () => {
it('should call fetch with ProxyAgent dispatcher when proxy is set', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;

const url = 'https://api.openai.com/v1';
await proxyFetch(url);

expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: { proxyUrl },
});
});

it('should pass through RequestInit options with proxy', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;

const url = 'https://api.openai.com/v1';
const init: RequestInit = {
method: 'POST',
headers: { Authorization: 'Bearer token123' },
};

await proxyFetch(url, init);

expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
expect(mockFetch).toHaveBeenCalledWith(url, {
...init,
dispatcher: { proxyUrl },
});
});

it('should handle URL objects with proxy', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;

const url = new URL('http://api.example.com/data');
await proxyFetch(url);

expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: { proxyUrl },
});
});

it('should respect NO_PROXY environment variable', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1';

const url = 'https://localhost:3000/api';
await proxyFetch(url);

// Should not create ProxyAgent for localhost
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
});
});
});

describe('return value', () => {
it('should return the Response from fetch', async () => {
const expectedResponse = new Response('{"success":true}', {
status: 200,
statusText: 'OK',
});
mockFetch.mockResolvedValueOnce(expectedResponse);

const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url);

expect(result).toBe(expectedResponse);
});

it('should propagate fetch errors', async () => {
const error = new Error('Network error');
mockFetch.mockRejectedValueOnce(error);

const url = 'https://api.openai.com/v1';

await expect(proxyFetch(url)).rejects.toThrow('Network error');
});

it('should return error responses without throwing', async () => {
const errorResponse = new Response('Not Found', {
status: 404,
statusText: 'Not Found',
});
mockFetch.mockResolvedValueOnce(errorResponse);

const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url);

expect(result).toBe(errorResponse);
expect(result.status).toBe(404);
});
});
});
4 changes: 2 additions & 2 deletions packages/core/src/http-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ function getOrCreateProxyAgent<T extends HttpProxyAgent<string> | HttpsProxyAgen
return proxyAgent;
}

function createFallbackAgent<T extends http.Agent | https.Agent>(AgentClass: new () => T): T {
return new AgentClass();
function createFallbackAgent<T extends http.Agent | https.Agent>(agentClass: new () => T): T {
return new agentClass();
}

/**
Expand Down
Loading