mirror of
https://github.com/hansjone/dsh-search-mcp.git
synced 2026-10-16 15:03:18 +08:00
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:
parent
229a36d5f3
commit
26a8c2ecee
5 changed files with 93 additions and 15 deletions
|
|
@ -4,13 +4,22 @@
|
||||||
* Desktop 0.2 ships the split MCP client package (v2). Import that package so
|
* 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
|
* 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:
|
||||||
|
* - 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 { 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 { validateHttpEndpoint } from './url-policy.js';
|
import {
|
||||||
|
isNonPublicIpLiteral,
|
||||||
|
parseHttpEndpoint,
|
||||||
|
validateHttpEndpoint,
|
||||||
|
} from './url-policy.js';
|
||||||
|
|
||||||
/** Run one search through a resolved server entry. */
|
/** Run one search through a resolved server entry. */
|
||||||
export async function callMcpSearch(server, key, args, signal) {
|
export async function callMcpSearch(server, key, args, signal) {
|
||||||
|
|
@ -23,7 +32,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.39' },
|
{ name: 'dsh-search-mcp', version: '0.2.40' },
|
||||||
{ capabilities: {}, versionNegotiation: { mode: 'auto' } },
|
{ capabilities: {}, versionNegotiation: { mode: 'auto' } },
|
||||||
);
|
);
|
||||||
try {
|
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) {
|
async function httpRuntime(server, key, signal) {
|
||||||
const validated = await validateHttpEndpoint(server.url, { signal });
|
const parsed = parseHttpEndpoint(server.url);
|
||||||
const url = new URL(validated.url);
|
const url = new URL(parsed.url);
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (key !== undefined && key.length > 0 && server.authParam.length > 0) {
|
if (key !== undefined && key.length > 0 && server.authParam.length > 0) {
|
||||||
const value = `${server.authPrefix ?? ''}${key}`;
|
const value = `${server.authPrefix ?? ''}${key}`;
|
||||||
|
|
@ -80,11 +103,23 @@ 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
|
||||||
|
// 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({
|
const agent = new Agent({
|
||||||
connect: { lookup: validated.lookup },
|
connect: { lookup: validated.lookup },
|
||||||
connections: validated.addresses.length,
|
connections: validated.addresses.length,
|
||||||
maxRedirections: 0,
|
maxRedirections: 0,
|
||||||
});
|
});
|
||||||
|
return buildTransport(url, headers, signal, agent, () => agent.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransport(url, headers, signal, dispatcher, close) {
|
||||||
const expectedOrigin = url.origin;
|
const expectedOrigin = url.origin;
|
||||||
const secureFetch = async (input, init = {}) => {
|
const secureFetch = async (input, init = {}) => {
|
||||||
const requestUrl = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
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, {
|
return undiciFetch(input, {
|
||||||
...init,
|
...init,
|
||||||
dispatcher: agent,
|
dispatcher,
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}),
|
...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}),
|
||||||
});
|
});
|
||||||
|
|
@ -108,7 +143,7 @@ async function httpRuntime(server, key, signal) {
|
||||||
...(signal !== undefined ? { signal } : {}),
|
...(signal !== undefined ? { signal } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
close: () => agent.close(),
|
close,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ import ipaddr from 'ipaddr.js';
|
||||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse and resolve one HTTP endpoint before any request is sent.
|
* Structural URL checks for one MCP HTTP endpoint (no DNS).
|
||||||
* Every resolved address must be globally routable.
|
* Used by both the direct (DNS-pinned) path and the proxied path.
|
||||||
*/
|
*/
|
||||||
export async function validateHttpEndpoint(input, options = {}) {
|
export function parseHttpEndpoint(input) {
|
||||||
if (options.signal?.aborted) throw abortedPolicyError();
|
|
||||||
if (typeof input !== 'string' || input.length === 0 || input !== input.trim()) {
|
if (typeof input !== 'string' || input.length === 0 || input !== input.trim()) {
|
||||||
throw policyError('endpoint must be a non-empty canonical URL');
|
throw policyError('endpoint must be a non-empty canonical URL');
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +35,27 @@ export async function validateHttpEndpoint(input, options = {}) {
|
||||||
}
|
}
|
||||||
rejectAmbiguousIpv4(input, comparable);
|
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;
|
let addresses;
|
||||||
if (ipaddr.isValid(comparable)) {
|
if (ipaddr.isValid(comparable)) {
|
||||||
addresses = [{ address: normalizeAddress(comparable), family: addressFamily(comparable) }];
|
addresses = [{ address: normalizeAddress(comparable), family: addressFamily(comparable) }];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-search-mcp",
|
"name": "dsh-search-mcp",
|
||||||
"version": "0.2.39",
|
"version": "0.2.40",
|
||||||
"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",
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/dsh-api-remotes": "*",
|
"@deepseek-ai/dsh-api-remotes": "*",
|
||||||
"@deepseek-ai/dsh-credentials": "*",
|
"@deepseek-ai/dsh-credentials": "*",
|
||||||
|
"@deepseek-ai/dsh-http-proxy": "*",
|
||||||
"@deepseek-ai/dsh-launch-environment": "*",
|
"@deepseek-ai/dsh-launch-environment": "*",
|
||||||
"@deepseek-ai/dsh-settings": "*",
|
"@deepseek-ai/dsh-settings": "*",
|
||||||
"@deepseek-ai/dsh-web": "*",
|
"@deepseek-ai/dsh-web": "*",
|
||||||
|
|
@ -64,6 +65,9 @@
|
||||||
"undici": "*"
|
"undici": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
|
"@deepseek-ai/dsh-http-proxy": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"@modelcontextprotocol/client": {
|
"@modelcontextprotocol/client": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.39')
|
assert.equal(pkg.version, '0.2.40')
|
||||||
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'))
|
||||||
|
|
@ -20,6 +20,7 @@ test('package exports resolve and peerDependencies stay open', async () => {
|
||||||
for (const name of [
|
for (const name of [
|
||||||
'@deepseek-ai/dsh-api-remotes',
|
'@deepseek-ai/dsh-api-remotes',
|
||||||
'@deepseek-ai/dsh-credentials',
|
'@deepseek-ai/dsh-credentials',
|
||||||
|
'@deepseek-ai/dsh-http-proxy',
|
||||||
'@deepseek-ai/dsh-launch-environment',
|
'@deepseek-ai/dsh-launch-environment',
|
||||||
'@deepseek-ai/dsh-settings',
|
'@deepseek-ai/dsh-settings',
|
||||||
'@deepseek-ai/dsh-web',
|
'@deepseek-ai/dsh-web',
|
||||||
|
|
@ -85,13 +86,16 @@ test('known providers are CDKey-only while custom keeps advanced fields', async
|
||||||
assert.match(client, /已知提供商不需要填写端点链接/)
|
assert.match(client, /已知提供商不需要填写端点链接/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('HTTP transport pins DNS and applies one guarded fetch to every SDK request', async () => {
|
test('HTTP transport inherits DSH proxy and pins DNS on the direct path', async () => {
|
||||||
const transport = await read('lib/client.js')
|
const transport = await read('lib/client.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, /proxyRouteFor/)
|
||||||
|
assert.match(transport, /parseHttpEndpoint/)
|
||||||
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/)
|
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/)
|
||||||
assert.match(transport, /dispatcher: agent/)
|
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['"]/)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
createPinnedLookup,
|
createPinnedLookup,
|
||||||
isAllowedEndpointAddress,
|
isAllowedEndpointAddress,
|
||||||
|
isNonPublicIpLiteral,
|
||||||
isPublicAddress,
|
isPublicAddress,
|
||||||
|
parseHttpEndpoint,
|
||||||
validateHttpEndpoint,
|
validateHttpEndpoint,
|
||||||
} from '../lib/url-policy.js';
|
} from '../lib/url-policy.js';
|
||||||
|
|
||||||
|
|
@ -12,6 +14,19 @@ const lookup = (records) => (_hostname, options, callback) => {
|
||||||
queueMicrotask(() => callback(null, records));
|
queueMicrotask(() => callback(null, records));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('parseHttpEndpoint accepts structure without DNS', () => {
|
||||||
|
const parsed = parseHttpEndpoint('https://search.example/mcp');
|
||||||
|
assert.equal(parsed.hostname, 'search.example');
|
||||||
|
assert.equal(parsed.url.protocol, 'https:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isNonPublicIpLiteral gates loopback and private literals', () => {
|
||||||
|
assert.equal(isNonPublicIpLiteral('127.0.0.1'), true);
|
||||||
|
assert.equal(isNonPublicIpLiteral('10.0.0.1'), true);
|
||||||
|
assert.equal(isNonPublicIpLiteral('8.8.8.8'), false);
|
||||||
|
assert.equal(isNonPublicIpLiteral('search.example'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('URL policy accepts HTTP(S) with public DNS only', async () => {
|
test('URL policy accepts HTTP(S) with public DNS only', async () => {
|
||||||
const result = await validateHttpEndpoint('https://search.example/mcp', {
|
const result = await validateHttpEndpoint('https://search.example/mcp', {
|
||||||
lookup: lookup([
|
lookup: lookup([
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue