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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,16 @@ docker run -i --rm \
- `MAX_SESSIONS`: Maximum number of concurrent sessions allowed. Default: `1000`. Valid range: 1-10000. When limit is reached, new connections are rejected with HTTP 503.
- `MAX_REQUESTS_PER_MINUTE`: Rate limit per session in requests per minute. Default: `60`. Valid range: 1-1000. Exceeded requests return HTTP 429.
- `PORT`: Server port. Default: `3002`. Valid range: 1-65535.
- `HTTP_PROXY`: HTTP proxy server URL for outgoing requests. Example: `http://proxy.example.com:8080`. Supports HTTP/HTTPS and SOCKS proxies (URLs starting with `socks://` or `socks5://`). CLI arg: `--http-proxy`
- `HTTPS_PROXY`: HTTPS proxy server URL for outgoing requests. Example: `https://proxy.example.com:8080`. Supports HTTP/HTTPS and SOCKS proxies. CLI arg: `--https-proxy`
- `NO_PROXY`: Comma-separated list of hosts that should bypass the proxy. Supports:
- Exact hostname matches (e.g., `localhost`, `gitlab.internal.com`)
- Domain suffix matches (e.g., `.internal.com` matches any subdomain)
- IP addresses (e.g., `127.0.0.1`, `192.168.1.1`)
- Port-specific matches (e.g., `example.com:443`)
- Wildcard `*` to bypass proxy for all hosts
- Example: `NO_PROXY=localhost,127.0.0.1,.internal.com`
- CLI arg: `--no-proxy`

#### Monitoring Endpoints

Expand Down
79 changes: 74 additions & 5 deletions gitlab-client-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,76 @@ import { HttpsProxyAgent } from "https-proxy-agent";
import { SocksProxyAgent } from "socks-proxy-agent";
import fs from "fs";

/**
* Checks if a URL should bypass the proxy based on NO_PROXY patterns.
* Supports:
* - Exact hostname matches (e.g., "localhost", "gitlab.example.com")
* - Domain suffix matches (e.g., ".example.com" matches "gitlab.example.com")
* - IP addresses (e.g., "127.0.0.1", "192.168.1.1")
* - Wildcard "*" to bypass all proxies
* - Port-specific matches (e.g., "example.com:8080")
*
* @param url The URL to check
* @param noProxy Comma-separated list of patterns from NO_PROXY
* @returns true if the URL should bypass the proxy, false otherwise
*/
function shouldBypassProxy(url: string, noProxy: string | undefined): boolean {
if (!noProxy) {
return false;
}

// Parse URL to get hostname and port
let hostname: string;
let port: string;
let protocol: string;
try {
const parsedUrl = new URL(url);
hostname = parsedUrl.hostname.toLowerCase();
protocol = parsedUrl.protocol;
// Use explicit port if provided, otherwise use default port based on protocol
port = parsedUrl.port || (protocol === 'https:' ? '443' : '80');
} catch {
return false;
}

// Split NO_PROXY into patterns and trim whitespace
const patterns = noProxy.split(',').map(p => p.trim().toLowerCase()).filter(p => p.length > 0);

for (const pattern of patterns) {
// Wildcard matches everything
if (pattern === '*') {
return true;
}

// Handle port-specific patterns (e.g., "example.com:8080")
const [patternHost, patternPort] = pattern.split(':');

// If pattern specifies a port, check if it matches
if (patternPort && port !== patternPort) {
continue;
}
Comment on lines +49 to +55
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldBypassProxy() parses NO_PROXY entries with pattern.split(':'), which breaks IPv6 literals (e.g., ::1) and bracketed IPv6+port forms (e.g., [::1]:443). This will cause proxy bypass to fail for IPv6 hosts. Consider parsing port by taking the last : only when the suffix is a numeric port and the host is not an IPv6 literal (or explicitly support [v6]:port syntax).

Copilot uses AI. Check for mistakes.

// Check for domain suffix match (e.g., ".example.com")
if (patternHost.startsWith('.')) {
const suffix = patternHost.substring(1);
if (hostname === suffix || hostname.endsWith('.' + suffix)) {
return true;
}
}
// Check for exact hostname match
else if (hostname === patternHost) {
return true;
}
}

return false;
}

export interface GitLabClientPoolOptions {
apiUrls?: string[];
httpProxy?: string;
httpsProxy?: string;
noProxy?: string;
rejectUnauthorized?: boolean;
caCertPath?: string;
poolMaxSize?: number;
Expand Down Expand Up @@ -40,7 +106,7 @@ export class GitLabClientPool {
* @returns A `ClientAgents` object containing the configured agents.
*/
private createAgentsForUrl(apiUrl: string): ClientAgents {
const { httpProxy, httpsProxy, rejectUnauthorized, caCertPath } = this.options;
const { httpProxy, httpsProxy, noProxy, rejectUnauthorized, caCertPath } = this.options;
const url = new URL(apiUrl);

let sslOptions: { rejectUnauthorized?: boolean; ca?: Buffer } = {};
Expand All @@ -55,20 +121,23 @@ export class GitLabClientPool {
}
}

// Check if this URL should bypass the proxy
const bypassProxy = shouldBypassProxy(apiUrl, noProxy);

let httpAgent: Agent;
let httpsAgent: HttpsAgent;

// Configure HTTP agent with proxy if specified
if (httpProxy) {
// Configure HTTP agent with proxy if specified and not bypassed
if (httpProxy && !bypassProxy) {
httpAgent = httpProxy.startsWith("socks")
? new SocksProxyAgent(httpProxy)
: new HttpProxyAgent(httpProxy);
} else {
httpAgent = new Agent({ keepAlive: true });
}

// Configure HTTPS agent with proxy and SSL options if specified
if (httpsProxy) {
// Configure HTTPS agent with proxy and SSL options if specified and not bypassed
if (httpsProxy && !bypassProxy) {
httpsAgent = httpsProxy.startsWith("socks")
// The `as any` cast is used here to bypass a TypeScript type mismatch error.
// The `socks-proxy-agent` documentation indicates that TLS options like
Expand Down
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ const PORT = Number.parseInt(getConfig("port", "PORT", "3002"), 10);
// Add proxy configuration
const HTTP_PROXY = getConfig("http-proxy", "HTTP_PROXY");
const HTTPS_PROXY = getConfig("https-proxy", "HTTPS_PROXY");
const NO_PROXY = getConfig("no-proxy", "NO_PROXY");
const NODE_TLS_REJECT_UNAUTHORIZED = getConfig(
"tls-reject-unauthorized",
"NODE_TLS_REJECT_UNAUTHORIZED"
Expand Down Expand Up @@ -582,6 +583,7 @@ const clientPool = new GitLabClientPool({
.map(normalizeGitLabApiUrl),
httpProxy: HTTP_PROXY,
httpsProxy: HTTPS_PROXY,
noProxy: NO_PROXY,
rejectUnauthorized: NODE_TLS_REJECT_UNAUTHORIZED !== "0",
caCertPath: GITLAB_CA_CERT_PATH,
poolMaxSize: GITLAB_POOL_MAX_SIZE,
Expand Down
218 changes: 218 additions & 0 deletions test/no-proxy-integration-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* NO_PROXY Integration Test
* Tests NO_PROXY functionality with mock servers
*/

import { describe, test, after, before } from 'node:test';
import assert from 'node:assert';
import {
launchServer,
findAvailablePort,
cleanupServers,
ServerInstance,
TransportMode,
HOST
} from './utils/server-launcher.js';
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
import { CustomHeaderClient } from './clients/custom-header-client.js';

Comment on lines +1 to +18
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This integration test file is not included in the current test:mock/test:all scripts (which run a fixed list of test files). If it’s meant to be part of the automated suite, it should be added to the test scripts; otherwise it’s easy for NO_PROXY behavior to regress without detection.

Copilot uses AI. Check for mistakes.
// Test constants
const MOCK_TOKEN = 'glpat-mock-token-12345';

// Port ranges
const MOCK_GITLAB_PORT_BASE = 9600;
const MCP_SERVER_PORT_BASE = 3600;

console.log('🌐 NO_PROXY Integration Test Suite');
console.log('');

describe('NO_PROXY Integration Tests', () => {
let mcpUrl: string;
let mockGitLab: MockGitLabServer;
let servers: ServerInstance[] = [];
let mockGitLabUrl: string;
let mockGitLabHost: string;

before(async () => {
// Start mock GitLab server
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
mockGitLab = new MockGitLabServer({
port: mockPort,
validTokens: [MOCK_TOKEN]
});
await mockGitLab.start();
mockGitLabUrl = mockGitLab.getUrl();
// Extract the host:port part for NO_PROXY
const url = new URL(mockGitLabUrl);
mockGitLabHost = url.host; // This includes port if non-standard

console.log(`Mock GitLab: ${mockGitLabUrl}`);
console.log(`Mock GitLab Host: ${mockGitLabHost}`);
});

after(async () => {
cleanupServers(servers);
if (mockGitLab) {
await mockGitLab.stop();
}
});

test('should bypass proxy when hostname is in NO_PROXY', async () => {
// Start MCP server with proxy settings and NO_PROXY
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
const server = await launchServer({
mode: TransportMode.STREAMABLE_HTTP,
port: mcpPort,
timeout: 5000,
env: {
STREAMABLE_HTTP: 'true',
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
// Set a fake proxy that would fail if used
HTTP_PROXY: 'http://nonexistent-proxy.example.com:9999',
HTTPS_PROXY: 'http://nonexistent-proxy.example.com:9999',
// Bypass proxy for our mock GitLab server
NO_PROXY: mockGitLabHost,
}
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

console.log(`MCP Server: ${mcpUrl}`);
console.log(`NO_PROXY: ${mockGitLabHost}`);

// Create client and make a request
const client = new CustomHeaderClient({
'authorization': `Bearer ${MOCK_TOKEN}`,
});
await client.connect(mcpUrl);

// This should succeed because the proxy is bypassed
const result = await client.callTool('list_projects', { per_page: 1 });
console.log(' ✓ Request succeeded with NO_PROXY bypass');

assert.ok(result, 'Request should succeed');
await client.disconnect();
});

test('should use proxy when hostname is NOT in NO_PROXY', async () => {
// Start MCP server with proxy settings but NO_PROXY doesn't match
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 1);
const server = await launchServer({
mode: TransportMode.STREAMABLE_HTTP,
port: mcpPort,
timeout: 5000,
env: {
STREAMABLE_HTTP: 'true',
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
// Set a fake proxy that would fail if used
HTTP_PROXY: 'http://nonexistent-proxy.example.com:9999',
HTTPS_PROXY: 'http://nonexistent-proxy.example.com:9999',
// NO_PROXY doesn't match our server
NO_PROXY: 'different-host.example.com,10.0.0.1',
}
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

console.log(`MCP Server: ${mcpUrl}`);
console.log(`NO_PROXY: different-host.example.com,10.0.0.1 (should NOT match)`);

// Create client and make a request
const client = new CustomHeaderClient({
'authorization': `Bearer ${MOCK_TOKEN}`,
});
await client.connect(mcpUrl);

// This should fail because it tries to use the nonexistent proxy
try {
await client.callTool('list_projects', { per_page: 1 });
assert.fail('Request should have failed due to proxy connection error');
} catch (error: any) {
console.log(' ✓ Request failed as expected (proxy error)');
// Expected to fail with connection/proxy error
assert.ok(error, 'Should throw an error when proxy fails');
}

await client.disconnect();
});

test('should bypass proxy with wildcard NO_PROXY', async () => {
// Start MCP server with wildcard NO_PROXY
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 2);
const server = await launchServer({
mode: TransportMode.STREAMABLE_HTTP,
port: mcpPort,
timeout: 5000,
env: {
STREAMABLE_HTTP: 'true',
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
// Set a fake proxy that would fail if used
HTTP_PROXY: 'http://nonexistent-proxy.example.com:9999',
HTTPS_PROXY: 'http://nonexistent-proxy.example.com:9999',
// Wildcard bypasses all proxies
NO_PROXY: '*',
}
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

console.log(`MCP Server: ${mcpUrl}`);
console.log(`NO_PROXY: * (wildcard - bypasses all)`);

// Create client and make a request
const client = new CustomHeaderClient({
'authorization': `Bearer ${MOCK_TOKEN}`,
});
await client.connect(mcpUrl);

// This should succeed because wildcard bypasses all proxies
const result = await client.callTool('list_projects', { per_page: 1 });
console.log(' ✓ Request succeeded with wildcard NO_PROXY');

assert.ok(result, 'Request should succeed');
await client.disconnect();
});

test('should bypass proxy with exact IP and localhost', async () => {
// Start MCP server with explicit IP and localhost patterns
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 3);
const server = await launchServer({
mode: TransportMode.STREAMABLE_HTTP,
port: mcpPort,
timeout: 5000,
env: {
STREAMABLE_HTTP: 'true',
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
// Set a fake proxy that would fail if used
HTTP_PROXY: 'http://nonexistent-proxy.example.com:9999',
HTTPS_PROXY: 'http://nonexistent-proxy.example.com:9999',
// Use explicit localhost and 127.0.0.1 patterns
NO_PROXY: 'localhost,127.0.0.1',
}
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

console.log(`MCP Server: ${mcpUrl}`);
console.log(`NO_PROXY: localhost,127.0.0.1 (exact matches)`);

// Create client and make a request
const client = new CustomHeaderClient({
'authorization': `Bearer ${MOCK_TOKEN}`,
});
await client.connect(mcpUrl);

// This should succeed because 127.0.0.1 and localhost are explicitly in NO_PROXY
const result = await client.callTool('list_projects', { per_page: 1 });
console.log(' ✓ Request succeeded with exact IP/localhost NO_PROXY match');

assert.ok(result, 'Request should succeed');
await client.disconnect();
});
});

console.log('✅ NO_PROXY integration tests completed');
Loading
Loading