diff --git a/client/bin/start.js b/client/bin/start.js index 7e7e013af..e80790e39 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -13,7 +13,13 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms, true)); } -function getClientUrl(port, authDisabled, sessionToken, serverPort) { +function getClientUrl( + port, + authDisabled, + sessionToken, + serverPort, + upstreamSocks5Proxy, +) { const host = process.env.HOST || "localhost"; const baseUrl = `http://${host}:${port}`; @@ -24,6 +30,9 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) { if (!authDisabled) { params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); } + if (upstreamSocks5Proxy) { + params.set("MCP_UPSTREAM_SOCKS5_PROXY", upstreamSocks5Proxy); + } return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl; } @@ -36,6 +45,7 @@ async function startDevServer(serverOptions) { abort, transport, serverUrl, + upstreamSocks5Proxy, } = serverOptions; const serverCommand = "npx"; const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; @@ -51,6 +61,9 @@ async function startDevServer(serverOptions) { MCP_ENV_VARS: JSON.stringify(envVars), ...(transport ? { MCP_TRANSPORT: transport } : {}), ...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}), + ...(upstreamSocks5Proxy + ? { MCP_UPSTREAM_SOCKS5_PROXY: upstreamSocks5Proxy } + : {}), }, signal: abort.signal, echoOutput: true, @@ -91,6 +104,7 @@ async function startProdServer(serverOptions) { mcpServerArgs, transport, serverUrl, + upstreamSocks5Proxy, } = serverOptions; const inspectorServerPath = resolve( __dirname, @@ -110,6 +124,7 @@ async function startProdServer(serverOptions) { : []), ...(transport ? [`--transport=${transport}`] : []), ...(serverUrl ? [`--server-url=${serverUrl}`] : []), + ...(upstreamSocks5Proxy ? [`--socks5=${upstreamSocks5Proxy}`] : []), ], { env: { @@ -138,6 +153,7 @@ async function startDevClient(clientOptions) { sessionToken, abort, cancelled, + upstreamSocks5Proxy, } = clientOptions; const clientCommand = "npx"; const host = process.env.HOST || "localhost"; @@ -163,6 +179,7 @@ async function startDevClient(clientOptions) { authDisabled, sessionToken, SERVER_PORT, + upstreamSocks5Proxy, ); // Give vite time to start before opening or logging the URL @@ -196,6 +213,7 @@ async function startProdClient(clientOptions) { sessionToken, abort, cancelled, + upstreamSocks5Proxy, } = clientOptions; const inspectorClientPath = resolve( __dirname, @@ -210,6 +228,7 @@ async function startProdClient(clientOptions) { authDisabled, sessionToken, SERVER_PORT, + upstreamSocks5Proxy, ); await spawnPromise("node", [inspectorClientPath], { @@ -233,6 +252,7 @@ async function main() { let isDev = false; let transport = null; let serverUrl = null; + let upstreamSocks5Proxy = process.env.MCP_UPSTREAM_SOCKS5_PROXY || null; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -257,6 +277,11 @@ async function main() { continue; } + if (parsingFlags && arg === "--socks5" && i + 1 < args.length) { + upstreamSocks5Proxy = args[++i]; + continue; + } + if (parsingFlags && arg === "-e" && i + 1 < args.length) { const envVar = args[++i]; const equalsIndex = envVar.indexOf("="); @@ -310,6 +335,7 @@ async function main() { mcpServerArgs, transport, serverUrl, + upstreamSocks5Proxy, }; const result = isDev @@ -329,6 +355,7 @@ async function main() { sessionToken, abort, cancelled, + upstreamSocks5Proxy, }; await (isDev diff --git a/client/src/App.tsx b/client/src/App.tsx index 59d15ba06..de52acce7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -724,6 +724,21 @@ const App = () => { if (data.defaultServerUrl) { setSseUrl(data.defaultServerUrl); } + if (data.defaultUpstreamSocks5Proxy) { + setConfig((prev) => { + if (prev.MCP_UPSTREAM_SOCKS5_PROXY.value) { + return prev; + } + + return { + ...prev, + MCP_UPSTREAM_SOCKS5_PROXY: { + ...prev.MCP_UPSTREAM_SOCKS5_PROXY, + value: data.defaultUpstreamSocks5Proxy, + }, + }; + }); + } }) .catch((error) => console.error("Error fetching default environment:", error), diff --git a/client/src/lib/configurationTypes.ts b/client/src/lib/configurationTypes.ts index 60a993564..6e1fbd843 100644 --- a/client/src/lib/configurationTypes.ts +++ b/client/src/lib/configurationTypes.ts @@ -41,6 +41,11 @@ export type InspectorConfig = { */ MCP_PROXY_AUTH_TOKEN: ConfigItem; + /** + * Optional SOCKS5 proxy URL used by the Inspector proxy server when connecting to upstream SSE or Streamable HTTP MCP servers. + */ + MCP_UPSTREAM_SOCKS5_PROXY: ConfigItem; + /** * Default Time-to-Live (TTL) in milliseconds for newly created tasks. */ diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index d986d3802..056647bfc 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -85,6 +85,13 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = { value: "", is_session_item: true, }, + MCP_UPSTREAM_SOCKS5_PROXY: { + label: "Upstream SOCKS5 Proxy", + description: + "Optional SOCKS5 proxy for the Inspector proxy server when it connects to upstream SSE or Streamable HTTP MCP servers. Example: socks5://127.0.0.1:1080", + value: "", + is_session_item: false, + }, MCP_TASK_TTL: { label: "Task TTL", description: diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 016f8aa4f..063aaa00e 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -643,6 +643,8 @@ export function useConnection({ } let mcpProxyServerUrl; + const upstreamSocks5Proxy = config.MCP_UPSTREAM_SOCKS5_PROXY + .value as string; switch (transportType) { case "stdio": { mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); @@ -680,6 +682,12 @@ export function useConnection({ case "sse": { mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); mcpProxyServerUrl.searchParams.append("url", sseUrl); + if (upstreamSocks5Proxy) { + mcpProxyServerUrl.searchParams.append( + "socks5", + upstreamSocks5Proxy, + ); + } const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS .value as string; @@ -711,6 +719,12 @@ export function useConnection({ case "streamable-http": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); mcpProxyServerUrl.searchParams.append("url", sseUrl); + if (upstreamSocks5Proxy) { + mcpProxyServerUrl.searchParams.append( + "socks5", + upstreamSocks5Proxy, + ); + } transportOptions = { authProvider: serverAuthProvider, eventSourceInit: { diff --git a/package-lock.json b/package-lock.json index e1c97e919..83b3ed3bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,9 @@ "node-fetch": "^3.3.2", "open": "^10.2.0", "shell-quote": "^1.8.3", + "socks-proxy-agent": "^10.0.0", "spawn-rx": "^5.1.2", + "tailwind-merge": "^3.6.0", "ts-node": "^10.9.2", "zod": "^3.25.76" }, @@ -167,6 +169,16 @@ } } }, + "client/node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -11979,6 +11991,53 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz", + "integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -12266,9 +12325,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", - "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", "funding": { "type": "github", @@ -13585,6 +13644,7 @@ "express-rate-limit": "^8.2.1", "shell-quote": "^1.8.3", "shx": "^0.3.4", + "socks-proxy-agent": "^10.0.0", "spawn-rx": "^5.1.2", "ws": "^8.18.0", "zod": "^3.25.76" diff --git a/package.json b/package.json index 601ac3e2d..8aef5ae8e 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,9 @@ "node-fetch": "^3.3.2", "open": "^10.2.0", "shell-quote": "^1.8.3", + "socks-proxy-agent": "^10.0.0", "spawn-rx": "^5.1.2", + "tailwind-merge": "^3.6.0", "ts-node": "^10.9.2", "zod": "^3.25.76" }, diff --git a/server/package.json b/server/package.json index 5d348912a..a88cef0ba 100644 --- a/server/package.json +++ b/server/package.json @@ -38,6 +38,7 @@ "@modelcontextprotocol/sdk": "^1.25.2", "cors": "^2.8.5", "express": "^5.1.0", + "socks-proxy-agent": "^10.0.0", "shell-quote": "^1.8.3", "shx": "^0.3.4", "spawn-rx": "^5.1.2", diff --git a/server/src/index.ts b/server/src/index.ts index bdfe49019..94bc94464 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import cors from "cors"; import { parseArgs } from "node:util"; import { parse as shellParseArgs } from "shell-quote"; import nodeFetch, { Headers as NodeHeaders } from "node-fetch"; +import { SocksProxyAgent } from "socks-proxy-agent"; // Type-compatible wrappers for node-fetch to work with browser-style types const fetch = nodeFetch; @@ -53,6 +54,7 @@ const { values } = parseArgs({ command: { type: "string", default: "" }, transport: { type: "string", default: "" }, "server-url": { type: "string", default: "" }, + socks5: { type: "string", default: "" }, }, }); @@ -208,6 +210,31 @@ const updateHeadersInPlace = ( } }; +const createSocksProxyAgent = ( + proxyUrl?: string, +): SocksProxyAgent | undefined => { + if (!proxyUrl) { + return undefined; + } + + let parsedProxyUrl: URL; + try { + parsedProxyUrl = new URL(proxyUrl); + } catch (error) { + throw new Error( + `Invalid SOCKS5 proxy URL: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!["socks5:", "socks5h:"].includes(parsedProxyUrl.protocol)) { + throw new Error( + `Invalid SOCKS5 proxy protocol: ${parsedProxyUrl.protocol}. Use socks5:// or socks5h://.`, + ); + } + + return new SocksProxyAgent(parsedProxyUrl); +}; + const app = express(); app.use(cors()); app.use((req, res, next) => { @@ -342,7 +369,12 @@ const createWebReadableStream = (nodeStream: any): ReadableStream => { * `Content-Type` are preserved. For SSE requests, it also converts Node.js * streams to web-compatible streams. */ -const createCustomFetch = (headerHolder: ProxyHeaderHolder) => { +const createCustomFetch = ( + headerHolder: ProxyHeaderHolder, + socks5ProxyUrl?: string, +) => { + const agent = createSocksProxyAgent(socks5ProxyUrl); + return async ( input: RequestInfo | URL, init?: RequestInit, @@ -370,7 +402,7 @@ const createCustomFetch = (headerHolder: ProxyHeaderHolder) => { // Get the response from node-fetch (cast input and init to handle type differences) const response = await fetch( input as any, - { ...init, headers: headersObject } as any, + { ...init, headers: headersObject, agent } as any, ); if (response.status === 401) { @@ -433,6 +465,11 @@ const createTransport = async ( console.log("Query parameters:", JSON.stringify(query)); const transportType = query.transportType as string; + const socks5ProxyUrl = + (query.socks5 as string | undefined) || + values.socks5 || + process.env.MCP_UPSTREAM_SOCKS5_PROXY || + undefined; if (transportType === "stdio") { const command = (query.command as string).trim(); @@ -466,7 +503,7 @@ const createTransport = async ( const transport = new SSEClientTransport(new URL(url), { eventSourceInit: { - fetch: createCustomFetch(headerHolder), + fetch: createCustomFetch(headerHolder, socks5ProxyUrl), }, requestInit: { headers: headerHolder.headers, @@ -483,7 +520,7 @@ const createTransport = async ( new URL(query.url as string), { // Pass a custom fetch to inject the latest headers on each request - fetch: createCustomFetch(headerHolder), + fetch: createCustomFetch(headerHolder, socks5ProxyUrl), }, ); await transport.start(); @@ -922,6 +959,8 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { defaultArgs: values.args, defaultTransport: values.transport, defaultServerUrl: values["server-url"], + defaultUpstreamSocks5Proxy: + values.socks5 || process.env.MCP_UPSTREAM_SOCKS5_PROXY || "", }); } catch (error) { console.error("Error in /config route:", error);