dsh-search-mcp/lib/client.js
oliver ada788a236 Skip MCP era probe for Bailian-compatible connect.
Omit Client versionNegotiation so the SDK stays on legacy initialize; Bailian answers the 2026 server/discover probe with HTTP 500 and never reaches search.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 15:10:46 +08:00

182 lines
6.3 KiB
JavaScript

/**
* MCP transport layer for one search.
*
* 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:
* 1. DSH policy (`proxyRouteFor`) when `@deepseek-ai/dsh-http-proxy` resolves.
* 2. Else env + undici global dispatcher (DSH boot installs proxy from HTTP_PROXY).
* 3. Else DNS-pinned direct dial (SSRF-safe).
*/
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 { resolveEgressRoute } from './proxy-env.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) {
if (!server.toolName) {
throw new WebError(
`search-mcp server "${server.id}": no MCP tool name (set "toolName" or pick a known kind)`,
'WEB_PROVIDER_ERROR',
);
}
let runtime;
// Do not opt into MCP 2026 era negotiation. SDK default is legacy (plain
// 2025 initialize, no server/discover probe). Bailian/DashScope answers
// that probe with HTTP 500 and connect fails before any search call.
const client = new Client(
{ name: 'dsh-search-mcp', version: '0.2.42' },
{ capabilities: {} },
);
try {
runtime = server.transport === 'stdio'
? { transport: stdioTransport(server, key), close: async () => {} }
: await httpRuntime(server, key, signal);
await race(client.connect(runtime.transport), signal, `connect to "${server.id}"`);
const callArgs = { query: args.query };
if (server.countArg.length > 0 && args.maxResults !== undefined) {
callArgs[server.countArg] = clampSearchResults(server, args.maxResults);
}
const result = await race(
client.callTool({ name: server.toolName, arguments: callArgs }),
signal,
`call "${server.id}" tool "${server.toolName}"`,
);
if (result.isError) {
throw new WebError(
`search-mcp: MCP server "${server.id}" tool "${server.toolName}" reported an error`,
'WEB_PROVIDER_ERROR',
);
}
return result;
} catch (error) {
if (error instanceof WebError) throw error;
if (signal?.aborted) throw aborted(`complete request for "${server.id}"`);
const detail = error?.name === 'SearchMcpUrlPolicyError' ? `: ${error.message}` : '';
throw new WebError(
`search-mcp server "${server.id}" request failed${detail}`,
'WEB_PROVIDER_ERROR',
);
} finally {
try {
await client.close();
} catch {
// The connection is already gone.
}
try {
await runtime?.close();
} catch {
// The dedicated dispatcher has no shared state to recover.
}
}
}
/** Build streamable-http transport: proxy when policy/env says so, else DNS-pin. */
async function httpRuntime(server, key, signal) {
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}`;
if (server.authStyle === 'query') url.searchParams.set(server.authParam, value);
else if (server.authStyle === 'header') headers[server.authParam] = value;
}
const route = await resolveEgressRoute(url, isNonPublicIpLiteral);
if (route.proxied) {
return buildTransport(url, headers, signal, route.dispatcher, route.close ?? (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);
if (requestUrl.origin !== expectedOrigin) {
throw new Error('search-mcp URL policy: request origin changed after validation');
}
const fetchOptions = {
...init,
redirect: 'error',
...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}),
};
if (dispatcher !== undefined) fetchOptions.dispatcher = dispatcher;
return undiciFetch(input, fetchOptions);
};
return {
transport: new StreamableHTTPClientTransport(url, {
fetch: secureFetch,
requestInit: {
headers,
redirect: 'error',
...(signal !== undefined ? { signal } : {}),
},
}),
close,
};
}
/** Build a stdio transport; the authParam name doubles as the env var name. */
function stdioTransport(server, key) {
const env = { ...process.env };
if (key !== undefined && key.length > 0 && server.authParam.length > 0) {
env[server.authParam] = `${server.authPrefix ?? ''}${key}`;
}
return new StdioClientTransport({
command: server.command,
args: server.args ?? [],
env,
});
}
/** Race a protocol operation against the caller/timeout abort signal. */
function race(promise, signal, stage) {
if (signal === undefined) return promise;
if (signal.aborted) throw aborted(stage);
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
reject(aborted(stage));
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
}
function combineSignals(base, request) {
if (request === undefined || request === null || request === base) return base;
return AbortSignal.any([base, request]);
}
function aborted(stage) {
return new WebError(`search-mcp: aborted while trying to ${stage}`, 'WEB_ABORTED');
}