mirror of
https://github.com/hansjone/dsh-search-mcp.git
synced 2026-10-09 05:30:49 +08:00
Publish dsh-search-mcp fork for newer DSH and Bailian WebSearch MCP.
Based on gxpppp/dsh-search-mcp; includes DSH web settings fixes, Clash fake-IP URL policy, and Bailian default server patch. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
d5c4d2006c
16 changed files with 4821 additions and 0 deletions
137
lib/catalog.js
Normal file
137
lib/catalog.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Search-MCP provider catalog.
|
||||
*
|
||||
* Known providers are intentionally connection-opaque to Settings clients: the
|
||||
* host owns their endpoint, transport, authentication contract and tool name.
|
||||
* `custom` is the only kind whose connection details come from the user.
|
||||
*/
|
||||
export const SEARCH_MCP_CATALOG = {
|
||||
tavily: {
|
||||
transport: 'http',
|
||||
url: 'https://mcp.tavily.com/mcp/',
|
||||
authStyle: 'query',
|
||||
authParam: 'tavilyApiKey',
|
||||
toolName: 'tavily_search',
|
||||
countArg: 'max_results',
|
||||
minResults: 5,
|
||||
maxResultsLimit: 20,
|
||||
apiKeyEnv: 'TAVILY_API_KEY',
|
||||
needsKey: true,
|
||||
},
|
||||
brave: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', '@brave/brave-search-mcp-server@2.1.3'],
|
||||
authStyle: 'env',
|
||||
authParam: 'BRAVE_API_KEY',
|
||||
toolName: 'brave_web_search',
|
||||
countArg: 'count',
|
||||
minResults: 1,
|
||||
maxResultsLimit: 20,
|
||||
apiKeyEnv: 'BRAVE_API_KEY',
|
||||
needsKey: true,
|
||||
},
|
||||
exa: {
|
||||
transport: 'http',
|
||||
url: 'https://mcp.exa.ai/mcp',
|
||||
authStyle: 'header',
|
||||
authParam: 'x-api-key',
|
||||
toolName: 'web_search_exa',
|
||||
countArg: 'numResults',
|
||||
apiKeyEnv: 'EXA_API_KEY',
|
||||
needsKey: true,
|
||||
},
|
||||
perplexity: {
|
||||
transport: 'http',
|
||||
url: 'https://api.perplexity.ai/mcp',
|
||||
authStyle: 'header',
|
||||
authParam: 'Authorization',
|
||||
authPrefix: 'Bearer ',
|
||||
toolName: 'perplexity_search',
|
||||
countArg: 'max_results',
|
||||
minResults: 1,
|
||||
maxResultsLimit: 20,
|
||||
apiKeyEnv: 'PERPLEXITY_API_KEY',
|
||||
needsKey: true,
|
||||
},
|
||||
duckduckgo: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'duckduckgo-mcp-server@0.1.2'],
|
||||
authStyle: 'env',
|
||||
authParam: '',
|
||||
toolName: 'duckduckgo_web_search',
|
||||
countArg: 'count',
|
||||
minResults: 1,
|
||||
maxResultsLimit: 20,
|
||||
needsKey: false,
|
||||
},
|
||||
custom: {
|
||||
transport: 'http',
|
||||
url: '',
|
||||
authStyle: 'query',
|
||||
authParam: '',
|
||||
authPrefix: '',
|
||||
toolName: '',
|
||||
countArg: '',
|
||||
needsKey: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** The provider ids offered in the settings UI. */
|
||||
export const KNOWN_KINDS = Object.keys(SEARCH_MCP_CATALOG);
|
||||
|
||||
export function clampSearchResults(server, value) {
|
||||
const minimum = server.minResults ?? 1;
|
||||
const maximum = server.maxResultsLimit ?? value;
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
/** Resolve a stored entry without allowing known-provider connection overrides. */
|
||||
export function resolveServer(server) {
|
||||
const kind = typeof server?.kind === 'string' && Object.hasOwn(SEARCH_MCP_CATALOG, server.kind)
|
||||
? server.kind
|
||||
: 'custom';
|
||||
const preset = SEARCH_MCP_CATALOG[kind];
|
||||
if (kind !== 'custom') {
|
||||
return {
|
||||
id: typeof server.id === 'string' ? server.id : '',
|
||||
kind,
|
||||
apiKey: typeof server.apiKey === 'string' ? server.apiKey : undefined,
|
||||
apiKeyEnv: typeof server.apiKeyEnv === 'string' ? server.apiKeyEnv : '',
|
||||
maxResults: server.maxResults,
|
||||
transport: preset.transport,
|
||||
url: preset.url ?? '',
|
||||
command: preset.command ?? '',
|
||||
args: [...(preset.args ?? [])],
|
||||
authStyle: preset.authStyle ?? '',
|
||||
authParam: preset.authParam ?? '',
|
||||
authPrefix: preset.authPrefix ?? '',
|
||||
toolName: preset.toolName ?? '',
|
||||
countArg: preset.countArg ?? '',
|
||||
minResults: preset.minResults,
|
||||
maxResultsLimit: preset.maxResultsLimit,
|
||||
needsKey: preset.needsKey ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof server.id === 'string' ? server.id : '',
|
||||
kind: 'custom',
|
||||
apiKey: typeof server.apiKey === 'string' ? server.apiKey : undefined,
|
||||
apiKeyEnv: typeof server.apiKeyEnv === 'string' ? server.apiKeyEnv : '',
|
||||
maxResults: server.maxResults,
|
||||
transport: server.transport || preset.transport || 'http',
|
||||
url: server.url || preset.url || '',
|
||||
command: server.command || preset.command || '',
|
||||
args: Array.isArray(server.args) ? [...server.args] : [...(preset.args ?? [])],
|
||||
authStyle: server.authStyle || preset.authStyle || '',
|
||||
authParam: server.authParam || preset.authParam || '',
|
||||
authPrefix: server.authPrefix || preset.authPrefix || '',
|
||||
toolName: server.toolName || preset.toolName || '',
|
||||
countArg: preset.countArg || '',
|
||||
minResults: preset.minResults,
|
||||
maxResultsLimit: preset.maxResultsLimit,
|
||||
needsKey: preset.needsKey ?? false,
|
||||
};
|
||||
}
|
||||
1077
lib/client.browser.js
Normal file
1077
lib/client.browser.js
Normal file
File diff suppressed because it is too large
Load diff
150
lib/client.js
Normal file
150
lib/client.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/** MCP transport layer for one search. */
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
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';
|
||||
|
||||
/** 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;
|
||||
const client = new Client({ name: 'dsh-search-mcp', version: '0.2.0' }, { 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 a DNS-pinned streamable-http transport and its cleanup. */
|
||||
async function httpRuntime(server, key, signal) {
|
||||
const validated = await validateHttpEndpoint(server.url, { signal });
|
||||
const url = new URL(validated.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 agent = new Agent({
|
||||
connect: { lookup: validated.lookup },
|
||||
connections: validated.addresses.length,
|
||||
maxRedirections: 0,
|
||||
});
|
||||
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');
|
||||
}
|
||||
return undiciFetch(input, {
|
||||
...init,
|
||||
dispatcher: agent,
|
||||
redirect: 'error',
|
||||
...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
transport: new StreamableHTTPClientTransport(url, {
|
||||
fetch: secureFetch,
|
||||
requestInit: {
|
||||
headers,
|
||||
redirect: 'error',
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
},
|
||||
}),
|
||||
close: () => agent.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');
|
||||
}
|
||||
117
lib/extract.js
Normal file
117
lib/extract.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Generic normalization of an MCP `tools/call` result into the
|
||||
* `web_search` provider shape `{ sources, truncated, content? }`.
|
||||
*
|
||||
* Different search MCP servers return wildly different payloads (Tavily
|
||||
* `results[]`, Brave `web.results[]`, Exa `results[]`, Perplexity text +
|
||||
* citations, DuckDuckGo `results[]`...). Instead of mapping each vendor, we
|
||||
* recursively walk the returned JSON and collect every object that carries a
|
||||
* string `url` as a source, taking title / snippet / date from the common
|
||||
* field names. A top-level `answer` (or non-JSON text blocks) becomes the
|
||||
* `content` answer.
|
||||
*/
|
||||
|
||||
const TITLE_KEYS = ['title', 'name', 'headline'];
|
||||
const SNIPPET_KEYS = ['snippet', 'content', 'description', 'text', 'excerpt', 'summary'];
|
||||
const DATE_KEYS = [
|
||||
'published_date',
|
||||
'publishedDate',
|
||||
'published_at',
|
||||
'publish_date',
|
||||
'publishedAt',
|
||||
'page_age',
|
||||
'age',
|
||||
'date',
|
||||
];
|
||||
|
||||
/** Cap a snippet so a single source cannot blow up the context window. */
|
||||
const MAX_SNIPPET_CHARS = 600;
|
||||
/** Cap the answer text block. */
|
||||
const MAX_CONTENT_CHARS = 4000;
|
||||
|
||||
/**
|
||||
* Project one MCP `tools/call` result into `{ sources, truncated, content? }`.
|
||||
*
|
||||
* @param result - the raw `CallToolResult` from the MCP SDK.
|
||||
* @returns the normalized provider result; `truncated` is always false
|
||||
* because the `ctx.web` seam owns the final `maxResults` cap.
|
||||
*/
|
||||
export function extractSearchResult(result) {
|
||||
const bucket = {
|
||||
sources: [],
|
||||
seen: new Set(),
|
||||
content: '',
|
||||
};
|
||||
if (result !== null && typeof result === 'object') {
|
||||
if (result.structuredContent !== undefined) collect(result.structuredContent, bucket);
|
||||
const blocks = Array.isArray(result.content) ? result.content : [];
|
||||
for (const block of blocks) {
|
||||
if (block === null || typeof block !== 'object') continue;
|
||||
if (block.type === 'json' && block.json !== undefined) {
|
||||
collect(block.json, bucket);
|
||||
} else if (block.type === 'text' && typeof block.text === 'string') {
|
||||
const parsed = tryParseJson(block.text);
|
||||
if (parsed !== undefined) collect(parsed, bucket);
|
||||
else if (bucket.content.length === 0 && block.text.trim().length > 0) {
|
||||
bucket.content = block.text.trim().slice(0, MAX_CONTENT_CHARS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources: bucket.sources,
|
||||
truncated: false,
|
||||
...(bucket.content.length > 0 ? { content: bucket.content } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Depth-first walk collecting source objects and the `answer` field. */
|
||||
function collect(node, bucket) {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) collect(item, bucket);
|
||||
return;
|
||||
}
|
||||
if (node === null || typeof node !== 'object') return;
|
||||
if (typeof node.url === 'string' && /^https?:\/\//i.test(node.url)) {
|
||||
if (!bucket.seen.has(node.url)) {
|
||||
bucket.seen.add(node.url);
|
||||
const title = firstOf(node, TITLE_KEYS);
|
||||
const snippet = truncate(firstOf(node, SNIPPET_KEYS), MAX_SNIPPET_CHARS);
|
||||
const publishedAt = firstOf(node, DATE_KEYS);
|
||||
bucket.sources.push({
|
||||
url: node.url,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(snippet !== undefined ? { snippet } : {}),
|
||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (bucket.content.length === 0 && typeof node.answer === 'string' && node.answer.trim().length > 0) {
|
||||
bucket.content = node.answer.trim().slice(0, MAX_CONTENT_CHARS);
|
||||
}
|
||||
for (const value of Object.values(node)) collect(value, bucket);
|
||||
}
|
||||
|
||||
/** First non-empty string among the candidate keys, else undefined. */
|
||||
function firstOf(node, keys) {
|
||||
for (const key of keys) {
|
||||
const value = node[key];
|
||||
if (typeof value === 'string' && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncate(value, max) {
|
||||
if (value === undefined) return undefined;
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
/** Parse a JSON text block; returns undefined when it is not JSON. */
|
||||
function tryParseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
112
lib/index.js
Normal file
112
lib/index.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* dsh-search-mcp — replace dsh's built-in web search with search MCP servers.
|
||||
*
|
||||
* Adapted for DeepSeek Harness 0.1.2+: settings use
|
||||
* `ctx.settings.installSection` (the old free-function
|
||||
* `installSettingsSection` from 0.1.1-rc.2 no longer exists).
|
||||
*
|
||||
* A Cordis plugin that
|
||||
* - registers a `ctx.web` search provider under the id `search-mcp`, and
|
||||
* - installs a Settings section (`search-mcp`) where the user manages the
|
||||
* search MCP server list (kind, endpoint/command, API key or key env
|
||||
* reference, tool name) plus `defaultServer` / `maxResults` /
|
||||
* `searchTimeoutMs` from the web Settings → Plugins page.
|
||||
*
|
||||
* The package's `cordis.patch.yml` (bundle layer) switches
|
||||
* `web.searchProvider` to `search-mcp` and disables the built-in
|
||||
* `web-search-deepseek` provider, so while this plugin is enabled the
|
||||
* built-in search is unavailable and every `web_search` call runs through
|
||||
* the configured MCP server(s). Removing the package restores the built-in.
|
||||
*/
|
||||
import z from '@deepseek-ai/schemastery';
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
||||
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
||||
import { SearchMCPProvider } from './provider.js';
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'search-mcp';
|
||||
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web'];
|
||||
|
||||
const serverSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.string().default('custom'),
|
||||
transport: z.string().default('http'),
|
||||
url: z.string().default(''),
|
||||
command: z.string().default(''),
|
||||
args: z.array(z.string()).default([]),
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref').default(''),
|
||||
authStyle: z.string().default(''),
|
||||
authParam: z.string().default(''),
|
||||
authPrefix: z.string().default(''),
|
||||
toolName: z.string().default(''),
|
||||
// Note: this schemastery fork has no `.optional()`; object fields are
|
||||
// optional unless `.required()` is applied, so absence is already allowed.
|
||||
maxResults: z.number().step(1).min(1).max(50),
|
||||
});
|
||||
|
||||
export const Config = z.object({
|
||||
defaultServer: z.string().default(''),
|
||||
maxResults: z.number().step(1).min(1).max(50).default(8),
|
||||
searchTimeoutMs: z.number().step(1).min(1000).default(30000),
|
||||
servers: z.array(serverSchema).default([]),
|
||||
});
|
||||
|
||||
/** Settings namespace owning this plugin's section (Settings → Plugins card). */
|
||||
export const SEARCH_MCP_SETTINGS_NAMESPACE = 'search-mcp';
|
||||
|
||||
/** Register the search provider and the live settings section. */
|
||||
export function apply(ctx, config) {
|
||||
let current = () => config;
|
||||
// Optional settings seam: fall back to the composition entry when settings
|
||||
// is absent (same pattern as @deepseek-ai/dsh-web-search-deepseek).
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.installSection(ctx, SEARCH_MCP_SETTINGS_NAMESPACE, Config, config, {
|
||||
setSource: (source) => {
|
||||
current = source;
|
||||
},
|
||||
// Provider projects the section per search; no re-registration needed.
|
||||
onChange: () => {},
|
||||
});
|
||||
});
|
||||
// `registerSearchProvider` owns its cleanup via ctx.effect (HMR/dispose safe).
|
||||
ctx.web.registerSearchProvider(new SearchMCPProvider(() => resolveOptions(ctx, current())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the authoritative config into per-search options. The section
|
||||
* returned by `setSource` (settings.yaml `search-mcp:` block) replaces the
|
||||
* row config entirely, matching how every other settings section behaves.
|
||||
*
|
||||
* @param ctx - plugin context supplying the credential and environment planes.
|
||||
* @param config - the currently authoritative section.
|
||||
* @returns options for one search.
|
||||
*/
|
||||
function resolveOptions(ctx, config) {
|
||||
return {
|
||||
servers: config.servers ?? [],
|
||||
defaultServer: config.defaultServer ?? '',
|
||||
maxResults: config.maxResults ?? 8,
|
||||
searchTimeoutMs: config.searchTimeoutMs ?? 30000,
|
||||
resolveKey: async (server) => {
|
||||
if (server.apiKey !== undefined && server.apiKey.length > 0) return server.apiKey;
|
||||
const envName = server.apiKeyEnv ?? '';
|
||||
if (envName.length === 0) return undefined;
|
||||
const credentials = ctx.get('credentials');
|
||||
if (credentials !== undefined) {
|
||||
try {
|
||||
const resolved = await credentials.resolve(credentialRef(envName));
|
||||
if (resolved !== undefined && resolved.value !== undefined && resolved.value.length > 0) {
|
||||
return resolved.value;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the launch environment */
|
||||
}
|
||||
}
|
||||
const ambient = launchEnvironmentOf(ctx).get(envName);
|
||||
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
83
lib/provider.js
Normal file
83
lib/provider.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* The `search-mcp` web search provider.
|
||||
*
|
||||
* Registers into `ctx.web` under the stable id `search-mcp`; the profile
|
||||
* patch switches `web.searchProvider` to this id and disables the built-in
|
||||
* DeepSeek provider, so the model-facing `web_search` tool executes entirely
|
||||
* through the configured search MCP server(s).
|
||||
*/
|
||||
import { WebError } from '@deepseek-ai/dsh-web';
|
||||
import { resolveServer } from './catalog.js';
|
||||
import { callMcpSearch } from './client.js';
|
||||
import { extractSearchResult } from './extract.js';
|
||||
|
||||
/** Stable provider id the `web` row's `searchProvider` config selects. */
|
||||
export const SEARCH_MCP_PROVIDER_ID = 'search-mcp';
|
||||
|
||||
/** The web search provider served by this plugin. */
|
||||
export class SearchMCPProvider {
|
||||
id = SEARCH_MCP_PROVIDER_ID;
|
||||
|
||||
/**
|
||||
* @param resolveOptions - snapshots the authoritative config (row config,
|
||||
* or the live settings section) at the START of each operation, so one
|
||||
* search never mixes two settings saves.
|
||||
*/
|
||||
constructor(resolveOptions) {
|
||||
this.resolveOptions = resolveOptions;
|
||||
}
|
||||
|
||||
/** Usable when at least one server entry exists; precise errors surface at search time. */
|
||||
available() {
|
||||
const options = this.resolveOptions();
|
||||
return Array.isArray(options.servers) && options.servers.length > 0;
|
||||
}
|
||||
|
||||
async search(request, signal) {
|
||||
const options = this.resolveOptions();
|
||||
const server = pickServer(options);
|
||||
const resolved = resolveServer(server);
|
||||
const maxResults = resolved.maxResults ?? options.maxResults ?? request.maxResults ?? 8;
|
||||
const key = await options.resolveKey(resolved);
|
||||
if (resolved.needsKey && (key === undefined || key.length === 0)) {
|
||||
const ref = resolved.apiKeyEnv.length > 0 ? resolved.apiKeyEnv : 'apiKey';
|
||||
throw new WebError(
|
||||
`search-mcp server "${resolved.id}" (${resolved.kind}) has no API key; set "apiKey" or a resolvable "apiKeyEnv" (${ref}) in Settings → Plugins → search-mcp`,
|
||||
'WEB_PROVIDER_ERROR',
|
||||
);
|
||||
}
|
||||
const combined = buildSignal(signal, options.searchTimeoutMs);
|
||||
const outcome = await callMcpSearch(resolved, key, { query: request.query, maxResults }, combined);
|
||||
return extractSearchResult(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
/** Select the default server, falling back to the first configured entry. */
|
||||
function pickServer(options) {
|
||||
const servers = Array.isArray(options.servers) ? options.servers : [];
|
||||
if (servers.length === 0) {
|
||||
throw new WebError(
|
||||
'search-mcp: no search MCP servers configured; add one in Settings → Plugins → search-mcp',
|
||||
'WEB_PROVIDER_ERROR',
|
||||
);
|
||||
}
|
||||
if (options.defaultServer !== undefined && options.defaultServer.length > 0) {
|
||||
const found = servers.find((entry) => entry.id === options.defaultServer);
|
||||
if (found === undefined) {
|
||||
throw new WebError(
|
||||
`search-mcp: defaultServer "${options.defaultServer}" is not configured; known servers: ${servers
|
||||
.map((entry) => `"${entry.id}"`)
|
||||
.join(', ') || '(none)'}`,
|
||||
'WEB_PROVIDER_ERROR',
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
return servers[0];
|
||||
}
|
||||
|
||||
/** Combine the caller's cancellation with the configured timeout. */
|
||||
function buildSignal(signal, timeoutMs) {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal !== undefined ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
243
lib/url-policy.js
Normal file
243
lib/url-policy.js
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { lookup as dnsLookup } from 'node:dns';
|
||||
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.
|
||||
*/
|
||||
export async function validateHttpEndpoint(input, options = {}) {
|
||||
if (options.signal?.aborted) throw abortedPolicyError();
|
||||
if (typeof input !== 'string' || input.length === 0 || input !== input.trim()) {
|
||||
throw policyError('endpoint must be a non-empty canonical URL');
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
throw policyError('endpoint is not a valid URL');
|
||||
}
|
||||
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||
throw policyError('endpoint protocol must be http or https');
|
||||
}
|
||||
if (url.username.length > 0 || url.password.length > 0) {
|
||||
throw policyError('endpoint must not contain user information');
|
||||
}
|
||||
if (url.hostname.length === 0) {
|
||||
throw policyError('endpoint hostname is missing');
|
||||
}
|
||||
|
||||
const hostname = stripIpv6Brackets(url.hostname).toLowerCase();
|
||||
const comparable = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
|
||||
if (comparable === 'localhost' || comparable.endsWith('.localhost')) {
|
||||
throw policyError('localhost endpoints are not allowed');
|
||||
}
|
||||
rejectAmbiguousIpv4(input, comparable);
|
||||
|
||||
let addresses;
|
||||
if (ipaddr.isValid(comparable)) {
|
||||
addresses = [{ address: normalizeAddress(comparable), family: addressFamily(comparable) }];
|
||||
} else {
|
||||
addresses = await resolveAll(comparable, options.lookup ?? dnsLookup, options.signal);
|
||||
}
|
||||
if (addresses.length === 0) {
|
||||
throw policyError('endpoint hostname did not resolve');
|
||||
}
|
||||
|
||||
const normalized = deduplicateAddresses(addresses);
|
||||
// Keep globally routable answers. Also allow RFC 2544 (198.18.0.0/15), which
|
||||
// Clash/V2Ray fake-IP / TUN mode commonly returns for otherwise-public hosts.
|
||||
// Real private/LAN answers are dropped; fail only when nothing usable remains.
|
||||
const allowed = normalized.filter((record) => isAllowedEndpointAddress(record.address));
|
||||
if (allowed.length === 0) {
|
||||
throw policyError('endpoint hostname resolves to a non-public address');
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
url,
|
||||
hostname: comparable,
|
||||
addresses: Object.freeze(allowed.map((record) => Object.freeze(record))),
|
||||
lookup: createPinnedLookup(comparable, allowed),
|
||||
});
|
||||
}
|
||||
|
||||
/** Return true only for globally routable IPv4 or IPv6 addresses. */
|
||||
export function isPublicAddress(input) {
|
||||
let address;
|
||||
try {
|
||||
address = ipaddr.parse(stripIpv6Brackets(input));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (address.kind() === 'ipv6') {
|
||||
if (address.isIPv4MappedAddress()) {
|
||||
address = address.toIPv4Address();
|
||||
} else if (isIpv4CompatibleAddress(address)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return address.range() === 'unicast';
|
||||
}
|
||||
|
||||
/** Addresses safe to dial for remote MCP endpoints (public or proxy fake-IP). */
|
||||
export function isAllowedEndpointAddress(input) {
|
||||
if (isPublicAddress(input)) return true;
|
||||
return isProxyFakeIpAddress(input);
|
||||
}
|
||||
|
||||
/** RFC 2544 benchmarking range used as DNS fake-IP by many local proxies. */
|
||||
export function isProxyFakeIpAddress(input) {
|
||||
let address;
|
||||
try {
|
||||
address = ipaddr.parse(stripIpv6Brackets(input));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (address.kind() === 'ipv6') {
|
||||
if (address.isIPv4MappedAddress()) {
|
||||
address = address.toIPv4Address();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (address.kind() !== 'ipv4') return false;
|
||||
// 198.18.0.0/15
|
||||
const [a, b] = address.octets;
|
||||
return a === 198 && (b === 18 || b === 19);
|
||||
}
|
||||
|
||||
/** Build a Node-compatible DNS lookup that never resolves beyond the pinned set. */
|
||||
export function createPinnedLookup(hostname, records) {
|
||||
const target = normalizeHostname(hostname);
|
||||
const frozen = deduplicateAddresses(records);
|
||||
let cursor = 0;
|
||||
return (requested, options, callback) => {
|
||||
const requestedHost = normalizeHostname(requested);
|
||||
if (requestedHost !== target) {
|
||||
const error = policyError('connection attempted an unvalidated hostname');
|
||||
error.code = 'EACCES';
|
||||
queueMicrotask(() => callback(error));
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupOptions = typeof options === 'object' && options !== null ? options : {};
|
||||
const family = Number(lookupOptions.family) || 0;
|
||||
const candidates = family === 4 || family === 6
|
||||
? frozen.filter((record) => record.family === family)
|
||||
: frozen;
|
||||
if (candidates.length === 0) {
|
||||
const error = policyError('no validated address matches the requested family');
|
||||
error.code = 'ENOTFOUND';
|
||||
queueMicrotask(() => callback(error));
|
||||
return;
|
||||
}
|
||||
if (lookupOptions.all === true) {
|
||||
queueMicrotask(() => callback(null, candidates.map((record) => ({ ...record }))));
|
||||
return;
|
||||
}
|
||||
const selected = candidates[cursor++ % candidates.length];
|
||||
queueMicrotask(() => callback(null, selected.address, selected.family));
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAll(hostname, lookup, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(abortedPolicyError());
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(reject, abortedPolicyError());
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
lookup(hostname, { all: true, verbatim: true }, (error, records) => {
|
||||
if (settled) return;
|
||||
if (error) {
|
||||
finish(reject, policyError('endpoint hostname resolution failed'));
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(records) ? records : records === undefined ? [] : [records];
|
||||
try {
|
||||
finish(resolve, list.map((record) => {
|
||||
const raw = typeof record === 'string' ? record : record.address;
|
||||
return { address: normalizeAddress(raw), family: addressFamily(raw) };
|
||||
}));
|
||||
} catch {
|
||||
finish(reject, policyError('endpoint hostname returned an invalid address'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deduplicateAddresses(records) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const record of records) {
|
||||
const address = normalizeAddress(record.address);
|
||||
const family = addressFamily(address);
|
||||
const key = `${family}:${address}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({ address, family });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeAddress(input) {
|
||||
let address = ipaddr.parse(stripIpv6Brackets(input));
|
||||
if (address.kind() === 'ipv6' && address.isIPv4MappedAddress()) {
|
||||
address = address.toIPv4Address();
|
||||
}
|
||||
return address.toNormalizedString();
|
||||
}
|
||||
|
||||
function addressFamily(input) {
|
||||
const address = ipaddr.parse(stripIpv6Brackets(input));
|
||||
if (address.kind() === 'ipv6' && address.isIPv4MappedAddress()) return 4;
|
||||
return address.kind() === 'ipv4' ? 4 : 6;
|
||||
}
|
||||
|
||||
function rejectAmbiguousIpv4(input, hostname) {
|
||||
const authority = input.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]+)/)?.[1] ?? '';
|
||||
const rawHost = authority.startsWith('[')
|
||||
? authority.slice(1, authority.indexOf(']'))
|
||||
: authority.replace(/:\d*$/, '');
|
||||
if (!rawHost.includes(':') && ipaddr.IPv4.isValid(rawHost)) {
|
||||
const canonical = ipaddr.IPv4.parse(rawHost).toString();
|
||||
if (rawHost !== canonical || hostname !== canonical) {
|
||||
throw policyError('endpoint contains a non-canonical IPv4 address');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isIpv4CompatibleAddress(address) {
|
||||
return address.parts.slice(0, 6).every((part) => part === 0);
|
||||
}
|
||||
|
||||
function normalizeHostname(input) {
|
||||
const value = stripIpv6Brackets(String(input)).toLowerCase();
|
||||
return value.endsWith('.') ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function stripIpv6Brackets(input) {
|
||||
return input.startsWith('[') && input.endsWith(']') ? input.slice(1, -1) : input;
|
||||
}
|
||||
|
||||
function abortedPolicyError() {
|
||||
const error = policyError('endpoint validation was aborted');
|
||||
error.code = 'ABORT_ERR';
|
||||
return error;
|
||||
}
|
||||
|
||||
function policyError(message) {
|
||||
const error = new Error(`search-mcp URL policy: ${message}`);
|
||||
error.name = 'SearchMcpUrlPolicyError';
|
||||
return error;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue