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

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
*.log
*.tgz
.DS_Store
# Local security scan state and private project handoff.
.mimosa/
PROJECT_CONTEXT.md

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

183
README.md Normal file
View file

@ -0,0 +1,183 @@
# dsh-search-mcp
用搜索类 MCP 服务器完整替代 DeepSeek Harness(DSH)内置网页搜索的独立插件。
> 当前兼容基线:DeepSeek Harness `0.1.1-rc.2`,Node.js 20 或更高版本。
## 功能
- 模型侧继续使用原生 `web_search`,插件只替换底层 search provider。
- 支持 Tavily、Brave、Exa、Perplexity、DuckDuckGo 和自定义 HTTP/stdio MCP。
- 已知 provider 只需选择服务商并填写 CDKey/API key,不需要填写 URL、命令、鉴权参数或工具名。
- 自定义 MCP 保留 URL、stdio 命令、鉴权方式和工具名等高级配置。
- 密钥通过 DSH credentials domain 写入;设置读取接口只返回是否已配置,不返回密钥值。
- DSH RC2 支持一次 `web_search` 提交多个查询,默认上限为 4。
- 卸载插件后 bundle 覆盖层随之移除,DSH 内置搜索组合恢复。
## 安装
```powershell
git clone https://github.com/gxpppp/dsh-search-mcp.git
cd dsh-search-mcp
npm install
dsh plugin --profile web add link:<dsh-search-mcp 的绝对路径>
dsh web
```
`link:` 会让源码更新直接作用于 profile。修改或升级浏览器 bundle 后需要重启 DSH Web 并刷新页面。
如果 profile 中已有独立搜索 MCP 行,建议先移除重复入口,避免同时暴露 `mcp__...` 工具和本插件提供的 `web_search`。
## 配置
打开:
**设置 → 插件 → 插件配置 → 搜索 MCP**
### 已知 provider
1. 点击 Tavily、Brave、Exa、Perplexity 或 DuckDuckGo 快捷按钮。
2. 展开服务器行。
3. 对需要凭据的 provider 填写 CDKey/API key,然后保存。
4. DuckDuckGo 无需 key。
已知 provider 的 endpoint、transport、鉴权方式、工具名和结果参数由 Host catalog 固定管理,设置页不会自动填入或显示链接。保存 CDKey 后,客户端先调用 `credentials.set`,再将生成的 credential reference 写入服务器设置;密钥本身不会写回普通 settings 字段。
也可以预先在 `$DSH_HOME/.credentials.yaml` 中保存凭据,再在设置页填写引用名:
```yaml
TAVILY_API_KEY: <your-key>
EXA_API_KEY: <your-key>
PERPLEXITY_API_KEY: <your-key>
BRAVE_API_KEY: <your-key>
```
RC2 的 `credentials/reference-updated` 事件会刷新设置卡片中的“已配置/未配置”状态,但不会传输密钥值。卡片按 RC2 每批最多 64 个引用的限制分批读取状态。保存多个字段失败时会逆序恢复已写入的 settings,并清理本次新建的 credential reference;由于 RC2 不允许读回已有密钥,覆盖一个此前已配置的引用后无法跨 credentials/settings 做值级回滚。
### 自定义 MCP
添加 `custom` 服务器后,可配置:
| 字段 | 说明 |
|---|---|
| `id` | 服务器唯一标识,供 `defaultServer` 引用 |
| `transport` | `http`(Streamable HTTP)或 `stdio` |
| `url` | 仅 HTTP 自定义 MCP 使用 |
| `command` / `args` | 仅 stdio 自定义 MCP 使用 |
| `apiKey` | 写入方向的 CDKey/API key 输入 |
| `apiKeyEnv` | 环境变量或 DSH credential reference |
| `authStyle` | HTTP 的 `query` 或 `header` |
| `authParam` | query/header 参数名;stdio 下作为环境变量名 |
| `toolName` | MCP 搜索工具名称 |
| `maxResults` | 单服务器结果数覆盖 |
### 全局选项
| 字段 | 说明 |
|---|---|
| `defaultServer` | 默认服务器 id;留空时使用第一行 |
| `maxResults` | 全局结果数上限,默认 8,可选 1–50 |
| `searchTimeoutMs` | 每次 MCP 搜索超时,默认 30000 ms;界面以秒显示 |
## Provider 预设
| kind | Host 管理的连接 | 凭据 | 搜索工具 | 结果数参数 |
|---|---|---|---|---|
| `tavily` | hosted Streamable HTTP | CDKey/API key | `tavily_search` | `max_results` |
| `brave` | `@brave/brave-search-mcp-server@2.1.3` stdio | `BRAVE_API_KEY` | `brave_web_search` | `count` |
| `exa` | hosted Streamable HTTP | `x-api-key` | `web_search_exa` | `numResults` |
| `perplexity` | hosted Streamable HTTP | Bearer token | `perplexity_search` | `max_results` |
| `duckduckgo` | `duckduckgo-mcp-server@0.1.2` stdio | 无 | `duckduckgo_web_search` | `count` |
| `custom` | 用户配置 | 用户配置 | 用户配置 | 无预设 |
旧配置中的 known-provider URL、transport、auth 和 tool 字段仍可被 schema 读取,但运行时会忽略它们;下一次保存服务器列表时会清理这些冗余字段。只有 `custom` 使用用户提供的连接信息。
插件在调用 known provider 前会把结果数限制到上游 MCP schema 接受的范围:Tavily 为 5–20,Brave、Perplexity 和 DuckDuckGo 为 1–20;Exa 当前保留插件的 1–50 范围。该限制只影响传给上游的参数,最终返回数量仍会受到插件全局/单服务器限制和实际 agent preset 的 `tool-web.searchMaxResults` 共同约束。
## DSH 0.1.1-rc.2 适配
- DSH host 依赖精确锁定为 `0.1.1-rc.2`,不使用可能落到旧版本线的子包 `latest`。
- 设置卡片继续使用 keyed slot:`settings.plugin.item` + `key: "search-mcp"`。
- 新密钥通过 `credentials.set` 单向写入,凭据状态通过 `credentials.describe` 读取。
- 监听 RC2 的 `credentials/reference-updated`,外部凭据变更后刷新状态 badge。
- RC6/RC7 遗留的字面 `apiKey` 仍可由 Host 使用;涉及服务器数组的编辑会阻止不可见旧密钥被意外删除,并要求先迁移。
- 普通全局字段修改不会重写 `servers` 数组。
- `tool-web.searchMaxQueries` 配置为 4,与 RC2 默认多查询能力一致。
## URL 安全策略
所有自定义 HTTP MCP 请求在联网前执行安全校验:
- 只允许 `http:` 和 `https:`,拒绝 userinfo 与非规范 IPv4 表示。
- 拒绝 localhost、环回、RFC1918 私网、链路本地、CGNAT、benchmark、文档/测试、多播、保留和广播地址。
- IPv4-mapped IPv6 先映射为 IPv4 再判断;IPv4-compatible IPv6、IPv6 ULA、link-local、NAT64/转换、Teredo、6to4、文档和保留范围同样拒绝。
- 域名会解析全部 A/AAAA 结果;任意一个结果不公开可路由时整体拒绝,DNS 等待也受同一个搜索 AbortSignal/超时约束。
- 每次搜索使用独占 Undici Agent 和预解析地址的 pinned lookup,同时保留原始 Host 与 TLS SNI,防止 DNS rebinding。
- GET、POST、DELETE 和 SSE 重连都通过同一 fetch wrapper,HTTP 重定向设置为 `error`。
- 先关闭 MCP client,再关闭本次 Agent,不共享连接池。
- 错误信息不会输出包含 CDKey 的完整 URL。
如果代理或 TUN 把公共域名解析到 `198.18.0.0/15` fake-IP,本插件会按 benchmark/test 网段安全拒绝。应让 DSH 进程获得真实公网 DNS 结果,而不是放宽策略。
## 组合覆盖
插件通过 `cordis.patch.yml`:
- 注册 `search-mcp` provider。
- 设置 `web.searchProvider: search-mcp`。
- 禁用 `web-search-deepseek`。
- 保持 `web_fetch` 关闭。
- 请求 `tool-web.searchMaxResults: 50` 和 `searchMaxQueries: 4`。
RC2 的 standard、code、cordis agent preset 各自包含 `tool-web` 行,并且都省略了 `searchMaxResults` 和 `searchMaxQueries`,因此实际采用 `dsh-tool-web` 默认值 8 和 4。agent-scoped 工具会遮蔽根层同名工具,所以根层 patch 中的 50 条请求不会提高这些 shipped preset 的实际上限。验证结果上限时必须检查 session 使用的 preset,不能只依据根层 `--dump-config`。
## 验证
```powershell
npm test
npm run check
npm pack --dry-run
```
自动测试覆盖 RC2 依赖锁定、known/custom catalog 边界、CDKey-only 设置结构、凭据事件、旧 secret 保护、结果归一化,以及 URL/DNS/pinning 安全策略。
2026-08-30 的隔离 RC2 Web 冒烟检查确认:插件卡片可加载;默认 Tavily 行不显示链接或高级连接字段;DuckDuckGo 摘要显示“无需密钥”,展开后只有 ID、provider 和结果数;测试草稿已放弃且没有写入 settings。无密钥 DuckDuckGo stdio server 能启动并收到正确的 `duckduckgo_web_search`/`count` 调用,但当次公开搜索被 DuckDuckGo 上游异常流量检测拒绝,因此未取得可用于结果归一化验收的真实来源。
组合检查:
```powershell
dsh --profile web --dump-config |
Select-String -Pattern "searchProvider|search-mcp|web-search-deepseek|searchMaxResults|searchMaxQueries"
```
预期至少包括:
- `web.searchProvider: search-mcp`
- `web-search-deepseek.disabled: true`
- `tool-web.disabled: false`
- `fetch: false`
实际 agent preset 的结果数和多查询上限应在隔离 profile/session 中单独验证。
## 故障排查
- `no search MCP servers configured`:在设置页添加 provider。
- `has no API key`:填写 CDKey/API key,或填写已有 credential reference。
- “凭证未配置”:引用名存在于 settings,但 credentials provider 当前找不到对应值。
- `defaultServer "x" is not configured`:默认 id 没有匹配任何服务器行。
- `URL policy` 拒绝:endpoint 非 HTTP(S),或 DNS 结果包含本地、私有、保留/测试地址。
- stdio 启动失败:确认 Node/npm 可用,且运行环境允许 `npx` 获取或执行对应 MCP 包。
- 设置页没有 Search MCP 卡片:确认 client bundle 已安装,重启 DSH Web 后强制刷新页面。
- 返回结果仍被截断:检查实际 agent preset 中的 `tool-web.searchMaxResults`,以及全局/单服务器 `maxResults`。
## 卸载
```powershell
dsh plugin --profile web remove dsh-search-mcp
```
随后重启 DSH Web。不要只禁用 `search-mcp` 行,因为 bundle 还覆盖了 `web`、`web-search-deepseek` 和 `tool-web`;完整卸载 bundle 才会恢复内置组合。
## License
MIT

58
cordis.patch.yml Normal file
View file

@ -0,0 +1,58 @@
# dsh-search-mcp bundle layer: applied after dsh-base and dsh-web-app,
# before the profile's own cordis.patch.yml (last write wins per row).
#
# Installing this package therefore REPLACES dsh's built-in web search:
# - the `web` row's searchProvider switches from `deepseek-official` to
# `search-mcp` (the provider registered by this plugin), and
# - the built-in DeepSeek search provider row is disabled.
# The model-facing `web_search` tool keeps its name and presentation; its
# execution now goes through the search MCP server(s) configured below or in
# the web Settings → Plugins → search-mcp section.
#
# Removing this package (dsh plugin --profile web remove dsh-search-mcp)
# drops this whole layer and restores the built-in search exactly.
#
# SECURITY: no API keys are committed to this repository. Server keys are
# supplied at runtime through `apiKeyEnv` — stored in
# `$DSH_HOME/.credentials.yaml` (e.g. `TAVILY_API_KEY: <key>`) — or through
# the web Settings → Plugins → search-mcp section (`apiKey` field).
- insert:
- id: search-mcp
name: 'dsh-search-mcp'
config:
defaultServer: bailian
maxResults: 8
searchTimeoutMs: 30000
servers:
- id: bailian
kind: custom
transport: http
url: https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp
authStyle: header
authParam: Authorization
authPrefix: 'Bearer '
apiKeyEnv: DASHSCOPE_API_KEY
toolName: bailian_web_search
- id: web
config:
searchProvider: search-mcp
- id: web-search-deepseek
disabled: true
# The model-facing web_search tool is owned by dsh-tool-web (its `Config`
# default is a hard 8-source cap, enforced by dsh-web's seam on EVERY
# request). This plugin takes over result sizing, so raise the tool layer's
# cap to the plugin schema maximum: `search-mcp`'s own maxResults (Settings →
# Plugins → search-mcp) then decides how many sources actually come back.
# `fetch` stays disabled and the base timeout is restated, because a patch
# replaces the targeted row's whole config.
- id: tool-web
disabled: false
config:
fetch: false
searchTimeoutMs: 60000
searchMaxResults: 50
searchMaxQueries: 4

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;
}

2202
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

63
package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "dsh-search-mcp",
"version": "0.2.0",
"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",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.browser.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"files": [
"lib",
"cordis.patch.yml"
],
"license": "MIT",
"engines": {
"node": ">=20"
},
"scripts": {
"check": "node --check lib/index.js && node --check lib/provider.js && node --check lib/catalog.js && node --check lib/extract.js && node --check lib/url-policy.js && node --check lib/client.js && node --check lib/client.browser.js",
"test": "node --test"
},
"keywords": [
"dsh",
"dsh-plugin",
"mcp",
"search",
"web_search",
"tavily",
"brave",
"exa",
"perplexity",
"duckduckgo"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-settings-plugins",
"@deepseek-ai/dsh-client-locale"
]
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"ipaddr.js": "2.5.0",
"undici": "6.28.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "*",
"@deepseek-ai/dsh-credentials": "*",
"@deepseek-ai/dsh-launch-environment": "*",
"@deepseek-ai/dsh-settings": "*",
"@deepseek-ai/dsh-web": "*",
"@deepseek-ai/schemastery": "*"
}
}

View file

@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const read = (path) => readFile(resolve(root, path), 'utf8');
test('package exports resolve and RC2 dependencies stay pinned', async () => {
const pkg = JSON.parse(await read('package.json'));
assert.equal(pkg.exports['.'], './lib/index.js');
assert.equal(pkg.exports['./client'], './lib/client.browser.js');
assert.equal(pkg.engines.node, '>=20');
for (const name of [
'@deepseek-ai/dsh-api-remotes',
'@deepseek-ai/dsh-credentials',
'@deepseek-ai/dsh-launch-environment',
'@deepseek-ai/dsh-settings',
'@deepseek-ai/dsh-web',
]) {
assert.equal(pkg.dependencies[name], '0.1.1-rc.2');
}
assert.equal(pkg.dependencies['@deepseek-ai/schemastery'], '3.18.1');
assert.equal(pkg.dependencies.undici, '6.28.0');
assert.equal(pkg.dependencies['ipaddr.js'], '2.5.0');
assert.equal(pkg.dependencies['@modelcontextprotocol/sdk'], '1.30.0');
});
test('RC2 browser bundle uses keyed settings slot and credential migration', async () => {
const client = await read('lib/client.browser.js');
assert.match(client, /name: "settings\.plugin\.item",\s+key: NS,/);
assert.doesNotMatch(client, /name: "settings\.plugin\.item",\s+id:/);
assert.match(client, /api\.settings\.describe\(\{\}\)/);
assert.match(client, /api\.credentials\.describe\(\{ refs: refs\.slice\(index, index \+ CREDENTIAL_DESCRIBE_BATCH_SIZE\) \}\)/);
assert.match(client, /api\.credentials\.set\(\{ ref, value \}\)/);
assert.match(client, /credentials\/reference-updated/);
assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/);
assert.match(client, /api\.credentials\.unset\(\{ ref \}\)/);
assert.match(client, /rollbackSettingsWrites/);
assert.match(client, /legacyKeyBlocked/);
assert.match(client, /deepEqualJson\(current\[field\], value\)/);
});
test('known providers are CDKey-only while custom keeps advanced fields', async () => {
const client = await read('lib/client.browser.js');
const catalog = client.slice(client.indexOf('const CATALOG = {'), client.indexOf('const KIND_OPTIONS'));
assert.doesNotMatch(catalog, /https?:\/\//);
assert.doesNotMatch(catalog, /toolName|authParam|transport/);
assert.match(client, /const known = row\.kind !== "custom"/);
assert.match(client, /children: known \? \[/);
assert.match(client, /No endpoint is required for known providers/);
assert.match(client, /已知提供商不需要填写端点链接/);
assert.match(client, /kind, apiKey: "", apiKeyEnv: ""/);
});
test('HTTP transport pins DNS and applies one guarded fetch to every SDK request', async () => {
const transport = await read('lib/client.js');
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/);
assert.match(transport, /new Agent\(\{[\s\S]*connect: \{ lookup: validated\.lookup \}/);
assert.match(transport, /requestUrl\.origin !== expectedOrigin/);
assert.match(transport, /dispatcher: agent/);
assert.match(transport, /redirect: 'error'/);
assert.match(transport, /fetch: secureFetch/);
assert.match(transport, /await client\.close\(\)[\s\S]*await runtime\?\.close\(\)/);
assert.doesNotMatch(transport, /new URL\(server\.url\)[\s\S]*new StreamableHTTPClientTransport\(url, \{\s*requestInit:/);
});
test('bundle replaces built-in search and leaves default row endpoint-free', async () => {
const patch = await read('cordis.patch.yml');
assert.match(patch, /searchProvider: search-mcp/);
assert.match(patch, /- id: web-search-deepseek\s+disabled: true/);
assert.match(patch, /- id: tool-web\s+disabled: false/);
assert.match(patch, /fetch: false/);
assert.match(patch, /searchMaxResults: 50/);
assert.match(patch, /searchMaxQueries: 4/);
const defaultRow = patch.slice(patch.indexOf('- id: tavily'), patch.indexOf('- id: web'));
assert.doesNotMatch(defaultRow, /url:|toolName:|authParam:|transport:/);
});

130
test/runtime.test.js Normal file
View file

@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { clampSearchResults, SEARCH_MCP_CATALOG, resolveServer } from '../lib/catalog.js';
import { extractSearchResult } from '../lib/extract.js';
test('catalog exposes every supported provider preset', () => {
assert.deepEqual(Object.keys(SEARCH_MCP_CATALOG), [
'tavily',
'brave',
'exa',
'perplexity',
'duckduckgo',
'custom',
]);
});
test('known providers ignore stored connection overrides', () => {
const tavily = resolveServer({
id: 'primary',
kind: 'tavily',
transport: 'stdio',
url: 'https://example.test/mcp',
authStyle: 'header',
authParam: 'X-Other-Key',
toolName: 'other_search',
apiKeyEnv: 'MY_TAVILY_KEY',
maxResults: 12,
});
assert.equal(tavily.transport, 'http');
assert.equal(tavily.url, 'https://mcp.tavily.com/mcp/');
assert.equal(tavily.authStyle, 'query');
assert.equal(tavily.authParam, 'tavilyApiKey');
assert.equal(tavily.toolName, 'tavily_search');
assert.equal(tavily.countArg, 'max_results');
assert.equal(tavily.apiKeyEnv, 'MY_TAVILY_KEY');
assert.equal(tavily.maxResults, 12);
});
test('prototype property kinds fall back to custom', () => {
for (const kind of ['constructor', 'toString', '__proto__']) {
const resolved = resolveServer({ id: kind, kind, url: 'https://search.example/mcp' });
assert.equal(resolved.kind, 'custom');
assert.equal(resolved.url, 'https://search.example/mcp');
}
});
test('custom providers preserve advanced connection fields', () => {
const custom = resolveServer({
id: 'custom',
kind: 'custom',
transport: 'stdio',
command: 'custom-mcp',
args: ['--stdio'],
authStyle: 'header',
authParam: 'X-Key',
authPrefix: 'Token ',
toolName: 'search',
});
assert.equal(custom.transport, 'stdio');
assert.equal(custom.command, 'custom-mcp');
assert.deepEqual(custom.args, ['--stdio']);
assert.equal(custom.authParam, 'X-Key');
assert.equal(custom.authPrefix, 'Token ');
assert.equal(custom.toolName, 'search');
});
test('provider contracts match current upstream transports', () => {
const brave = resolveServer({ id: 'brave', kind: 'brave' });
assert.equal(brave.transport, 'stdio');
assert.equal(brave.command, 'npx');
assert.deepEqual(brave.args, ['-y', '@brave/brave-search-mcp-server@2.1.3']);
assert.equal(brave.authParam, 'BRAVE_API_KEY');
const perplexity = resolveServer({ id: 'perplexity', kind: 'perplexity' });
assert.equal(perplexity.url, 'https://api.perplexity.ai/mcp');
assert.equal(perplexity.authParam, 'Authorization');
assert.equal(perplexity.authPrefix, 'Bearer ');
assert.equal(perplexity.toolName, 'perplexity_search');
const duckduckgo = resolveServer({ id: 'duckduckgo', kind: 'duckduckgo' });
assert.equal(duckduckgo.needsKey, false);
assert.equal(duckduckgo.toolName, 'duckduckgo_web_search');
assert.equal(duckduckgo.countArg, 'count');
});
test('provider result counts stay within upstream MCP schemas', () => {
assert.equal(clampSearchResults(resolveServer({ id: 't', kind: 'tavily' }), 1), 5);
assert.equal(clampSearchResults(resolveServer({ id: 't', kind: 'tavily' }), 50), 20);
assert.equal(clampSearchResults(resolveServer({ id: 'b', kind: 'brave' }), 50), 20);
assert.equal(clampSearchResults(resolveServer({ id: 'p', kind: 'perplexity' }), 0), 1);
assert.equal(clampSearchResults(resolveServer({ id: 'd', kind: 'duckduckgo' }), 50), 20);
assert.equal(clampSearchResults(resolveServer({ id: 'e', kind: 'exa' }), 50), 50);
});
test('extractSearchResult normalizes, deduplicates, and rejects invalid URLs', () => {
const result = extractSearchResult({
structuredContent: {
answer: 'Summary',
results: [
{ url: 'https://example.com/a', title: 'A', content: 'alpha', published_date: '2026-08-18' },
{ url: 'https://example.com/a', title: 'Duplicate' },
{ url: 'ftp://example.com/ignored', title: 'Ignored' },
],
nested: { url: 'http://example.com/b', description: 'beta' },
},
});
assert.deepEqual(result, {
sources: [
{
url: 'https://example.com/a',
title: 'A',
snippet: 'alpha',
publishedAt: '2026-08-18',
},
{ url: 'http://example.com/b', snippet: 'beta' },
],
truncated: false,
content: 'Summary',
});
});
test('extractSearchResult accepts JSON and plain text MCP blocks', () => {
const json = extractSearchResult({
content: [{ type: 'text', text: '{"results":[{"url":"https://example.com"}]}' }],
});
assert.equal(json.sources.length, 1);
const text = extractSearchResult({ content: [{ type: 'text', text: 'Direct answer' }] });
assert.deepEqual(text, { sources: [], truncated: false, content: 'Direct answer' });
});

158
test/url-policy.test.js Normal file
View file

@ -0,0 +1,158 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createPinnedLookup,
isAllowedEndpointAddress,
isPublicAddress,
validateHttpEndpoint,
} from '../lib/url-policy.js';
const lookup = (records) => (_hostname, options, callback) => {
assert.equal(options.all, true);
queueMicrotask(() => callback(null, records));
};
test('URL policy accepts HTTP(S) with public DNS only', async () => {
const result = await validateHttpEndpoint('https://search.example/mcp', {
lookup: lookup([
{ address: '8.8.8.8', family: 4 },
{ address: '2606:4700:4700:0:0:0:0:1111', family: 6 },
]),
});
assert.equal(result.url.hostname, 'search.example');
assert.deepEqual(result.addresses, [
{ address: '8.8.8.8', family: 4 },
{ address: '2606:4700:4700:0:0:0:0:1111', family: 6 },
]);
});
test('URL policy rejects schemes, userinfo, localhost, and ambiguous IPv4', async () => {
for (const input of [
'ftp://example.com/mcp',
'https://user:pass@example.com/mcp',
'http://localhost/mcp',
'http://api.localhost/mcp',
'http://127.0.0.1/mcp',
'http://127.1/mcp',
'http://0177.0.0.1/mcp',
'http://0x7f000001/mcp',
]) {
await assert.rejects(() => validateHttpEndpoint(input), { name: 'SearchMcpUrlPolicyError' }, input);
}
});
test('URL policy rejects private, reserved, test, mapped, and transition ranges', () => {
for (const address of [
'0.0.0.0',
'10.0.0.1',
'100.64.0.1',
'127.0.0.1',
'169.254.169.254',
'172.16.0.1',
'192.168.0.1',
'192.0.2.1',
'198.18.0.1',
'198.51.100.1',
'203.0.113.1',
'224.0.0.1',
'255.255.255.255',
'::',
'::1',
'::ffff:127.0.0.1',
'::7f00:1',
'::a00:1',
'::a9fe:a9fe',
'::c0a8:101',
'::808:808',
'64:ff9b::808:808',
'2001:db8::1',
'2001::1',
'2002:0808:0808::1',
'fc00::1',
'fe80::1',
'ff02::1',
]) {
assert.equal(isPublicAddress(address), false, address);
}
assert.equal(isPublicAddress('8.8.8.8'), true);
assert.equal(isPublicAddress('2606:4700:4700::1111'), true);
});
test('URL policy drops private DNS answers and keeps public ones', async () => {
const result = await validateHttpEndpoint('https://search.example/mcp', {
lookup: lookup([
{ address: '8.8.8.8', family: 4 },
{ address: '10.0.0.1', family: 4 },
]),
});
assert.deepEqual(result.addresses, [{ address: '8.8.8.8', family: 4 }]);
});
test('URL policy accepts Clash fake-IP answers with optional public peers', async () => {
const mixed = await validateHttpEndpoint('https://dashscope.example/mcp', {
lookup: lookup([
{ address: '198.18.2.146', family: 4 },
{ address: '2408:400a:3e:ef02:12f:bd95:e827:51d', family: 6 },
]),
});
assert.deepEqual(mixed.addresses, [
{ address: '198.18.2.146', family: 4 },
{ address: '2408:400a:3e:ef02:12f:bd95:e827:51d', family: 6 },
]);
const fakeOnly = await validateHttpEndpoint('https://dashscope.example/mcp', {
lookup: lookup([{ address: '198.18.2.146', family: 4 }]),
});
assert.deepEqual(fakeOnly.addresses, [{ address: '198.18.2.146', family: 4 }]);
assert.equal(isAllowedEndpointAddress('198.18.2.146'), true);
assert.equal(isAllowedEndpointAddress('10.0.0.1'), false);
});
test('URL policy rejects private-only DNS answers', async () => {
await assert.rejects(
() => validateHttpEndpoint('https://search.example/mcp', {
lookup: lookup([{ address: '10.0.0.1', family: 4 }]),
}),
/non-public address/,
);
});
test('URL policy rejects DNS errors and empty answers without leaking endpoint data', async () => {
await assert.rejects(
() => validateHttpEndpoint('https://search.example/mcp', {
lookup: (_hostname, _options, callback) => callback(new Error('resolver detail')),
}),
/resolution failed/,
);
await assert.rejects(
() => validateHttpEndpoint('https://search.example/mcp', { lookup: lookup([]) }),
/did not resolve/,
);
});
test('URL policy aborts a pending DNS lookup', async () => {
const controller = new AbortController();
const pending = validateHttpEndpoint('https://search.example/mcp', {
lookup: () => {},
signal: controller.signal,
});
controller.abort();
await assert.rejects(pending, /validation was aborted/);
});
test('pinned lookup serves only validated host and addresses', async () => {
const pinned = createPinnedLookup('search.example', [
{ address: '8.8.8.8', family: 4 },
{ address: '2606:4700:4700:0:0:0:0:1111', family: 6 },
]);
const all = await new Promise((resolve, reject) => {
pinned('search.example', { all: true }, (error, records) => error ? reject(error) : resolve(records));
});
assert.deepEqual(all, [
{ address: '8.8.8.8', family: 4 },
{ address: '2606:4700:4700:0:0:0:0:1111', family: 6 },
]);
await assert.rejects(new Promise((resolve, reject) => {
pinned('other.example', {}, (error, address) => error ? reject(error) : resolve(address));
}), /unvalidated hostname/);
});