mirror of
https://github.com/hansjone/dsh-search-mcp.git
synced 2026-10-15 03:40:44 +08:00
Fallback to env proxy when dsh-http-proxy cannot resolve on link installs.
Use process HTTP_PROXY/HTTPS_PROXY and undici global dispatcher when the policy module import fails, so symlinked desktop plugins still egress through the corporate proxy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
26a8c2ecee
commit
4b4a1e4460
5 changed files with 197 additions and 35 deletions
|
|
@ -5,16 +5,17 @@
|
||||||
* link:/Desktop profiles can resolve it from the host graph without a local
|
* link:/Desktop profiles can resolve it from the host graph without a local
|
||||||
* `node_modules` copy of the legacy monolith SDK.
|
* `node_modules` copy of the legacy monolith SDK.
|
||||||
*
|
*
|
||||||
* HTTP egress follows web-fetch-http:
|
* HTTP egress:
|
||||||
* - When DSH installed a process proxy policy (`proxyRouteFor` → proxied),
|
* 1. DSH policy (`proxyRouteFor`) when `@deepseek-ai/dsh-http-proxy` resolves.
|
||||||
* reuse that dispatcher so HTTPS_PROXY / system proxy is inherited.
|
* 2. Else env + undici global dispatcher (DSH boot installs proxy from HTTP_PROXY).
|
||||||
* - Otherwise keep the DNS-pinned undici Agent (SSRF-safe direct dial).
|
* 3. Else DNS-pinned direct dial (SSRF-safe).
|
||||||
*/
|
*/
|
||||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||||
import { WebError } from '@deepseek-ai/dsh-web';
|
import { WebError } from '@deepseek-ai/dsh-web';
|
||||||
import { Agent, fetch as undiciFetch } from 'undici';
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
import { clampSearchResults } from './catalog.js';
|
import { clampSearchResults } from './catalog.js';
|
||||||
|
import { resolveEgressRoute } from './proxy-env.js';
|
||||||
import {
|
import {
|
||||||
isNonPublicIpLiteral,
|
isNonPublicIpLiteral,
|
||||||
parseHttpEndpoint,
|
parseHttpEndpoint,
|
||||||
|
|
@ -32,7 +33,7 @@ export async function callMcpSearch(server, key, args, signal) {
|
||||||
|
|
||||||
let runtime;
|
let runtime;
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
{ name: 'dsh-search-mcp', version: '0.2.40' },
|
{ name: 'dsh-search-mcp', version: '0.2.41' },
|
||||||
{ capabilities: {}, versionNegotiation: { mode: 'auto' } },
|
{ capabilities: {}, versionNegotiation: { mode: 'auto' } },
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
|
@ -78,21 +79,7 @@ export async function callMcpSearch(server, key, args, signal) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Build streamable-http transport: proxy when policy/env says so, else DNS-pin. */
|
||||||
* Ask DSH's process-wide proxy policy how to send this URL.
|
|
||||||
* Soft-import so missing peer does not kill plugin boot.
|
|
||||||
*/
|
|
||||||
async function resolveProxyRoute(url) {
|
|
||||||
try {
|
|
||||||
const mod = await import('@deepseek-ai/dsh-http-proxy');
|
|
||||||
if (typeof mod.proxyRouteFor !== 'function') return { proxied: false };
|
|
||||||
return mod.proxyRouteFor(url);
|
|
||||||
} catch {
|
|
||||||
return { proxied: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build streamable-http transport: inherit proxy when installed, else DNS-pin. */
|
|
||||||
async function httpRuntime(server, key, signal) {
|
async function httpRuntime(server, key, signal) {
|
||||||
const parsed = parseHttpEndpoint(server.url);
|
const parsed = parseHttpEndpoint(server.url);
|
||||||
const url = new URL(parsed.url);
|
const url = new URL(parsed.url);
|
||||||
|
|
@ -103,11 +90,9 @@ async function httpRuntime(server, key, signal) {
|
||||||
else if (server.authStyle === 'header') headers[server.authParam] = value;
|
else if (server.authStyle === 'header') headers[server.authParam] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxied hops must not pin local DNS — that would dial the origin directly
|
const route = await resolveEgressRoute(url, isNonPublicIpLiteral);
|
||||||
// and bypass the proxy (same rule as web-fetch-http).
|
if (route.proxied) {
|
||||||
const route = await resolveProxyRoute(url);
|
return buildTransport(url, headers, signal, route.dispatcher, route.close ?? (async () => {}));
|
||||||
if (route.proxied && !isNonPublicIpLiteral(url.hostname)) {
|
|
||||||
return buildTransport(url, headers, signal, route.dispatcher, async () => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validated = await validateHttpEndpoint(server.url, { signal });
|
const validated = await validateHttpEndpoint(server.url, { signal });
|
||||||
|
|
@ -126,12 +111,13 @@ function buildTransport(url, headers, signal, dispatcher, close) {
|
||||||
if (requestUrl.origin !== expectedOrigin) {
|
if (requestUrl.origin !== expectedOrigin) {
|
||||||
throw new Error('search-mcp URL policy: request origin changed after validation');
|
throw new Error('search-mcp URL policy: request origin changed after validation');
|
||||||
}
|
}
|
||||||
return undiciFetch(input, {
|
const fetchOptions = {
|
||||||
...init,
|
...init,
|
||||||
dispatcher,
|
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}),
|
...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}),
|
||||||
});
|
};
|
||||||
|
if (dispatcher !== undefined) fetchOptions.dispatcher = dispatcher;
|
||||||
|
return undiciFetch(input, fetchOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
108
lib/proxy-env.js
Normal file
108
lib/proxy-env.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Proxy resolution for search-mcp HTTP egress.
|
||||||
|
*
|
||||||
|
* Primary: DSH's installed policy (`proxyRouteFor`) when the module resolves.
|
||||||
|
* Fallback: process env + undici global dispatcher — covers link: installs where
|
||||||
|
* `@deepseek-ai/dsh-http-proxy` is not reachable from the plugin source tree
|
||||||
|
* even though DSH boot already installed proxy from the same env vars.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** First non-empty proxy URL from standard env names. */
|
||||||
|
export function readProxyEnv() {
|
||||||
|
for (const name of [
|
||||||
|
'HTTPS_PROXY', 'https_proxy',
|
||||||
|
'HTTP_PROXY', 'http_proxy',
|
||||||
|
'ALL_PROXY', 'all_proxy',
|
||||||
|
]) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) return value.trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective NO_PROXY list (may include CIDR entries we do not interpret). */
|
||||||
|
export function readNoProxyEnv() {
|
||||||
|
return (process.env.NO_PROXY ?? process.env.no_proxy ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suffix / host bypass — same rules as dsh-http-proxy (no CIDR matching).
|
||||||
|
* @param {URL} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function bypassesEnvNoProxy(url) {
|
||||||
|
const noProxy = readNoProxyEnv();
|
||||||
|
if (noProxy.length === 0) return false;
|
||||||
|
const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
||||||
|
const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80';
|
||||||
|
for (const raw of noProxy.split(/[,\s]+/)) {
|
||||||
|
const entry = raw.trim().toLowerCase();
|
||||||
|
if (entry.length === 0) continue;
|
||||||
|
if (entry === '*') return true;
|
||||||
|
const colon = entry.lastIndexOf(':');
|
||||||
|
let candidate = entry;
|
||||||
|
let entryPort;
|
||||||
|
if (colon > 0 && /^\d+$/.test(entry.slice(colon + 1))) {
|
||||||
|
entryPort = entry.slice(colon + 1);
|
||||||
|
candidate = entry.slice(0, colon);
|
||||||
|
}
|
||||||
|
if (entryPort !== undefined && entryPort !== port) continue;
|
||||||
|
candidate = candidate.replace(/^\*?\./, '').replace(/\.$/, '');
|
||||||
|
if (candidate.length === 0) continue;
|
||||||
|
if (host === candidate || host.endsWith(`.${candidate}`)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this URL should use a proxy based on ambient env (fallback path).
|
||||||
|
* @param {URL} url
|
||||||
|
* @param {(host: string) => boolean} isNonPublicIpLiteral
|
||||||
|
*/
|
||||||
|
export function shouldUseProcessProxy(url, isNonPublicIpLiteral) {
|
||||||
|
if (isNonPublicIpLiteral(url.hostname)) return false;
|
||||||
|
if (readProxyEnv() === undefined) return false;
|
||||||
|
return !bypassesEnvNoProxy(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve egress: DSH policy module first, then env/global dispatcher fallback.
|
||||||
|
* @param {URL} url
|
||||||
|
* @param {(host: string) => boolean} isNonPublicIpLiteral
|
||||||
|
* @returns {Promise<{ proxied: boolean, dispatcher?: import('undici').Dispatcher, close?: () => Promise<void> | void }>}
|
||||||
|
*/
|
||||||
|
export async function resolveEgressRoute(url, isNonPublicIpLiteral) {
|
||||||
|
if (isNonPublicIpLiteral(url.hostname)) {
|
||||||
|
return { proxied: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = await import('@deepseek-ai/dsh-http-proxy');
|
||||||
|
if (typeof mod.proxyRouteFor === 'function') {
|
||||||
|
const route = mod.proxyRouteFor(url);
|
||||||
|
if (route.proxied && route.dispatcher) {
|
||||||
|
return { proxied: true, dispatcher: route.dispatcher, close: async () => {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// link: source trees often cannot resolve this peer from D:\code\gpt\...
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldUseProcessProxy(url, isNonPublicIpLiteral)) {
|
||||||
|
return { proxied: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ProxyAgent, getGlobalDispatcher } = await import('undici');
|
||||||
|
// Prefer the dispatcher DSH installed at boot from the same env vars.
|
||||||
|
try {
|
||||||
|
const global = getGlobalDispatcher();
|
||||||
|
if (global) {
|
||||||
|
return { proxied: true, dispatcher: global, close: async () => {} };
|
||||||
|
}
|
||||||
|
} catch { /* undici unavailable */ }
|
||||||
|
|
||||||
|
const proxyUrl = readProxyEnv();
|
||||||
|
if (proxyUrl === undefined) return { proxied: false };
|
||||||
|
const agent = new ProxyAgent(proxyUrl);
|
||||||
|
return { proxied: true, dispatcher: agent, close: () => agent.close() };
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-search-mcp",
|
"name": "dsh-search-mcp",
|
||||||
"version": "0.2.40",
|
"version": "0.2.41",
|
||||||
"description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.",
|
"description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check": "node --check lib/index.js && node --check lib/provider.js && node --check lib/catalog.js && node --check lib/extract.js && node --check lib/url-policy.js && node --check lib/client.js && node --check lib/client.browser.js",
|
"check": "node --check lib/index.js && node --check lib/provider.js && node --check lib/catalog.js && node --check lib/extract.js && node --check lib/url-policy.js && node --check lib/proxy-env.js && node --check lib/client.js && node --check lib/client.browser.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ test('package exports resolve and peerDependencies stay open', async () => {
|
||||||
assert.equal(pkg.exports['.'], './lib/index.js')
|
assert.equal(pkg.exports['.'], './lib/index.js')
|
||||||
assert.equal(pkg.exports['./client'], './lib/client.browser.js')
|
assert.equal(pkg.exports['./client'], './lib/client.browser.js')
|
||||||
assert.equal(pkg.engines.node, '>=20')
|
assert.equal(pkg.engines.node, '>=20')
|
||||||
assert.equal(pkg.version, '0.2.40')
|
assert.equal(pkg.version, '0.2.41')
|
||||||
assert.equal(pkg.dsh.client.immediately, false)
|
assert.equal(pkg.dsh.client.immediately, false)
|
||||||
assert.equal(pkg.dependencies['@modelcontextprotocol/client'], '2.0.0')
|
assert.equal(pkg.dependencies['@modelcontextprotocol/client'], '2.0.0')
|
||||||
assert.ok(!Object.hasOwn(pkg.dependencies, '@modelcontextprotocol/sdk'))
|
assert.ok(!Object.hasOwn(pkg.dependencies, '@modelcontextprotocol/sdk'))
|
||||||
|
|
@ -86,16 +86,18 @@ test('known providers are CDKey-only while custom keeps advanced fields', async
|
||||||
assert.match(client, /已知提供商不需要填写端点链接/)
|
assert.match(client, /已知提供商不需要填写端点链接/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('HTTP transport inherits DSH proxy and pins DNS on the direct path', async () => {
|
test('HTTP transport inherits DSH proxy with env fallback and pins DNS on direct path', async () => {
|
||||||
const transport = await read('lib/client.js')
|
const transport = await read('lib/client.js')
|
||||||
|
const proxyEnv = await read('lib/proxy-env.js')
|
||||||
assert.match(transport, /from '@modelcontextprotocol\/client'/)
|
assert.match(transport, /from '@modelcontextprotocol\/client'/)
|
||||||
assert.match(transport, /from '@modelcontextprotocol\/client\/stdio'/)
|
assert.match(transport, /from '@modelcontextprotocol\/client\/stdio'/)
|
||||||
assert.doesNotMatch(transport, /from ['"]@modelcontextprotocol\/sdk/)
|
assert.doesNotMatch(transport, /from ['"]@modelcontextprotocol\/sdk/)
|
||||||
assert.match(transport, /@deepseek-ai\/dsh-http-proxy/)
|
assert.match(transport, /resolveEgressRoute/)
|
||||||
assert.match(transport, /proxyRouteFor/)
|
assert.match(proxyEnv, /proxyRouteFor/)
|
||||||
|
assert.match(proxyEnv, /readProxyEnv/)
|
||||||
|
assert.match(proxyEnv, /getGlobalDispatcher/)
|
||||||
assert.match(transport, /parseHttpEndpoint/)
|
assert.match(transport, /parseHttpEndpoint/)
|
||||||
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/)
|
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/)
|
||||||
assert.match(transport, /dispatcher/)
|
|
||||||
assert.match(transport, /redirect: 'error'/)
|
assert.match(transport, /redirect: 'error'/)
|
||||||
assert.match(transport, /versionNegotiation:\s*\{\s*mode:\s*['"]auto['"]/)
|
assert.match(transport, /versionNegotiation:\s*\{\s*mode:\s*['"]auto['"]/)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
66
test/proxy-env.test.js
Normal file
66
test/proxy-env.test.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
bypassesEnvNoProxy,
|
||||||
|
readProxyEnv,
|
||||||
|
shouldUseProcessProxy,
|
||||||
|
} from '../lib/proxy-env.js';
|
||||||
|
|
||||||
|
const nonPublic = (host) => host === '127.0.0.1' || host === '10.0.0.1';
|
||||||
|
|
||||||
|
test('readProxyEnv picks HTTPS_PROXY over HTTP_PROXY', () => {
|
||||||
|
const prev = {
|
||||||
|
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||||
|
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||||
|
};
|
||||||
|
process.env.HTTPS_PROXY = 'http://proxy.example:8080';
|
||||||
|
process.env.HTTP_PROXY = 'http://other:8080';
|
||||||
|
try {
|
||||||
|
assert.equal(readProxyEnv(), 'http://proxy.example:8080');
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of Object.entries(prev)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bypassesEnvNoProxy matches suffix entries', () => {
|
||||||
|
const prev = process.env.NO_PROXY;
|
||||||
|
process.env.NO_PROXY = 'localhost,.zte.com.cn';
|
||||||
|
try {
|
||||||
|
assert.equal(bypassesEnvNoProxy(new URL('https://api.zte.com.cn/v1')), true);
|
||||||
|
assert.equal(bypassesEnvNoProxy(new URL('https://dashscope.aliyuncs.com/mcp')), false);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.NO_PROXY;
|
||||||
|
else process.env.NO_PROXY = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldUseProcessProxy respects NO_PROXY bypass', () => {
|
||||||
|
const prev = {
|
||||||
|
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||||
|
NO_PROXY: process.env.NO_PROXY,
|
||||||
|
};
|
||||||
|
process.env.HTTPS_PROXY = 'http://proxy.zte.com.cn:80';
|
||||||
|
process.env.NO_PROXY = '.zte.com.cn';
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
shouldUseProcessProxy(new URL('https://dashscope.aliyuncs.com/mcp'), nonPublic),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldUseProcessProxy(new URL('https://llm.zte.com.cn/v1'), nonPublic),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldUseProcessProxy(new URL('https://127.0.0.1/mcp'), nonPublic),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of Object.entries(prev)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue