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:
oliver 2026-09-12 08:51:32 +08:00
commit d5c4d2006c
16 changed files with 4821 additions and 0 deletions

137
lib/catalog.js Normal file
View 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

File diff suppressed because it is too large Load diff

150
lib/client.js Normal file
View 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
View 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
View 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
View 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
View 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;
}