Inherit DSH process proxy for search-mcp HTTP egress.

Use proxyRouteFor when a policy is installed so HTTPS_PROXY/system proxy is not bypassed by the DNS-pinned Agent; keep the pinned direct path otherwise.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-10 12:04:38 +08:00
parent 229a36d5f3
commit 26a8c2ecee
5 changed files with 93 additions and 15 deletions

View file

@ -4,13 +4,22 @@
* Desktop 0.2 ships the split MCP client package (v2). Import that package so
* link:/Desktop profiles can resolve it from the host graph without a local
* `node_modules` copy of the legacy monolith SDK.
*
* HTTP egress follows web-fetch-http:
* - When DSH installed a process proxy policy (`proxyRouteFor` → proxied),
* reuse that dispatcher so HTTPS_PROXY / system proxy is inherited.
* - Otherwise keep the DNS-pinned undici Agent (SSRF-safe direct dial).
*/
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { WebError } from '@deepseek-ai/dsh-web';
import { Agent, fetch as undiciFetch } from 'undici';
import { clampSearchResults } from './catalog.js';
import { validateHttpEndpoint } from './url-policy.js';
import {
isNonPublicIpLiteral,
parseHttpEndpoint,
validateHttpEndpoint,
} from './url-policy.js';
/** Run one search through a resolved server entry. */
export async function callMcpSearch(server, key, args, signal) {
@ -23,7 +32,7 @@ export async function callMcpSearch(server, key, args, signal) {
let runtime;
const client = new Client(
{ name: 'dsh-search-mcp', version: '0.2.39' },
{ name: 'dsh-search-mcp', version: '0.2.40' },
{ capabilities: {}, versionNegotiation: { mode: 'auto' } },
);
try {
@ -69,10 +78,24 @@ export async function callMcpSearch(server, key, args, signal) {
}
}
/** Build a DNS-pinned streamable-http transport and its cleanup. */
/**
* 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) {
const validated = await validateHttpEndpoint(server.url, { signal });
const url = new URL(validated.url);
const parsed = parseHttpEndpoint(server.url);
const url = new URL(parsed.url);
const headers = {};
if (key !== undefined && key.length > 0 && server.authParam.length > 0) {
const value = `${server.authPrefix ?? ''}${key}`;
@ -80,11 +103,23 @@ async function httpRuntime(server, key, signal) {
else if (server.authStyle === 'header') headers[server.authParam] = value;
}
// Proxied hops must not pin local DNS — that would dial the origin directly
// and bypass the proxy (same rule as web-fetch-http).
const route = await resolveProxyRoute(url);
if (route.proxied && !isNonPublicIpLiteral(url.hostname)) {
return buildTransport(url, headers, signal, route.dispatcher, async () => {});
}
const validated = await validateHttpEndpoint(server.url, { signal });
const agent = new Agent({
connect: { lookup: validated.lookup },
connections: validated.addresses.length,
maxRedirections: 0,
});
return buildTransport(url, headers, signal, agent, () => agent.close());
}
function buildTransport(url, headers, signal, dispatcher, close) {
const expectedOrigin = url.origin;
const secureFetch = async (input, init = {}) => {
const requestUrl = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
@ -93,7 +128,7 @@ async function httpRuntime(server, key, signal) {
}
return undiciFetch(input, {
...init,
dispatcher: agent,
dispatcher,
redirect: 'error',
...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}),
});
@ -108,7 +143,7 @@ async function httpRuntime(server, key, signal) {
...(signal !== undefined ? { signal } : {}),
},
}),
close: () => agent.close(),
close,
};
}

View file

@ -4,11 +4,10 @@ import ipaddr from 'ipaddr.js';
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
/**
* Parse and resolve one HTTP endpoint before any request is sent.
* Every resolved address must be globally routable.
* Structural URL checks for one MCP HTTP endpoint (no DNS).
* Used by both the direct (DNS-pinned) path and the proxied path.
*/
export async function validateHttpEndpoint(input, options = {}) {
if (options.signal?.aborted) throw abortedPolicyError();
export function parseHttpEndpoint(input) {
if (typeof input !== 'string' || input.length === 0 || input !== input.trim()) {
throw policyError('endpoint must be a non-empty canonical URL');
}
@ -36,6 +35,27 @@ export async function validateHttpEndpoint(input, options = {}) {
}
rejectAmbiguousIpv4(input, comparable);
return Object.freeze({ url, hostname: comparable });
}
/**
* True when the host is a literal address that must not go through a local proxy
* (loopback / private / link-local). Matches web-fetch-http's proxy gate.
*/
export function isNonPublicIpLiteral(hostname) {
const unbracketed = stripIpv6Brackets(hostname);
if (!ipaddr.isValid(unbracketed)) return false;
return !isPublicAddress(unbracketed);
}
/**
* Parse and resolve one HTTP endpoint before any direct (non-proxy) request.
* Every resolved address must be globally routable (or Clash fake-IP).
*/
export async function validateHttpEndpoint(input, options = {}) {
if (options.signal?.aborted) throw abortedPolicyError();
const { url, hostname: comparable } = parseHttpEndpoint(input);
let addresses;
if (ipaddr.isValid(comparable)) {
addresses = [{ address: normalizeAddress(comparable), family: addressFamily(comparable) }];