🛣️ feat: Add MCP Remote Proxy Support (#13076)

* feat: add MCP remote proxy support

* fix: Harden MCP Proxy Review Findings

* fix: Honor MCP Proxy Env Precedence

* fix: Harden MCP proxy routing

* fix: Align MCP proxy bypass semantics

* test: Pin MCP proxy admin scope
This commit is contained in:
Danny Avila 2026-05-21 15:28:54 -04:00 committed by GitHub
parent d7482ebe06
commit 05a3d1ed81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1534 additions and 66 deletions

View file

@ -141,6 +141,8 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic
# Optional outbound proxy for server-side requests, including remote MCP HTTP/SSE transports.
# Remote MCP transports also honor HTTP_PROXY, HTTPS_PROXY, and NO_PROXY when PROXY is unset.
PROXY=
#===================================#

View file

@ -313,6 +313,7 @@ actions:
# everything:
# # type: sse # type can optionally be omitted
# url: http://localhost:3001/sse
# # proxy: "${MCP_PROXY_URL}" # optional outbound proxy (http/https/socks/socks5)
# timeout: 60000 # 1 minute timeout for this server, this is the default timeout for MCP servers.
# puppeteer:
# type: stdio

View file

@ -13,6 +13,7 @@
import * as net from 'net';
import * as http from 'http';
import { lookup } from 'node:dns/promises';
import { randomUUID } from 'crypto';
import { Request as UndiciRequest } from 'undici';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@ -38,6 +39,10 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
jest.mock('node:dns/promises', () => ({
lookup: jest.fn(),
}));
jest.mock('~/auth', () => ({
createSSRFSafeUndiciConnect: jest.fn(() => ({
lookup: (_hostname: string, optionsOrCallback: unknown, maybeCallback?: LookupCallback) => {
@ -51,6 +56,7 @@ jest.mock('~/auth', () => ({
callback(null, '127.0.0.1', 4);
},
})),
isSSRFTarget: jest.fn(() => false),
resolveHostnameSSRF: jest.fn(async () => false),
}));
@ -61,10 +67,18 @@ jest.mock('~/mcp/mcpConfig', () => ({
const mockedResolveHostnameSSRF = resolveHostnameSSRF as jest.MockedFunction<
typeof resolveHostnameSSRF
>;
const mockedLookup = lookup as unknown as jest.MockedFunction<
(hostname: string, options: { all: true }) => Promise<Array<{ address: string; family: number }>>
>;
const mockedCreateSSRFSafeUndiciConnect = createSSRFSafeUndiciConnect as jest.MockedFunction<
typeof createSSRFSafeUndiciConnect
>;
beforeEach(() => {
mockedLookup.mockReset();
mockedLookup.mockResolvedValue([{ address: '203.0.113.10', family: 4 }]);
});
function getLookupCallback(
optionsOrCallback: unknown,
maybeCallback?: LookupCallback,
@ -534,6 +548,60 @@ async function createHeaderCaptureServer(): Promise<HeaderCaptureServer> {
};
}
async function createTunnelProxyCaptureServer(): Promise<HeaderCaptureServer> {
const headers: http.IncomingHttpHeaders[] = [];
const requests: CapturedRequest[] = [];
const server = http.createServer((_req, res) => {
res.writeHead(502);
res.end();
});
server.on('connect', (req, clientSocket, head) => {
headers.push({ ...req.headers });
requests.push({
method: 'CONNECT',
headers: { ...req.headers },
body: req.url ?? '',
});
let buffer = Buffer.from(head);
let responded = false;
const respondIfRequestComplete = () => {
if (responded || !buffer.includes('\r\n\r\n')) {
return;
}
responded = true;
const requestLine = buffer.toString('utf8').split('\r\n')[0] ?? '';
requests.push({
method: requestLine.split(' ')[0] ?? '',
headers: {},
body: requestLine,
});
clientSocket.write(
'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}',
);
clientSocket.end();
};
clientSocket.on('error', () => undefined);
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
clientSocket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
respondIfRequestComplete();
});
respondIfRequestComplete();
});
const destroySockets = trackSockets(server);
const port = await getFreePort();
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve));
return {
url: `http://127.0.0.1:${port}/`,
receivedHeaders: headers,
receivedRequests: requests,
close: destroySockets,
};
}
async function createRawResponseServer(handler: http.RequestListener): Promise<RawResponseServer> {
const server = http.createServer(handler);
const destroySockets = trackSockets(server);
@ -940,6 +1008,803 @@ describe('MCP SSRF protection customFetch input shapes', () => {
return factory.call(connection, () => null, undefined, undefined, undefined, undefined, true);
}
function createBaseUrlFetch(connection: MCPConnection, baseUrl: string): CustomFetch {
const factory = (
connection as unknown as {
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
}
).createFetchFunction;
return factory.call(connection, () => null, undefined, 300000, undefined, baseUrl);
}
function createBaseUrlDispatchers(connection: MCPConnection, baseUrl: string): string[] {
const privateSelf = connection as unknown as {
agents: Array<{ constructor: { name: string } }>;
};
createBaseUrlFetch(connection, baseUrl);
return privateSelf.agents.map((agent) => agent.constructor.name);
}
const proxyEnvKeys = [
'PROXY',
'HTTP_PROXY',
'HTTPS_PROXY',
'NO_PROXY',
'http_proxy',
'https_proxy',
'no_proxy',
] as const;
type ProxyEnvKey = (typeof proxyEnvKeys)[number];
function snapshotProxyEnv(): Partial<Record<ProxyEnvKey, string>> {
const snapshot: Partial<Record<ProxyEnvKey, string>> = {};
for (const key of proxyEnvKeys) {
if (process.env[key] != null) {
snapshot[key] = process.env[key];
}
}
return snapshot;
}
function restoreProxyEnv(snapshot: Partial<Record<ProxyEnvKey, string>>): void {
for (const key of proxyEnvKeys) {
if (snapshot[key] == null) {
delete process.env[key];
} else {
process.env[key] = snapshot[key];
}
}
}
function clearProxyEnv(): void {
for (const key of proxyEnvKeys) {
delete process.env[key];
}
}
it('should allocate proxy dispatchers for streamable-http when proxy is configured', () => {
conn = new MCPConnection({
serverName: 'customfetch-proxy-dispatchers',
serverConfig: {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
proxy: 'http://proxy.example.com:8080',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'https://mcp.example.com/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([
'ProxyAgent',
'ProxyAgent',
]);
});
it('should use the PROXY env var for streamable-http when server proxy is not configured', () => {
const originalProxy = process.env.PROXY;
process.env.PROXY = 'http://env-proxy.example.com:8080';
try {
conn = new MCPConnection({
serverName: 'customfetch-env-proxy-dispatchers',
serverConfig: {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'https://mcp.example.com/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([
'ProxyAgent',
'ProxyAgent',
]);
} finally {
if (originalProxy == null) {
delete process.env.PROXY;
} else {
process.env.PROXY = originalProxy;
}
}
});
it('should use standard HTTP proxy env vars for streamable-http when PROXY is absent', () => {
const originalProxy = process.env.PROXY;
const originalHttpProxy = process.env.HTTP_PROXY;
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const originalLowerHttpProxy = process.env.http_proxy;
const originalLowerHttpsProxy = process.env.https_proxy;
const originalLowerNoProxy = process.env.no_proxy;
delete process.env.PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
delete process.env.no_proxy;
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080';
process.env.NO_PROXY = 'localhost,127.0.0.1';
try {
conn = new MCPConnection({
serverName: 'customfetch-standard-env-proxy-dispatchers',
serverConfig: {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'https://mcp.example.com/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([
'ProxyAgent',
'ProxyAgent',
]);
} finally {
if (originalProxy == null) {
delete process.env.PROXY;
} else {
process.env.PROXY = originalProxy;
}
if (originalHttpProxy == null) {
delete process.env.HTTP_PROXY;
} else {
process.env.HTTP_PROXY = originalHttpProxy;
}
if (originalHttpsProxy == null) {
delete process.env.HTTPS_PROXY;
} else {
process.env.HTTPS_PROXY = originalHttpsProxy;
}
if (originalNoProxy == null) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalLowerHttpProxy == null) {
delete process.env.http_proxy;
} else {
process.env.http_proxy = originalLowerHttpProxy;
}
if (originalLowerHttpsProxy == null) {
delete process.env.https_proxy;
} else {
process.env.https_proxy = originalLowerHttpsProxy;
}
if (originalLowerNoProxy == null) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalLowerNoProxy;
}
}
});
it('should honor NO_PROXY when standard HTTP proxy env vars are configured', () => {
const originalProxy = process.env.PROXY;
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const originalLowerHttpsProxy = process.env.https_proxy;
const originalLowerNoProxy = process.env.no_proxy;
delete process.env.PROXY;
delete process.env.https_proxy;
delete process.env.no_proxy;
process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080';
process.env.NO_PROXY = 'mcp.example.com';
try {
conn = new MCPConnection({
serverName: 'customfetch-standard-env-no-proxy',
serverConfig: {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'https://mcp.example.com/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']);
} finally {
if (originalProxy == null) {
delete process.env.PROXY;
} else {
process.env.PROXY = originalProxy;
}
if (originalHttpsProxy == null) {
delete process.env.HTTPS_PROXY;
} else {
process.env.HTTPS_PROXY = originalHttpsProxy;
}
if (originalNoProxy == null) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalLowerHttpsProxy == null) {
delete process.env.https_proxy;
} else {
process.env.https_proxy = originalLowerHttpsProxy;
}
if (originalLowerNoProxy == null) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalLowerNoProxy;
}
}
});
it('should honor bare IPv6 NO_PROXY entries without parsing a port suffix', () => {
const originalProxy = process.env.PROXY;
const originalHttpProxy = process.env.HTTP_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const originalLowerHttpProxy = process.env.http_proxy;
const originalLowerNoProxy = process.env.no_proxy;
delete process.env.PROXY;
delete process.env.http_proxy;
delete process.env.no_proxy;
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.NO_PROXY = '::1';
try {
conn = new MCPConnection({
serverName: 'customfetch-standard-env-no-proxy-ipv6',
serverConfig: {
type: 'streamable-http',
url: 'http://[::1]:3000/mcp',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'http://[::1]:3000/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']);
} finally {
if (originalProxy == null) {
delete process.env.PROXY;
} else {
process.env.PROXY = originalProxy;
}
if (originalHttpProxy == null) {
delete process.env.HTTP_PROXY;
} else {
process.env.HTTP_PROXY = originalHttpProxy;
}
if (originalNoProxy == null) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalLowerHttpProxy == null) {
delete process.env.http_proxy;
} else {
process.env.http_proxy = originalLowerHttpProxy;
}
if (originalLowerNoProxy == null) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalLowerNoProxy;
}
}
});
it('should honor wildcard tokens in NO_PROXY lists', () => {
const originalProxy = process.env.PROXY;
const originalHttpProxy = process.env.HTTP_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const originalLowerHttpProxy = process.env.http_proxy;
const originalLowerNoProxy = process.env.no_proxy;
delete process.env.PROXY;
delete process.env.http_proxy;
delete process.env.no_proxy;
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.NO_PROXY = 'localhost,*';
try {
conn = new MCPConnection({
serverName: 'customfetch-standard-env-no-proxy-wildcard-list',
serverConfig: {
type: 'streamable-http',
url: 'http://mcp.example.com/mcp',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'http://mcp.example.com/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']);
} finally {
if (originalProxy == null) {
delete process.env.PROXY;
} else {
process.env.PROXY = originalProxy;
}
if (originalHttpProxy == null) {
delete process.env.HTTP_PROXY;
} else {
process.env.HTTP_PROXY = originalHttpProxy;
}
if (originalNoProxy == null) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalLowerHttpProxy == null) {
delete process.env.http_proxy;
} else {
process.env.http_proxy = originalLowerHttpProxy;
}
if (originalLowerNoProxy == null) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalLowerNoProxy;
}
}
});
it('should honor CIDR and IP range patterns in NO_PROXY lists', async () => {
const originalEnv = snapshotProxyEnv();
clearProxyEnv();
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.NO_PROXY = '10.0.0.0/8,192.168.1.10-192.168.1.20';
const expectDispatcherNamesForUrl = async (
url: string,
expectedNames: string[],
): Promise<void> => {
await safeDisconnect(conn);
conn = new MCPConnection({
serverName: `customfetch-no-proxy-${url}`,
serverConfig: {
type: 'streamable-http',
url,
},
useSSRFProtection: false,
});
expect(createBaseUrlDispatchers(conn, url)).toEqual(expectedNames);
};
try {
await expectDispatcherNamesForUrl('http://10.2.3.4/mcp', ['Agent', 'Agent']);
await expectDispatcherNamesForUrl('http://192.168.1.15/mcp', ['Agent', 'Agent']);
await expectDispatcherNamesForUrl('http://192.168.1.25/mcp', ['ProxyAgent', 'ProxyAgent']);
} finally {
restoreProxyEnv(originalEnv);
}
});
it('should match NO_PROXY host entries like undici env proxy agents', async () => {
const originalEnv = snapshotProxyEnv();
clearProxyEnv();
process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080';
const expectDispatcherNamesForUrl = async (
noProxy: string,
url: string,
expectedNames: string[],
): Promise<void> => {
await safeDisconnect(conn);
process.env.NO_PROXY = noProxy;
conn = new MCPConnection({
serverName: `customfetch-no-proxy-host-${noProxy}-${url}`,
serverConfig: {
type: 'streamable-http',
url,
},
useSSRFProtection: false,
});
expect(createBaseUrlDispatchers(conn, url)).toEqual(expectedNames);
};
try {
await expectDispatcherNamesForUrl('example.com', 'https://example.com/mcp', [
'Agent',
'Agent',
]);
await expectDispatcherNamesForUrl('example.com', 'https://api.example.com/mcp', [
'Agent',
'Agent',
]);
await expectDispatcherNamesForUrl('*.example.com', 'https://api.example.com/mcp', [
'Agent',
'Agent',
]);
await expectDispatcherNamesForUrl('*.example.com', 'https://example.com/mcp', [
'Agent',
'Agent',
]);
await expectDispatcherNamesForUrl('.example.com', 'https://example.com/mcp', [
'Agent',
'Agent',
]);
await expectDispatcherNamesForUrl('.example.com', 'https://api.example.com/mcp', [
'Agent',
'Agent',
]);
await expectDispatcherNamesForUrl('example.com', 'https://badexample.com/mcp', [
'ProxyAgent',
'ProxyAgent',
]);
} finally {
restoreProxyEnv(originalEnv);
}
});
it('should let empty lowercase proxy env vars disable uppercase fallbacks', () => {
const originalProxy = process.env.PROXY;
const originalHttpProxy = process.env.HTTP_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const originalLowerHttpProxy = process.env.http_proxy;
const originalLowerNoProxy = process.env.no_proxy;
delete process.env.PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.http_proxy = '';
try {
conn = new MCPConnection({
serverName: 'customfetch-standard-empty-lowercase-proxy',
serverConfig: {
type: 'streamable-http',
url: 'http://mcp.example.com/mcp',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'http://mcp.example.com/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']);
} finally {
if (originalProxy == null) {
delete process.env.PROXY;
} else {
process.env.PROXY = originalProxy;
}
if (originalHttpProxy == null) {
delete process.env.HTTP_PROXY;
} else {
process.env.HTTP_PROXY = originalHttpProxy;
}
if (originalNoProxy == null) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalLowerHttpProxy == null) {
delete process.env.http_proxy;
} else {
process.env.http_proxy = originalLowerHttpProxy;
}
if (originalLowerNoProxy == null) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalLowerNoProxy;
}
}
});
it('should let empty lowercase no_proxy disable uppercase fallbacks', () => {
const originalProxy = process.env.PROXY;
const originalHttpProxy = process.env.HTTP_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const originalLowerHttpProxy = process.env.http_proxy;
const originalLowerNoProxy = process.env.no_proxy;
delete process.env.PROXY;
delete process.env.http_proxy;
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.NO_PROXY = 'mcp.example.com';
process.env.no_proxy = '';
try {
conn = new MCPConnection({
serverName: 'customfetch-standard-empty-lowercase-no-proxy',
serverConfig: {
type: 'streamable-http',
url: 'http://mcp.example.com/mcp',
},
useSSRFProtection: false,
});
const privateSelf = conn as unknown as {
agents: Array<{ constructor: { name: string } }>;
createFetchFunction: (
getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
) => CustomFetch;
};
privateSelf.createFetchFunction.call(
conn,
() => null,
undefined,
300000,
undefined,
'http://mcp.example.com/mcp',
);
expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([
'ProxyAgent',
'ProxyAgent',
]);
} finally {
if (originalProxy == null) {
delete process.env.PROXY;
} else {
process.env.PROXY = originalProxy;
}
if (originalHttpProxy == null) {
delete process.env.HTTP_PROXY;
} else {
process.env.HTTP_PROXY = originalHttpProxy;
}
if (originalNoProxy == null) {
delete process.env.NO_PROXY;
} else {
process.env.NO_PROXY = originalNoProxy;
}
if (originalLowerHttpProxy == null) {
delete process.env.http_proxy;
} else {
process.env.http_proxy = originalLowerHttpProxy;
}
if (originalLowerNoProxy == null) {
delete process.env.no_proxy;
} else {
process.env.no_proxy = originalLowerNoProxy;
}
}
});
it('should recompute proxy dispatchers from the resolved request URL', async () => {
const originalEnv = snapshotProxyEnv();
const capture = await createHeaderCaptureServer();
clearProxyEnv();
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.NO_PROXY = '127.0.0.1';
try {
conn = new MCPConnection({
serverName: 'customfetch-recompute-proxy-dispatcher',
serverConfig: {
type: 'streamable-http',
url: 'http://mcp.example.com/mcp',
},
useSSRFProtection: false,
});
const customFetch = createBaseUrlFetch(conn, 'http://mcp.example.com/mcp');
const response = await customFetch(capture.url);
expect(response.status).toBe(200);
await response.body?.cancel();
expect(capture.receivedRequests).toHaveLength(1);
expect(
(conn as unknown as { agents: Array<{ constructor: { name: string } }> }).agents.map(
(agent) => agent.constructor.name,
),
).toEqual(['ProxyAgent', 'ProxyAgent', 'Agent']);
} finally {
restoreProxyEnv(originalEnv);
await capture.close();
}
});
it('should preflight proxied targets before dispatching network requests', async () => {
mockedResolveHostnameSSRF.mockResolvedValueOnce(true);
conn = new MCPConnection({
serverName: 'customfetch-proxy-ssrf',
serverConfig: {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
proxy: 'http://proxy.example.com:8080',
},
useSSRFProtection: true,
});
const customFetch = getCustomFetch(conn);
await expect(customFetch('http://blocked.example.com/mcp')).rejects.toThrow(
/proxied MCP request target/,
);
expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith('blocked.example.com', null, '80');
});
it('should fail closed when proxied target DNS cannot be resolved before dispatch', async () => {
mockedResolveHostnameSSRF.mockResolvedValueOnce(false);
mockedLookup.mockRejectedValueOnce(
Object.assign(new Error('getaddrinfo ENOTFOUND'), {
code: 'ENOTFOUND',
}),
);
conn = new MCPConnection({
serverName: 'customfetch-proxy-ssrf-dns-fail-closed',
serverConfig: {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
proxy: 'http://proxy.example.com:8080',
},
useSSRFProtection: true,
});
const customFetch = getCustomFetch(conn);
await expect(customFetch('http://proxy-only.internal/mcp')).rejects.toThrow(
/could not be resolved before proxying/,
);
expect(mockedLookup).toHaveBeenCalledWith('proxy-only.internal', { all: true });
});
it('should skip proxied DNS preflight for explicitly allowed target hosts', async () => {
const proxy = await createTunnelProxyCaptureServer();
mockedResolveHostnameSSRF.mockClear();
mockedLookup.mockClear();
mockedLookup.mockRejectedValueOnce(
Object.assign(new Error('getaddrinfo ENOTFOUND'), {
code: 'ENOTFOUND',
}),
);
try {
conn = new MCPConnection({
serverName: 'customfetch-proxy-ssrf-allowed-dns',
serverConfig: {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
proxy: proxy.url,
},
useSSRFProtection: true,
allowedAddresses: ['proxy-only.internal:80'],
});
const customFetch = getCustomFetch(conn);
const response = await customFetch('http://proxy-only.internal/mcp');
expect(response.status).toBe(200);
await response.body?.cancel().catch(() => undefined);
expect(proxy.receivedRequests[0]?.method).toBe('CONNECT');
expect(mockedResolveHostnameSSRF).not.toHaveBeenCalled();
expect(mockedLookup).not.toHaveBeenCalled();
} finally {
await proxy.close();
}
});
it.each<['string' | 'URL' | 'Request']>([['string'], ['URL'], ['Request']])(
'should accept a %s input without throwing on URL derivation',
async (shape) => {

View file

@ -150,6 +150,18 @@ describe('Environment Variable Extraction (MCP)', () => {
expect(result.headers).toEqual(options.headers);
});
it('should validate proxy URLs for remote HTTP transports', () => {
const options = {
type: 'streamable-http',
url: 'https://example.com/api',
proxy: 'http://proxy.example.com:8080',
};
const result = StreamableHTTPOptionsSchema.parse(options);
expect(result.proxy).toBe('http://proxy.example.com:8080');
});
it('should accept "http" as an alias for "streamable-http"', () => {
const options = {
type: 'http',
@ -324,6 +336,20 @@ describe('Environment Variable Extraction (MCP)', () => {
});
});
it('should process proxy in streamable-http options', () => {
process.env.MCP_PROXY_URL = 'http://proxy.example.com:8080';
const options: MCPOptions = {
type: 'streamable-http',
url: 'https://example.com',
proxy: '${MCP_PROXY_URL}',
};
const result = processMCPEnv({ options });
expect('proxy' in result && result.proxy).toBe('http://proxy.example.com:8080');
delete process.env.MCP_PROXY_URL;
});
it('should maintain streamable-http type in processed options', () => {
const options: MCPOptions = {
type: 'streamable-http',

View file

@ -1,6 +1,8 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { EventEmitter } from 'events';
import { logger } from '@librechat/data-schemas';
import { fetch as undiciFetch, Agent } from 'undici';
import { fetch as undiciFetch, Agent, ProxyAgent } from 'undici';
import {
StdioClientTransport,
getDefaultEnvironment,
@ -15,16 +17,38 @@ import type {
RequestInit as UndiciRequestInit,
RequestInfo as UndiciRequestInfo,
Response as UndiciResponse,
Dispatcher,
} from 'undici';
import type { MCPOAuthTokens } from './oauth/types';
import type * as t from './types';
import { createSSRFSafeUndiciConnect, resolveHostnameSSRF } from '~/auth';
import { createSSRFSafeUndiciConnect, isSSRFTarget, resolveHostnameSSRF } from '~/auth';
import { isAddressAllowed } from '~/auth/domain';
import { runOutsideTracing } from '~/utils/tracing';
import { sanitizeUrlForLogging } from './utils';
import { withTimeout } from '~/utils/promise';
import { mcpConfig } from './mcpConfig';
type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
type ManagedDispatcher = Agent | ProxyAgent;
type ParsedIP = { version: 4 | 6; bits: 32 | 128; value: bigint };
const BIGINT_ZERO = BigInt(0);
const BIGINT_ONE = BigInt(1);
const BIGINT_EIGHT = BigInt(8);
const BIGINT_SIXTEEN = BigInt(16);
const UINT16_MASK = BigInt(0xffff);
type MCPProxyConfig =
| {
type: 'explicit';
proxyUrl: string;
}
| {
type: 'env';
httpProxy?: string;
httpsProxy?: string;
noProxy?: string;
};
function isStdioOptions(options: t.MCPOptions): options is t.StdioOptions {
return 'command' in options;
@ -273,10 +297,7 @@ async function guardMCPStreamableHTTPResponse(
const sseEventDataLines: string[] = [];
const unresolvedRequestIds = new Set(context.requestIds ?? []);
const buildAndLogBlockedError = (
reason: string,
details: Record<string, unknown>,
): Error => {
const buildAndLogBlockedError = (reason: string, details: Record<string, unknown>): Error => {
const message = buildBlockedMCPResponseMessage(reason, {
maxResponseBytes,
maxLineBytes,
@ -509,7 +530,7 @@ function normalizeInitHeaders(init: UndiciRequestInit | undefined): Record<strin
function buildFetchInit(
init: UndiciRequestInit | undefined,
dispatcher: Agent,
dispatcher: Dispatcher,
requestHeaders: Record<string, string> | null | undefined,
): UndiciRequestInit {
const hasInitHeaders = init?.headers != null;
@ -541,6 +562,343 @@ function getUrlPort(url: URL | string): string {
return '';
}
function getTrimmedEnv(...keys: string[]): string | undefined {
for (const key of keys) {
const rawValue = process.env[key];
if (rawValue != null) {
return rawValue.trim() || undefined;
}
}
return undefined;
}
function getMCPProxyConfig(options: t.MCPOptions): MCPProxyConfig | undefined {
const configuredProxy =
'proxy' in options && typeof options.proxy === 'string' ? options.proxy.trim() : '';
if (configuredProxy) {
return { type: 'explicit', proxyUrl: configuredProxy };
}
const libreChatProxy = process.env.PROXY?.trim() ?? '';
if (libreChatProxy) {
return { type: 'explicit', proxyUrl: libreChatProxy };
}
const httpProxy = getTrimmedEnv('http_proxy', 'HTTP_PROXY');
const httpsProxy = getTrimmedEnv('https_proxy', 'HTTPS_PROXY');
if (!httpProxy && !httpsProxy) {
return undefined;
}
return {
type: 'env',
httpProxy,
httpsProxy,
noProxy: getTrimmedEnv('no_proxy', 'NO_PROXY'),
};
}
function parseIPv4ToBigInt(ip: string): bigint | null {
const octets = ip.split('.');
if (octets.length !== 4) {
return null;
}
let value = BIGINT_ZERO;
for (const octet of octets) {
if (!/^\d{1,3}$/.test(octet)) {
return null;
}
const parsed = Number.parseInt(octet, 10);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 255) {
return null;
}
value = (value << BIGINT_EIGHT) + BigInt(parsed);
}
return value;
}
function parseIPv6ToBigInt(ip: string): bigint | null {
let normalized = ip.toLowerCase().replace(/^\[|\]$/g, '');
const zoneIndex = normalized.indexOf('%');
if (zoneIndex !== -1) {
normalized = normalized.slice(0, zoneIndex);
}
if (normalized.includes('.')) {
const lastColon = normalized.lastIndexOf(':');
if (lastColon === -1) {
return null;
}
const ipv4Value = parseIPv4ToBigInt(normalized.slice(lastColon + 1));
if (ipv4Value == null) {
return null;
}
const hi = Number((ipv4Value >> BIGINT_SIXTEEN) & UINT16_MASK).toString(16);
const lo = Number(ipv4Value & UINT16_MASK).toString(16);
normalized = `${normalized.slice(0, lastColon)}:${hi}:${lo}`;
}
const halves = normalized.split('::');
if (halves.length > 2) {
return null;
}
const left = halves[0] ? halves[0].split(':') : [];
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
const missing = halves.length === 2 ? 8 - left.length - right.length : 0;
if (missing < 0 || (halves.length === 1 && left.length !== 8)) {
return null;
}
const parts = [...left, ...Array<string>(missing).fill('0'), ...right];
if (parts.length !== 8 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
return null;
}
return parts.reduce(
(value, part) => (value << BIGINT_SIXTEEN) + BigInt(Number.parseInt(part, 16)),
BIGINT_ZERO,
);
}
function parseIPLiteral(value: string): ParsedIP | null {
const normalized = value
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
const version = isIP(normalized);
if (version === 4) {
const parsed = parseIPv4ToBigInt(normalized);
return parsed == null ? null : { version: 4, bits: 32, value: parsed };
}
if (version === 6) {
const parsed = parseIPv6ToBigInt(normalized);
return parsed == null ? null : { version: 6, bits: 128, value: parsed };
}
return null;
}
function ipMatchesCIDR(hostname: string, cidr: string): boolean {
const [rangeAddress, prefixLength, extra] = cidr.split('/');
if (!rangeAddress || prefixLength == null || extra != null || !/^\d+$/.test(prefixLength)) {
return false;
}
const hostIP = parseIPLiteral(hostname);
const rangeIP = parseIPLiteral(rangeAddress);
if (!hostIP || !rangeIP || hostIP.version !== rangeIP.version) {
return false;
}
const prefix = Number.parseInt(prefixLength, 10);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > rangeIP.bits) {
return false;
}
const bits = BigInt(rangeIP.bits);
const mask =
prefix === 0
? BIGINT_ZERO
: (((BIGINT_ONE << bits) - BIGINT_ONE) << BigInt(rangeIP.bits - prefix)) &
((BIGINT_ONE << bits) - BIGINT_ONE);
return (hostIP.value & mask) === (rangeIP.value & mask);
}
function ipMatchesRange(hostname: string, range: string): boolean {
const [startAddress, endAddress, extra] = range.split('-');
if (!startAddress || !endAddress || extra != null) {
return false;
}
const hostIP = parseIPLiteral(hostname);
const startIP = parseIPLiteral(startAddress);
const endIP = parseIPLiteral(endAddress);
if (
!hostIP ||
!startIP ||
!endIP ||
hostIP.version !== startIP.version ||
hostIP.version !== endIP.version
) {
return false;
}
const min = startIP.value <= endIP.value ? startIP.value : endIP.value;
const max = startIP.value <= endIP.value ? endIP.value : startIP.value;
return hostIP.value >= min && hostIP.value <= max;
}
function matchesNoProxyIPPattern(hostname: string, entryHostname: string): boolean {
if (entryHostname.includes('/')) {
return ipMatchesCIDR(hostname, entryHostname);
}
if (entryHostname.includes('-')) {
return ipMatchesRange(hostname, entryHostname);
}
return false;
}
function getProxyEntryPort(entry: string): {
hostname: string;
port: number;
} {
const trimmed = entry.trim();
const bracketed = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/);
if (bracketed) {
return {
hostname: bracketed[1].toLowerCase(),
port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : 0,
};
}
const separatorCount = (trimmed.match(/:/g) ?? []).length;
const parsed = separatorCount === 1 ? trimmed.match(/^(.+):(\d+)$/) : null;
const hostname = (parsed ? parsed[1] : trimmed).replace(/^\[|\]$/g, '').toLowerCase();
return {
hostname: hostname.replace(/^\*?\./, ''),
port: parsed ? Number.parseInt(parsed[2], 10) : 0,
};
}
function shouldBypassEnvProxy(url: URL, noProxy?: string): boolean {
if (!noProxy) {
return false;
}
const trimmed = noProxy.trim();
if (!trimmed) {
return false;
}
if (trimmed === '*') {
return true;
}
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const port = Number.parseInt(getUrlPort(url), 10) || 0;
for (const entry of trimmed.split(/[,\s]/)) {
if (!entry) {
continue;
}
if (entry === '*') {
return true;
}
const proxyEntry = getProxyEntryPort(entry);
if (proxyEntry.port && proxyEntry.port !== port) {
continue;
}
if (matchesNoProxyIPPattern(hostname, proxyEntry.hostname)) {
return true;
}
if (hostname === proxyEntry.hostname || hostname.endsWith(`.${proxyEntry.hostname}`)) {
return true;
}
}
return false;
}
function getProxyUrlForRequest(
proxyConfig: MCPProxyConfig | undefined,
urlString: string,
): string | undefined {
if (!proxyConfig || !urlString) {
return undefined;
}
if (proxyConfig.type === 'explicit') {
return proxyConfig.proxyUrl;
}
const url = new URL(urlString);
if (shouldBypassEnvProxy(url, proxyConfig.noProxy)) {
return undefined;
}
if (url.protocol === 'https:') {
return proxyConfig.httpsProxy ?? proxyConfig.httpProxy;
}
if (url.protocol === 'http:') {
return proxyConfig.httpProxy;
}
return undefined;
}
function createMCPDispatcher(options: {
bodyTimeout: number;
headersTimeout: number;
proxyUrl?: string;
keepAliveTimeout?: number;
keepAliveMaxTimeout?: number;
connect?: ReturnType<typeof createSSRFSafeUndiciConnect>;
}): ManagedDispatcher {
const { bodyTimeout, headersTimeout, proxyUrl, keepAliveTimeout, keepAliveMaxTimeout, connect } =
options;
const baseOptions = {
bodyTimeout,
headersTimeout,
...(keepAliveTimeout != null ? { keepAliveTimeout } : {}),
...(keepAliveMaxTimeout != null ? { keepAliveMaxTimeout } : {}),
};
if (proxyUrl) {
return new ProxyAgent({
uri: proxyUrl,
...baseOptions,
});
}
return new Agent({
...baseOptions,
...(connect != null ? { connect } : {}),
});
}
async function assertProxiedRequestTargetResolvable(hostname: string): Promise<void> {
if (parseIPLiteral(hostname)) {
return;
}
try {
await lookup(hostname, { all: true });
} catch {
throw new Error(
`SSRF protection: proxied MCP request target "${hostname}" could not be resolved before proxying`,
);
}
}
async function assertProxiedRequestTargetAllowed(
urlString: string,
proxyConfig: MCPProxyConfig | undefined,
useSSRFProtection: boolean,
allowedAddresses?: string[] | null,
): Promise<void> {
if (!proxyConfig || !useSSRFProtection) {
return;
}
const targetUrl = new URL(urlString);
const port = getUrlPort(targetUrl);
if (isAddressAllowed(targetUrl.hostname, allowedAddresses, port)) {
return;
}
const isBlockedTarget =
isSSRFTarget(targetUrl.hostname, allowedAddresses, port) ||
(await resolveHostnameSSRF(targetUrl.hostname, allowedAddresses, port));
if (!isBlockedTarget) {
await assertProxiedRequestTargetResolvable(targetUrl.hostname);
return;
}
throw new Error(
`SSRF protection: proxied MCP request target "${targetUrl.hostname}" resolved to a private/reserved address`,
);
}
/**
* Drops credential-bearing headers when a 307/308 redirect crosses an origin
* boundary. Removes the always-forbidden set plus any caller-supplied secret
@ -758,7 +1116,7 @@ export class MCPConnection extends EventEmitter {
private isReconnecting = false;
private isInitializing = false;
private reconnectAttempts = 0;
private agents: Agent[] = [];
private agents: Dispatcher[] = [];
private readonly userId?: string;
private lastPingTime: number;
private lastConnectionCheckAt: number = 0;
@ -768,6 +1126,7 @@ export class MCPConnection extends EventEmitter {
private oauthRecovery = false;
private readonly useSSRFProtection: boolean;
private readonly allowedAddresses?: string[] | null;
private readonly proxyConfig?: MCPProxyConfig;
iconPath?: string;
timeout?: number;
sseReadTimeout?: number;
@ -883,6 +1242,7 @@ export class MCPConnection extends EventEmitter {
this.userId = params.userId;
this.useSSRFProtection = params.useSSRFProtection === true;
this.allowedAddresses = params.allowedAddresses ?? null;
this.proxyConfig = getMCPProxyConfig(params.serverConfig);
this.iconPath = params.serverConfig.iconPath;
this.timeout = params.serverConfig.timeout;
this.sseReadTimeout = params.serverConfig.sseReadTimeout;
@ -926,58 +1286,81 @@ export class MCPConnection extends EventEmitter {
baseUrl?: string,
guardStreamableHTTPResponses = false,
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
const basePort = baseUrl ? getUrlPort(baseUrl) : '';
const ssrfConnect = this.useSSRFProtection
? createSSRFSafeUndiciConnect(this.allowedAddresses, basePort)
: undefined;
const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {};
const proxyConfig = this.proxyConfig;
const useSSRFProtection = this.useSSRFProtection;
const allowedAddresses = this.allowedAddresses;
/** Capture only the fields needed by the fetch closure; see factory note above. */
const agents = this.agents;
const logPrefix = this.getLogPrefix();
const effectiveTimeout = timeout || DEFAULT_TIMEOUT;
const postAgent = new Agent({
bodyTimeout: effectiveTimeout,
headersTimeout: effectiveTimeout,
...connectOpts,
});
this.agents.push(postAgent);
const requestDispatchers = new Map<string, ManagedDispatcher>();
const ssrfConnects = new Map<string, ReturnType<typeof createSSRFSafeUndiciConnect>>();
let getAgent: Agent | undefined;
if (sseBodyTimeout != null) {
getAgent = new Agent({
bodyTimeout: sseBodyTimeout,
headersTimeout: effectiveTimeout,
...connectOpts,
});
this.agents.push(getAgent);
}
const getSSRFConnect = (
targetPort: string,
dispatcherAllowedAddresses: string[] | null | undefined,
forceSafeDirectConnect: boolean,
): ReturnType<typeof createSSRFSafeUndiciConnect> => {
const key = `${forceSafeDirectConnect ? 'redirect' : 'configured'}:${targetPort}`;
const existingConnect = ssrfConnects.get(key);
if (existingConnect) {
return existingConnect;
}
const connect = forceSafeDirectConnect
? createSSRFSafeUndiciConnect()
: createSSRFSafeUndiciConnect(dispatcherAllowedAddresses, targetPort);
ssrfConnects.set(key, connect);
return connect;
};
let safeRedirectPostAgent: Agent | undefined;
let safeRedirectGetAgent: Agent | undefined;
/**
* Allowlist mode keeps the original MCP URL admin-approved, but redirect
* targets are server-controlled. These agents add connect-time DNS checks
* for those cross-origin hops so DNS rebinding cannot beat the standalone
* resolveHostnameSSRF pre-check.
* Proxy selection depends on the resolved request URL, not just the
* configured MCP base URL. SSE message endpoints can be absolute URLs, so
* cache dispatchers by the target URL's proxy decision and connect policy.
*/
const createSafeRedirectAgent = (bodyTimeout: number): Agent => {
const redirectSSRFConnect = createSSRFSafeUndiciConnect();
const agent = new Agent({
const getRequestDispatcher = (
isGetRequest: boolean,
targetUrlString: string,
dispatcherAllowedAddresses: string[] | null | undefined,
forceSafeDirectConnect = false,
): ManagedDispatcher => {
const bodyTimeout =
isGetRequest && sseBodyTimeout != null ? sseBodyTimeout : effectiveTimeout;
const proxyUrl = getProxyUrlForRequest(proxyConfig, targetUrlString);
const targetPort = getUrlPort(targetUrlString);
const needsSSRFConnect = !proxyUrl && (useSSRFProtection || forceSafeDirectConnect);
const key = [
bodyTimeout,
proxyUrl ?? 'direct',
needsSSRFConnect ? targetPort : 'open',
forceSafeDirectConnect ? 'redirect' : 'configured',
].join(':');
const existingAgent = requestDispatchers.get(key);
if (existingAgent) {
return existingAgent;
}
const connect = needsSSRFConnect
? getSSRFConnect(targetPort, dispatcherAllowedAddresses, forceSafeDirectConnect)
: undefined;
const agent = createMCPDispatcher({
bodyTimeout,
headersTimeout: effectiveTimeout,
connect: redirectSSRFConnect,
proxyUrl,
...(connect != null ? { connect } : {}),
});
requestDispatchers.set(key, agent);
agents.push(agent);
return agent;
};
const getSafeRedirectDispatcher = (isGetRequest: boolean): Agent => {
if (!isGetRequest || sseBodyTimeout == null) {
safeRedirectPostAgent ??= createSafeRedirectAgent(effectiveTimeout);
return safeRedirectPostAgent;
if (baseUrl) {
getRequestDispatcher(false, baseUrl, allowedAddresses);
if (sseBodyTimeout != null) {
getRequestDispatcher(true, baseUrl, allowedAddresses);
}
safeRedirectGetAgent ??= createSafeRedirectAgent(sseBodyTimeout);
return safeRedirectGetAgent;
};
}
return async function customFetch(
input: UndiciRequestInfo,
@ -995,7 +1378,6 @@ export class MCPConnection extends EventEmitter {
const { urlString, resolvedInit } = await resolveFetchInput(input, init);
const isGet = (resolvedInit?.method ?? 'GET').toUpperCase() === 'GET';
const dispatcher = isGet && getAgent ? getAgent : postAgent;
const requestHeaders = getHeaders();
/**
* Headers that originated from user/server configuration runtime
@ -1008,10 +1390,22 @@ export class MCPConnection extends EventEmitter {
...(configuredSecretHeaderKeys ?? []),
]);
let currentInit = buildFetchInit(resolvedInit, dispatcher, requestHeaders);
let currentUrlString = urlString;
let currentAllowedAddresses = allowedAddresses;
let forceRedirectSSRFConnect = false;
let currentInit = buildFetchInit(
resolvedInit,
getRequestDispatcher(isGet, currentUrlString, currentAllowedAddresses),
requestHeaders,
);
const originalOrigin = new URL(currentUrlString).origin;
for (let redirects = 0; ; redirects++) {
await assertProxiedRequestTargetAllowed(
currentUrlString,
proxyConfig,
useSSRFProtection,
currentAllowedAddresses,
);
const response = await undiciFetch(currentUrlString, currentInit);
const isMethodPreservingRedirect = response.status === 307 || response.status === 308;
const responseContext = {
@ -1051,7 +1445,7 @@ export class MCPConnection extends EventEmitter {
* design letting redirect targets inherit the exemption would open
* an SSRF amplification primitive.
*/
if (await resolveHostnameSSRF(targetUrl.hostname)) {
if (isSSRFTarget(targetUrl.hostname) || (await resolveHostnameSSRF(targetUrl.hostname))) {
logger.warn(
`[MCP] Blocked redirect to private/reserved address: ${sanitizeUrlForLogging(targetUrl)}`,
);
@ -1071,6 +1465,8 @@ export class MCPConnection extends EventEmitter {
}
if (isCrossOriginRedirect) {
currentAllowedAddresses = null;
forceRedirectSSRFConnect = true;
/**
* Once a server-controlled cross-origin hop is seen, keep the safe
* dispatcher for the rest of this redirect chain. Restoring the
@ -1081,7 +1477,22 @@ export class MCPConnection extends EventEmitter {
*/
currentInit = {
...currentInit,
dispatcher: getSafeRedirectDispatcher(isGet),
dispatcher: getRequestDispatcher(
isGet,
targetUrl.href,
currentAllowedAddresses,
forceRedirectSSRFConnect,
),
};
} else {
currentInit = {
...currentInit,
dispatcher: getRequestDispatcher(
isGet,
targetUrl.href,
currentAllowedAddresses,
forceRedirectSSRFConnect,
),
};
}
@ -1177,17 +1588,33 @@ export class MCPConnection extends EventEmitter {
* The connect timeout is extended because proxies may delay initial response.
*/
const sseTimeout = this.timeout || SSE_CONNECT_TIMEOUT;
const ssrfConnect = this.useSSRFProtection
? createSSRFSafeUndiciConnect(this.allowedAddresses, getUrlPort(url))
: undefined;
const sseAgent = new Agent({
bodyTimeout: sseTimeout,
headersTimeout: sseTimeout,
keepAliveTimeout: sseTimeout,
keepAliveMaxTimeout: sseTimeout * 2,
...(ssrfConnect != null ? { connect: ssrfConnect } : {}),
});
this.agents.push(sseAgent);
const sseAgents = new Map<string, ManagedDispatcher>();
const getSSEDispatcher = (targetUrlString: string): ManagedDispatcher => {
const proxyUrl = getProxyUrlForRequest(this.proxyConfig, targetUrlString);
const targetPort = getUrlPort(targetUrlString);
const key = `${proxyUrl ?? 'direct'}:${this.useSSRFProtection && !proxyUrl ? targetPort : 'open'}`;
const existingAgent = sseAgents.get(key);
if (existingAgent) {
return existingAgent;
}
const connect =
this.useSSRFProtection && !proxyUrl
? createSSRFSafeUndiciConnect(this.allowedAddresses, targetPort)
: undefined;
const agent = createMCPDispatcher({
bodyTimeout: sseTimeout,
headersTimeout: sseTimeout,
keepAliveTimeout: sseTimeout,
keepAliveMaxTimeout: sseTimeout * 2,
proxyUrl,
...(connect != null ? { connect } : {}),
});
sseAgents.set(key, agent);
this.agents.push(agent);
return agent;
};
getSSEDispatcher(options.url);
const sseConfiguredSecretHeaderKeys: ReadonlySet<string> = new Set(
Object.keys(headers).map((key) => key.toLowerCase()),
);
@ -1198,15 +1625,25 @@ export class MCPConnection extends EventEmitter {
signal: abortController.signal,
},
eventSourceInit: {
fetch: (url, init) => {
fetch: async (url, init) => {
const { urlString, resolvedInit } = await resolveFetchInput(
url as UndiciRequestInfo,
init as UndiciRequestInit,
);
await assertProxiedRequestTargetAllowed(
urlString,
this.proxyConfig,
this.useSSRFProtection,
this.allowedAddresses,
);
/** Merge headers: SSE defaults < init headers < user headers (user wins) */
const fetchHeaders = new Headers(
Object.assign({}, SSE_REQUEST_HEADERS, init?.headers, headers),
Object.assign({}, SSE_REQUEST_HEADERS, resolvedInit?.headers, headers),
);
return undiciFetch(url, {
...init,
return undiciFetch(urlString, {
...resolvedInit,
redirect: 'manual',
dispatcher: sseAgent,
dispatcher: getSSEDispatcher(urlString),
headers: fetchHeaders,
});
},

View file

@ -990,6 +990,7 @@ describe('processMCPEnv', () => {
process.env.OAUTH_CLIENT_ID = 'oauth-client-id-value';
process.env.OAUTH_CLIENT_SECRET = 'oauth-client-secret-value';
process.env.MCP_SERVER_URL = 'https://mcp.example.com';
process.env.MCP_PROXY_URL = 'http://proxy.example.com:8080';
});
afterEach(() => {
@ -998,6 +999,7 @@ describe('processMCPEnv', () => {
delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_CLIENT_SECRET;
delete process.env.MCP_SERVER_URL;
delete process.env.MCP_PROXY_URL;
});
it('should return null/undefined as-is', () => {
@ -1045,6 +1047,47 @@ describe('processMCPEnv', () => {
});
});
it('should process outbound proxy for remote MCP options', () => {
const options: MCPOptions = {
type: 'sse',
url: '${MCP_SERVER_URL}/sse',
proxy: '${MCP_PROXY_URL}',
};
const result = processMCPEnv({ options });
expect(result).toEqual({
type: 'sse',
url: 'https://mcp.example.com/sse',
proxy: 'http://proxy.example.com:8080',
});
});
it('should not process user-controlled placeholders in outbound proxy', () => {
const user = createTestUser({ id: 'user-proxy-target' });
const body = { conversationId: 'conv-1', parentMessageId: 'parent-1', messageId: 'msg-1' };
const options: MCPOptions = {
type: 'sse',
url: '${MCP_SERVER_URL}/sse',
proxy:
'http://proxy.example.com/{{CUSTOM_PROXY_PATH}}/{{LIBRECHAT_USER_ID}}/{{LIBRECHAT_BODY_MESSAGEID}}',
};
const result = processMCPEnv({
options,
user,
body,
customUserVars: { CUSTOM_PROXY_PATH: 'tenant-proxy' },
});
expect(result).toEqual({
type: 'sse',
url: 'https://mcp.example.com/sse',
proxy:
'http://proxy.example.com/{{CUSTOM_PROXY_PATH}}/{{LIBRECHAT_USER_ID}}/{{LIBRECHAT_BODY_MESSAGEID}}',
});
});
it('should process OAuth configuration with environment variables', () => {
const options: MCPOptions = {
type: 'streamable-http',

View file

@ -271,6 +271,13 @@ function processSingleValue({
return value;
}
function processAdminValue(originalValue: string, dbSourced: boolean): string {
if (typeof originalValue !== 'string') {
return String(originalValue);
}
return dbSourced ? originalValue : extractEnvVariable(originalValue);
}
/**
* Recursively processes an object to replace environment variables in string values
* @param params - Processing parameters
@ -383,6 +390,11 @@ export function processMCPEnv(params: {
});
}
// Process outbound proxy if it exists (for SSE and StreamableHTTP types)
if ('proxy' in newObj && newObj.proxy) {
newObj.proxy = processAdminValue(newObj.proxy, dbSourced);
}
// Process OAuth configuration if it exists (for all transport types)
if ('oauth' in newObj && newObj.oauth) {
const processedOAuth: Record<string, boolean | string | string[] | undefined> = {};

View file

@ -1,4 +1,8 @@
import { SSEOptionsSchema, MCPServerUserInputSchema } from '../src/mcp';
import {
SSEOptionsSchema,
StreamableHTTPOptionsSchema,
MCPServerUserInputSchema,
} from '../src/mcp';
describe('MCPServerUserInputSchema', () => {
describe('env variable exfiltration prevention', () => {
@ -52,6 +56,59 @@ describe('MCPServerUserInputSchema', () => {
});
});
describe('proxy field restrictions', () => {
it('should accept admin-configured proxies for SSE', () => {
const result = SSEOptionsSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.proxy).toBe('http://proxy.example.com:8080');
}
});
it('should accept admin-configured proxies for streamable-http', () => {
const result = StreamableHTTPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.proxy).toBe('http://proxy.example.com:8080');
}
});
it('should reject unsupported proxy protocols', () => {
const result = StreamableHTTPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
proxy: 'ftp://proxy.example.com',
});
expect(result.success).toBe(false);
});
it('should reject SSE proxy configuration from user input', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(false);
});
it('should reject streamable-http proxy configuration from user input', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(false);
});
});
describe('protocol allowlisting', () => {
it('should reject file:// URLs for SSE', () => {
const result = MCPServerUserInputSchema.safeParse({

View file

@ -103,6 +103,25 @@ const BaseOptionsSchema = z.object({
.optional(),
});
const ProxyUrlSchema = z
.string()
.transform((val: string) => extractEnvVariable(val))
.pipe(z.string().url())
.refine(
(val: string) => {
const protocol = new URL(val).protocol;
return (
protocol === 'http:' ||
protocol === 'https:' ||
protocol === 'socks:' ||
protocol === 'socks5:'
);
},
{
message: 'Proxy URL must use http://, https://, socks://, or socks5://',
},
);
export const StdioOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('stdio').default('stdio'),
/**
@ -163,6 +182,8 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
export const SSEOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('sse').default('sse'),
headers: z.record(z.string(), z.string()).optional(),
/** Optional outbound proxy URL for this remote MCP transport */
proxy: ProxyUrlSchema.optional(),
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
@ -181,6 +202,8 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({
export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({
type: z.union([z.literal('streamable-http'), z.literal('http')]),
headers: z.record(z.string(), z.string()).optional(),
/** Optional outbound proxy URL for this remote MCP transport */
proxy: ProxyUrlSchema.optional(),
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
@ -261,9 +284,11 @@ export const MCPServerUserInputSchema = z.union([
url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'),
}),
omitServerManagedFields(SSEOptionsSchema).extend({
proxy: z.never().optional(),
url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'),
}),
omitServerManagedFields(StreamableHTTPOptionsSchema).extend({
proxy: z.never().optional(),
url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'),
}),
]);