` 重命名留下的惰性记录——重命名本身已在 `820641c` 里删掉,但它当年写进注册表的三条记录留了下来,而全机任何配置文件都不再包含这个名字(都固定着 6.1.0,正是报告里那三条)。它们既不会被重新导入,也不提供任何工具(fiber 按**名字**存放,`connect()` 见到已有名字就短路),却照样被算作服务器、响应行操作(按名字找第一条,正是「点最后一行、删掉第一行」的由来)。现在扫描器把这次的**证据**写进注册表(`registry.scanned`,`lib/scanner.js` 的 `scannedSources` / `findOrphans`):读了哪些来源、各自定义了哪些名字;面板据此把这些行标成 `来源已删除`(琥珀色胶囊,不显示更新徽标——没有人会重新导入它),并把整个集合收成一条提示 + 一个动作「清理 N 条」(`POST /api/overall-mcp/forget-orphans`,一次请求处理全部)。判定**只在扫描确实读到过该来源时**才做:`not-found` 不算「空配置」,其余读取错误进 `unreadable` 也不参与——一份读不到的配置不能当成「这个服务器不存在」的证据;`source: manual` 的手工条目永不动。**正在持有挂载的行(`serverName` 非空)永远不在清理范围内**:那一行此刻就在供工具,配置文件没有它并不能成为把它拿走的理由。这个标记现在由 `connect()` / `disconnect()` 经 `setMountHolder` / `clearMountHolder` 维护,同名条目里**只有真正挂载成功的那一行**带 `serverName`——`fibers` 是按名字存放的,早先每点一次「更新」都会重挂一次,于是标记被留在刚刚重挂过的那一行上,四条同名记录里会有三条同时自称「持有挂载」,清理反倒漏掉它们。因为持有者不在清理范围内,清理既不需要断开也不需要重连任何 fiber(`syncFiber` 的循环已随之删掉),返回体只有 `{name, source, version}`。`停用` / `移除` 也带 `slot`,不再是按名字找第一条。
同一个按钮还回答第二个问题:**本插件自己**(`dsh-overall-mcp`)在 npm 上有没有更新。有新版时,**版本号旁边**(页头 `全局 MCP v0.12.8` 的右侧)出现 `更新到 v<新版>` 按钮,两个标签页都在同一位置——这是唯一一件关于本页自身而不是本页内容的事,所以它属于版本号。两种安装形态给出的答案不同(`lib/self-update.js`,`test/self-update.test.mjs` 断言):
| 安装形态 | 判定 | 面板行为 |
| --- | --- | --- |
| profile 的 `node_modules/dsh-overall-mcp` | 版本由 pnpm 拥有,可查 npm `dist-tags.latest` 并比较 | 有新版时页头版本号旁出现「更新到 v<新版>」,点了执行 `dsh plugin --profile add @`,随后提示重启 DSH |
| `link:` / 本地检出(本仓库的开发形态) | 加载的就是源码目录,npm 上的版本与它无关 | **不出现**更新按钮,检查结果里注明「插件为 link 安装,跳过自身更新」 |
| npm 查询失败(未发布 / 404 / 超时) | 无法判定 | 不出现按钮,检查结果里原样给出原因 |
`GET /api/overall-mcp/self` 可以在不触发服务器检查的情况下单独看插件自身的版本判定(10 分钟记忆化,避免每次点按钮都打 npm);`POST /api/overall-mcp/self-update` 执行安装并把命令行原样回给面板。
能检测到版本的前提是该条目**通过包管理器启动**:
| 形式 | 可检测 | 说明 |
|---|---|---|
| `npx -y pkg@1.2.3 …` | ✅ | 对比 npm `dist-tags.latest` |
| `cmd.exe /d /c npx -y pkg@1.2.3 …` | ✅ | Windows 包装形式(从其它客户端导入的常见形态),会自动解包 |
| `uvx pkg` / `pipx pkg` | ✅ | 对比 PyPI `info.version` |
| `npx -y pkg`(未固定版本) | ⚪ | 视为始终最新,不提示更新 |
| `codegraph serve --mcp`、`python server.py` 等直接命令 | ❌ | 显示「not launched through a package runner」,不做猜测 |
更新动作会把包参数重写为**精确最新版本**(可复现),保存注册表,并断开重连让新版本真正生效。`GET|POST /api/overall-mcp/updates` 与 `POST /api/overall-mcp/update` 提供同等能力;`/update` 每次只处理一个条目,请求体是 `{ name, slot, from }`(同名的行只有一条时可省略;同名多条目时**必须**给出其中之一,否则报 409 而不是猜一条),界面上的「全部更新」是客户端把这些请求**排队串行**发出去,而不是宿主提供的批量接口——这样每一条的成败都能单独报出来。`/remove` 与 `/set-enabled` 接受同样的 `{ name, slot, from }`:同名多条时**只有带 `slot` 才敢动手**(否则宁可报「同名记录不唯一」也不猜),因为这两条命令一个会断开真实工具、一个会删掉注册表里的一行。
`POST /api/overall-mcp/forget-orphans` 是「来源已删除」那一类记录的清理入口:只删扫描**已证明**不再被任何配置定义、且当前**没有持有挂载**的行,返回每一条的 `{name, source, version}` 与总数;持有挂载的行永远不会被它碰到,所以它不需要断开或重连任何 fiber。
安装/连接采用**有界等待**:8 秒内未完成(例如首次 `npx` 下载)即返回 `starting: true`,连接在后台继续,页面刷新后显示最终状态。**这次等待失败或超时不会留下幽灵 fiber**:只有真正挂载成功的那一次才把 fiber 记进 `fibers` 并写下 `serverName`,后台那次失败(或超时后才报错)会连同挂载标记一起回收并 `dispose()`,同一行之后的重试不会撞上「已经有 fiber」的短路判断。
**页面上的每个请求都有截止时间**(`client/client.js` 的 `api()`):默认 30 秒,会拉网络或起进程的路由各自放宽(`/scan` 120s、`/catalog` 与仓库统计 120s、`/updates`、`/update`、`/install` 300s、`/self-update` 600s),超时即中止并写出「等待超过 N 秒仍未响应……重启 DSH」的提示。此前没有任何超时:宿主一旦挂住,`busy` 会跟着那个永不 settle 的 promise 一起卡死,整页按钮同时变灰,既没有提示也没有恢复路径。宿主侧「你读到的那一行已经不在原来的位置」也会被明说:按 `slot` 找不到该行时返回 `stale entry: <名字>(注册表已变化,请刷新后重试)`,同名不唯一时返回 `ambiguous server name: …(N 条同名记录);请带上 slot`,HTTP 上都映射为 409(`lib/http-status.js`,有单测)——这是「刷新后重试」,不是宿主内部错误(502)。
## Schema 守卫(防止一个坏 schema 打挂整轮对话)
部分 MCP 服务器发布的工具 JSON Schema 含有模型 API 正则校验器无法接受的写法。典型例子是 REA v6.1.0 的 `"pattern": "^[^\\0]*$"`,DeepSeek 会直接拒绝**整个请求**:
```
Invalid schema for function 'mcp__rea-om__analyze_javascript_application':
"^[^\\0]*$" is not a "regex"
```
本插件在本进程内为 `mcp__*` 工具装了窄范围守卫(只影响 MCP 工具,卸载时还原),按保守策略修复:
| 情况 | 处理 |
|---|---|
| `\0`(NUL 转义) | 改写为等价的 `\x00` |
| 其他不可移植写法(反向引用 `\1`、lookbehind、`\p{...}` 等) | 删除该 `pattern` 关键字(schema 变宽松,不会变错) |
修复会写日志,并可通过 `GET /api/overall-mcp/tools` 查看**注册后的真实 schema**进行取证。
## 重复挂载的处理
若某个服务器名已被其它 mcp-client 挂载占用(例如 harness 家目录补丁里的 `rea-mcp` 已提供 REA),本插件**不再重复挂载**,该条目状态标为 `provided-externally`(界面显示蓝色点 +「已由其他插件提供(跳过重复挂载)」)。这样既避免重复工具集膨胀上下文,也避免重复连接把坏 schema 带进请求。
这类条目没有本插件挂载的 fiber,所以本插件**无法**停掉或移除那些工具:界面上仍然可以点「停用」/「移除」(那是在记录本插件的意图或清掉本插件的记录),但两个按钮的悬停提示、动作返回的 `note` 与模型工具的描述都会说明「工具仍由另一个插件提供」,不会反过来宣称已经断开(返回体带 `stillProvided: true`)。
## 注册表里的重复行
用户报的第二个问题是「多个 rea 的 MCP 这个事也没解决」:那台机器的 `registry.json` 里,同一条 `npx -y rea-agents@6.5.0 mcp` 有 **5 行**(4 个 `rea-cursor` + 1 个 `rea`)。原因有两层:① 早先的合并把与手工条目冲突的导入**改名**成 `name-`,而它每次扫描都可能再推进一行,于是同一份配置被改名前后各导入一次就留下两行,反复几次就是四行;② 折叠的依据不能只是名字——本插件给条目补的 `serverName`(挂载身份)会让旧记录看起来「不是同一条」,而 `findOrphans()`(按名字清理「来源已删除」的记录)刻意跳过带 `serverName` 的行,于是那些幽灵行永远清不掉。现在 `lib/scanner.js` 的 `collapseDuplicateRows(registry)` 在合并**写入前**先折叠:同一个「名字 + 命令契约(`transport`/`command`/`args`/`url`)」只留一行。留下的那一行优先带 `serverName`(挂载身份是最难重新推导的字段,留着它才能把已挂载的服务器认回来),其次是 `providedExternally: true`(外部提供的记录不能因为折叠而消失),再不行才是第一条;`enabled` **取自存活的那一行而不是整组 OR**——那一行才是真正被挂载的那条,折叠不允许改变服务器跑不跑;`about` / `updateCheck` / `updatedAt` 取最新,`importedAt` 取最早(它记的是「第一次见到」),整组里出现过 `providedExternally` 就保留该标记。折叠在三处发生:**插件加载时**对磁盘上的注册表跑一次(真的删了行才写盘,并 `log.warn` 记下名字与删了几行)、`mergeIntoRegistry` 的前后各一次,以及 `POST /api/overall-mcp/scan` 的返回体里带 `collapsed:[{name,dropped}]`——面板与调用方不需要猜。单测见 `test/scanner.test.mjs`(同一个服务器只留一行、带挂载身份的那一行活下来并且 `enabled` 不被改动、结果如实报告删了什么)。注意区分:这里的「重复行」是**同一条目在注册表里出现多次**(本插件负责),与下面「已由其他插件提供」的**跨插件重复挂载**是两回事——`rea`(harness 家目录补丁 `~/.dsh/cordis.patch.yml` 的 `rea-mcp`,界面显示「已由其他插件提供」)与 `rea-cursor`(本插件挂载的同一命令)本来就是两份配置,本插件不会替用户删掉其中一份。
## HTTP API(界面使用,Connection 鉴权)
`GET /api/overall-mcp/servers`、`GET /api/overall-mcp/tools`(schema 取证)、
`GET /api/overall-mcp/status`(这个进程在跑哪份代码、它在做什么,见下)、
`GET /api/overall-mcp/events?limit=`(动作日志,见下)、
`GET|POST /api/overall-mcp/census`(普查:读状态 / `{action:'start'|'stop'}`,见下)、
`GET|POST /api/overall-mcp/install-evidence`(恢复安装命令的走查:读状态 / `{action:'start'|'stop', force?}`,见下)、
`GET|POST|DELETE /api/overall-mcp/auth`(GitHub 凭据:读状态 / 存一个 token / 清除,见下)、
`GET|POST /api/overall-mcp/updates`(版本检查)、`POST /api/overall-mcp/update`(更新并重连)、
`GET|POST /api/overall-mcp/catalog?query=&source=&limit=&refresh=`(`source=census` 是本机普查索引的视图)、
`GET|POST /api/overall-mcp/sources`(自定义目录源:列出 / 添加)、`POST /api/overall-mcp/sources/remove`(移除一个,见下)、
`POST /api/overall-mcp/scan`、
`POST /api/overall-mcp/install`、`POST /api/overall-mcp/configure`、`POST /api/overall-mcp/set-enabled`、
`POST /api/overall-mcp/set-source-enabled`、`POST /api/overall-mcp/remove`、
`POST /api/overall-mcp/forget-orphans`(清理「来源已删除」的记录,见上)、
`POST /api/overall-mcp/favorite`(收藏,见下)、`GET /api/overall-mcp/favorites`(列出收藏)、
`GET /api/overall-mcp/repo-stats?repos=owner/name,...`
错误码分层(`lib/http-status.js`,单测 `test/http-status.test.mjs`):`unknown server`/`unknown source` → **404**;缺参数(`item/key/name/source/enabled ... required`)、`no installable transport`、`is not launched through a package runner`、给了值但不可用(`on must be a boolean`)、枚举里的非法动词(`unknown census action`、`unknown evidence action`)、把动词放进 GET(`... must be sent as a POST`)→ **400**;`ambiguous server name`(同名多条目,没带 `slot`/`from`)、`stale entry`(你读到的那一行已经不在原位)、`manual entry`(这个名字属于手工添加的记录,安装不覆盖它)→ **409**;上游版本查询失败(`version lookup failed`)→ **502**;其余一律 **500** 并由宿主 `log.warn` 记一行(含方法、URL 与消息)——500 的含义就是「这条路径我们没预料到」,早先它和「你参数写错了」共用 400,真正的事故看起来像用户的错。
读与写分得比参数校验更硬:`GET /census?action=start`、`GET /install-evidence?action=stop` 一律 **400**,因为一次预取不该成为「一次正在跑的走查到底停没停」的分界线。
`POST /api/overall-mcp/install` 的 body 是 `{ item, overwrite? }`;`/update`、`/remove`、`/set-enabled` 是 `{ name, slot?, from? }`(同名多条目时不带 `slot`/`from` 返回 409);`POST /api/overall-mcp/sources` 是 `{ url, label? }`(`label` 省略时由地址推断);`POST /api/overall-mcp/sources/remove` 是 `{ id }`。
`POST /api/overall-mcp/install` 的回复里 `server` 是**服务器名**(就是刚写进注册表、也是挂载用的那个名字),`installed` 是**布尔**——它与市场条目上的 `installed` 同一个意思。回复把两层结果分开:`connect` 是挂载结果(`{ok:false, error}` 时条目已写入注册表但**没有连上**,此时 `counted:false` 且 `note` 说明没有计入本市场安装数),后面可能跟着 `diagnosis: {text, route, cached?}`(模型给的解释)、`diagnosisSkipped: "no-llm"|"no-route"`(解释不了的原因)或 `diagnosisError`(调用失败的原因)——三者的取舍见「MCP 市场」一节的「安装失败时的自动诊断」。
`GET|POST /api/overall-mcp/sources` 与 `POST /api/overall-mcp/sources/remove` 是「自定义目录源」的读写面(`lib/custom-sources.js`,单测 `test/custom-sources.test.mjs`):GET 回 `{sources:[{id,label,url,submission}], max:8, error}`(`error` 是 `sources.json` 读不出来的原因,此时 `sources` 为空**但仍然可用内置六源**),POST 添加一个。`submission` 由宿主从地址推出(`lib/submission.js` 的 `githubRepoOfSourceUrl`):住在 GitHub 仓库里的清单得到 `{repo, url}`(`url` 就是预填好的 `issues/new` 地址),别处托管的清单得到 `null`、面板于是不画那个链接。校验在 `normalizeSourceUrl`(只放行 `https:`、必须带主机、拒掉带用户名口令的地址、长度上限 600)与 `validateSourceInput`(地址重复 → 409,含与自己的对比;标签上限 40 字符),落盘前还兜一层 `addSourceTo` 的上限检查(第 9 个 → 409 `too-many-sources`)。id 由 `sourceIdFor` 从 `host + pathname` 生成 `custom:`(≤48 字符,小写、非字母数字折成 `-`、去重尾),所以「同一个地址」永远是同一个 id;标签由 `defaultLabelFor` 推出 `owner/repo`(GitHub raw、jsDelivr 的 `gh/owner/repo@ref`)或主机名。`normalizeSourcesDocument` 在**读取时**重跑地址校验:手改文件塞进来的 `http://` / 带口令 / 超长的行会被丢掉而不是带着进市场。删除成功会调 `catalogCache.forgetSourceId(id)`,否则被删源的行还能从来源层缓存里活 60 秒。
`/catalog` 的缓存分两层(都在 `lib/catalog-cache.js` 里,单测 `test/catalog-cache.test.mjs`):
- **URL 层(条件请求)**:每个上游 URL 记住上一次的 `ETag` 与正文,下次带 `If-None-Match` 去问,`304` 就直接把那份正文交回去(不重新解析 JSON)。LRU 上限 64 个 URL / 8MB 正文,按写入时间淘汰。上游不支持 `ETag` 时这层自动退化为「空 validator + 每次都是 200」,不需要按来源写分支。
- **来源层(TTL)**:抓取结果按 `来源|查询串|单源上限` 缓存 **60 秒**,TTL 内连上游都不问;TTL 过了才去问,而问的结果又常常是 `304`。上游失败时回退到同一个键上一次的结果(条目标 `stale`、错误照报),冷启动失败才真的报空——上游抽风不该让整页变空。
- 缓存按参数显式传入(`browseCatalog({ cache })`),不传就是老行为(每次真抓)——单测里近百次 `browseCatalog({ fetchImpl })` 因此不需要知道缓存存在。
- 查询串截断到 200 字符后才参与缓存键(否则请求方可以拿超长 query 撑出无界键空间、每条都吃一份整份列表的内存)。手动 `refresh` 跳过来源层 TTL,但**仍然**走 URL 层的条件请求:刷新通常就是一次 `304`,元信息行会把「N 源未变」写出来。
- 用户自己加的目录源(见「MCP 市场」一节)走的是**同一条**路径:它们进 `CATALOG_SOURCES` 之外的动态列表,来源层缓存键就是它们的 id,`/catalog?source=<自定义 id>` 与内置源行为一致;`sources` 返回体因此把每个源写成 `{at, cached, stale, notModified, items, merged}`(或一个 `{error}`),面板的各源时间/状态行读的就是它。
- **`counts` 是合并后的数**:`sources[id].items` 仍是该源这次**走过**的条数,而 `counts[id]` 与 `sources[id].merged` 是**去掉重复之后计入本页**的条数——面板把它们与「共 N 条」并排,两者必须用同一把尺子(此前 `counts` 是 merge 前的 walked 数,加起来永远比总数多)。`deduped` 是这次被合并掉的重复条数(同一个仓库被多个来源同时收录),答空但没报错的来源显式给 0 而不是缺席。
模型工具 `overall_mcp_search` 与市场页共用同一条聚合查询(`lib/catalog.js` 的 `browseCatalog`):同一套去重、分组与排序,只是服务端把结果截到 50 条。
### 收藏(favorite)
`POST /api/overall-mcp/favorite`,body `{"key":":","on":true|false}`:写入想要的**终态**(`on: true` 必加、`false` 必删);不带 `on` 时退化成**切换**(宿主旧行为,见 `index.js` 的 `doToggleFavorite`;`on` 给了但不是布尔会报错,而不是当成切换)。
key 就是市场卡片的 React key,因为官方 Registry 的 id 随版本变化,所以用「来源 + 标题」做稳定身份。
收藏集存在 `registry.favorites`,随注册表一起持久化;`/catalog` 每条都会标注 `favorite`。
`GET /api/overall-mcp/favorites` → `{"favorites":[":",...],"count":N}`:**读取面**。需要它是因为 `/catalog` 只标注本次查询命中的条目:来源筛选或搜索词会把其余的藏起来,于是「我到底收藏了什么」需要独立答案。
面板上对应的是市场页筛选行里的**仅看收藏 (N)** 按钮:它是对当前列表的视图过滤(不是重新查询),并在宿主总数多于当前可见数时明说「共 N 条收藏;当前来源与搜索下能看到 M 条」,免得筛出来的空列表看起来像「一条都没收藏」。
实测:`{"key":"official-registry:official:ai.0si/disclosures","favorite":true,"favorites":[...]}`,再次浏览同一条目即返回 `favorite: true`。
## 扫描的宿主机位置
| 应用 | 位置(按平台展开) |
|---|---|
| Claude Desktop | `%APPDATA%/Claude/claude_desktop_config.json`、`~/Library/Application Support/Claude/...`、`~/.config/Claude/...` |
| Claude Code | `~/.claude.json`、`~/.claude/settings.json` |
| Cursor | `~/.cursor/mcp.json` |
| VS Code (Copilot) | `.../Code/User/mcp.json`(Windows 用 `%APPDATA%` 路径) |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` |
| Codex CLI | `~/.codex/config.toml`(`[mcp_servers.*]`) |
| Gemini CLI | `~/.gemini/settings.json`(`mcpServers`) |
仅读取上述白名单路径;不递归扫描任意目录,不收集遥测。
## 市场数据来源(2026-10 实测)
| 来源 | 端点 | 说明 |
|---|---|---|
| 官方 Registry | `registry.modelcontextprotocol.io/v0/servers` | 记录**按版本存储**(一条一个 version),因此全量翻页约 7242 条记录对应 2582 个 server;插件按 `_meta.io.modelcontextprotocol.registry/official.isLatest` 去重(无该标记时退到 `updatedAt`,再退到版本号),翻页后保留每个 server 的最新记录。安装信息取 `packages`(npm→`npx`,其他→`uvx`)或 `remotes`(streamable-http);作者取 `namespace` |
| Smithery | `registry.smithery.ai/servers` | 使用 `displayName`;`useCount`→使用次数、`iconUrl`→卡片图标、`verified`→认证徽标;安装走 `npx -y @smithery/cli run ` |
| Docker MCP Catalog | `hub.docker.com/v2/repositories/mcp/` | 约 245 个命名空间镜像,安装走 `docker run -i --rm mcp/`;`pull_count`→使用次数(这个列表上比 Star 更可靠的流行度信号);官方镜像标 ✓认证。**匿名只能读第一页**:第 2 页起上游回 `403 pagination offset too large for anonymous requests; sign in to page further`——这不是故障,而是它按「谁在问」给的答复,因此该源**不再整源降级**,而是交回已读到的 100 条并在来源行注明「匿名只放出第一页(共 245 个镜像,已取 100)——翻页需要登录」;面板的统计行会标「部分」。该 403 也不会被重试(重试 2.5 秒改变不了策略),只有真正的限流 403 才重试 |
| GitHub MCP | `api.github.com/search/repositories`(`q=mcp-server in:name archived:false`、`q=topic:mcp-server mcp in:description archived:false`、`q=topic:model-context-protocol mcp in:name archived:false`,请求带 `User-Agent`) | 仓库**发现**源:每查 100 条、Star 降序,跨查询按 `owner/repo` 去重后再按 Star 排全局前 200;只发 `full_name/description/homepage/topics/language/stargazers_count/forks_count/open_issues_count/pushed_at/archived`,**搜索响应里没有任何安装信息**(所以 `install: null`,卡片只给仓库、Star/Fork/Issue、语言与最近推送);`archived` 的行标已废弃。匿名配额 10 次/分钟 → 每次浏览固定 3 个请求、候选 300 行、上限 200 条;本机**有普查索引时改读索引(0 请求、上限 800 条、关键词在全量普查上过滤)**;行自己的 `install` 只会来自另一条后台链路(读仓库里的 `package.json`/`pyproject.toml`/`server.json`/README,见下文「从仓库证据恢复安装命令」),恢复出来的行会带上 `installSource` 说明出处 |
| Awesome 远程 | `raw.githubusercontent.com/punkpeye/awesome-remote-mcp-servers/main/README.md` | **托管端点**清单,条目形如 `- [标题](主页) \`https://端点/mcp\``,反引号里的 URL 可直接装成 streamable-http;🔓 免登录 / 🔐 需凭据,转成标签 |
| Awesome 清单 | `raw.githubusercontent.com/punkpeye/awesome-mcp-servers/main/README.md` | 社区索引(200+ 条),解析 `### <分类>` + `- [owner/repo](url) [glama 徽标] <语言/范围/系统 emoji> - 描述`;跳过 `![...]` 徽标图,取真正的仓库链接;作者/头像取 GitHub owner。仅发现 + 仓库链接(无法安全推断启动命令,显示「需手动配置」) |
> PulseMCP 的公开 API 已被上游日落(`v0beta` 返回 410 `API_SUNSET`,其他路径 404),因此**不再是默认来源**;适配器保留但默认不启用。Glama 的 API 需要 API Key,也未纳入。
>
> 评估过但未纳入的来源:
> - **npm search**(`registry.npmjs.org/-/v1/search`):无鉴权时 `popularity` 评分恒为 1,无法区分真正的 MCP server 与泛泛提到 mcp 的包,噪音过大;
> - **GitHub topic search**:未认证仅 10 次/分钟,且 `topic:mcp-server` 下混入 n8n、gemini-cli 这类并非 MCP server 的仓库(所以第二个查询额外要求描述里出现 `mcp`,剩下的行仍要靠人判断)。
>
> 同名服务器跨目录合并:以 GitHub 仓库作为共享身份,若同一仓库同时出现在多个目录中,只保留一条(优先可安装的那条),并在 `sources` 中记录全部来源。官方 Registry 与两个 Awesome 清单之间靠仓库 URL 互相去重,Docker 与远程端点各自独立。
## 数据
- `$DSH_HOME/overall-mcp/registry.json` — 注册表(权威)。写盘走临时文件 + `rename`(不会留下半截 JSON);解析失败时先把原文件复制成 `registry.json.corrupt-<时间戳>`,再用空注册表启动,并把原因随列表响应报给面板(`registryError`)——**不会被当成「首次运行」而覆盖**
- `$DSH_HOME/overall-mcp/installs.json` — 本市场安装计数(键是目录条目的 `id`,无 id 时回退 `name:<服务器名>`;只在挂载成功之后 +1)
- `$DSH_HOME/overall-mcp/scan-report.json` — 最近一次扫描报告
- `$DSH_HOME/overall-mcp/events.ndjson` — 动作日志(每行一个 JSON,最近 400 行 / 256KB,见「面板身份行与动作日志」):只记元数据,删掉不影响任何功能,只是面板里的「运行日志」变空
- `$DSH_HOME/overall-mcp/github-auth.json` — 面板里填的那个 GitHub token(`{version, token, at}`,文件权限 `0600`,临时文件 + `rename`)。**这是唯一存着明文凭据的文件**:删掉它(或在面板上点「清除」)立刻回到匿名,环境变量与 `gh` 仍可能另行生效;读出来只用于 `api.github.com` 与 `raw.githubusercontent.com`,见「GitHub 凭据与普查续跑」一节
- `$DSH_HOME/overall-mcp/github-install.json` — 从仓库里恢复出来的安装命令(同样是**派生数据**:删掉只是重新走一遍):`{version, at, requests, partial, rows:[{id, install, installSource, at, lastSeen}]}`;`installSource` 记录证据来自哪个文件、包在哪个 registry 上被确认存在;文件损坏只记警告并按「没有证据」处理
- `$DSH_HOME/overall-mcp/github-index.json` — GitHub 普查索引(派生数据,删掉只会触发一次重新普查):`{version, at, minStars, queries, requests, complete, partial, rows}`,每行是市场条目的形状 + `firstSeen`/`lastSeen`;写盘同样走临时文件 + `rename`,文件损坏只记警告并按「没有索引」处理(不隔离、不备份)。走查**每 5 秒**把当前进度写一次(`complete:false`、`partial:"正在抓取(已收 N 行)"`),所以中途被拒绝或退出时,下一次浏览仍能看到已经收到的那些行
- 市场列表每个来源各缓存 60 秒(只缓存原始数据,"已安装"标记每次读取时重新计算);上游 URL 的 `ETag` 与正文另存一层,`304` 时复用而不重抓(详见上文 `/catalog` 的两层缓存)
- GitHub Star 补充数据缓存 6 小时(失败结果同样缓存,避免反复冲击 GitHub 的额度——有 token 时用的就是那份额度)
## 安全
- 扫描只读白名单文件;stdio env 只引用变量名,不展开宿主机密值。
- 市场条目安装前在界面/工具结果中可见启动命令与来源,由用户确认。
- GitHub 凭据(面板填的 token / `DSH_GITHUB_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)**只在 `lib/github-auth.js` 里被读到明文**,且按主机白名单只发给 `api.github.com` 与 `raw.githubusercontent.com`——Docker Hub、npm、PyPI、Smithery、官方 registry 一个字节都拿不到;面板、日志(`events.ndjson`)与 `/status` 只见掩码与登录名,见「GitHub 凭据与普查续跑」。
- 本地插件,无对外 HTTP 面。
## 开发
```sh
npm test # 纯逻辑测试(lib/ 零依赖);脚本是显式文件列表
npm run test:registry # 复核已发布的 registry.json
npm run test:probes # 渲染探针:把真页面渲染出来并断言 DOM(含两支读实时目录的校验)
npm run test:layout # 需要本机真实 Chromium(CHROME_PATH),只在本机跑
npm run test:live # 需要活实例的 base 与 token(活实例专用)
npm run verify:ci # lint && test && test:registry && test:probes ← CI 跑这个
npm run verify # verify:ci && test:layout ← 提交前跑这个
# .github/workflows/verify.yml 在 push / PR / workflow_dispatch 上跑 verify:ci,矩阵 node 22.x 与 24.x;
# 本包无任何依赖,所以工作流里没有安装步骤、没有锁文件、没有缓存
node --test test/events.test.mjs # 动作日志本身:往返、按行/按字节修剪并留痕、损坏行计数、并发追加不互相截断、写不进去只回 null
node --test test/index.test.mjs # 宿主契约测试:真跑 apply(ctx),假宿主 + 临时 DSH_HOME(见下)
# 三个宿主 peer 依赖本仓库不安装,由 test/hooks.mjs 映射到 test/stubs/;npm test 的脚本是显式文件列表,新增测试要加进去
node tools/client-smoke.mjs # 客户端注册契约 smoke(模拟前端 module loader;断言客户端版本号 == package.json == CHANGELOG 最新小节)
node tools/render-detail.mjs # 渲染详情页:事实表有值、链接齐全、命令可见、复制按钮真的把命令交给剪贴板(打桩 navigator.clipboard 后驱动真实按钮)
node tools/render-card.mjs # 渲染卡片:标题是链接、正文不是 button、无嵌套交互元素、GitHub 标记/版本在位、命令行只在详情面板
node tools/render-market-tab.mjs # 渲染整个市场页 chrome:页头两行(含身份行与折叠的运行日志)、页签计数、搜索行、筛选行、元信息行、卡片流、页脚分工,以及「仅看收藏」过滤后如实说明藏了多少条
node tools/probe-server-about.mjs # 渲染「已注册」行:用途行在事实行之前、被 clamp 成两行、title 与证据来源一致、无证据时不留空元素
node tools/verify-css.mjs # 校验所有 className 在注入的样式表里都有对应规则(探针选择器除外,见文件内 HOOKS)
node tools/layout-probe.mjs # 真实 Chrome 量卡片几何:长名称必须省略号、CTA 必须在卡内(npm run test:layout)
node tools/shot.mjs # 把真页面渲染成 HTML 交给 headless Chrome:出七张对照截图(tools/out/*.png,含带 240px 侧栏的 frame-2col.png / frame-wide.png / frame-loading.png),断言栅格列数跟着**容器**走而不是窗口走、四种窗口宽度下面板都不出现横向滚动、且加载态不会把行拉伸铺满面板(`npm run test:layout`)
node tools/preview-paged.mjs "" 20 1 installs
# 按面板页的方式渲染某一页(可带 pageSize/page/sort/group,group 如"金融支付"/"__none__")
node tools/verify-sort-paging.mjs # 校验 6 种排序、3 种页宽、越界夹取、紧凑计数
node tools/verify-detail-links.mjs # 校验详情页链接:逐源覆盖、零死链、零重复 URL(进 test:probes)
node tools/verify-group-row.mjs # 校验分类 chip 行的显示/隐藏判定与计数之和 == 条目数(进 test:probes)
# 上面两支读实时目录:没有任何来源应答时它们 exit 2(不是 1)并打印「no source answered — nothing was verified (offline?)」,
# 因为「0 条死链」在 0 个条目上是空真的——CI 必须把离线读成失败,而不是读成通过
node tools/registry-submission.mjs --check --registry registry.json
# 复核已发布的清单(npm run test:registry,进 verify)
node tools/registry-submission.mjs --body-file /tmp/issue.md
# 单独跑一遍投稿校验(--write 才写盘;退出码 0/1/2 见「投稿到社区清单」)
node tools/probe-real-cards.mjs # 拉真实市场条目逐条渲染卡片+详情(活实例)
node tools/probe-media.mjs # 实测 /repo-media:五个真实仓库的图片数/尺寸/缓存命中耗时
node tools/probe-favorites.mjs # 收藏闭环实测(活实例:POST /favorite on/off → GET /favorites 计数 → /catalog 回读)
node tools/measure-awesome.mjs # 测量两个 Awesome 清单的真实深度(4224 + 928)
node tools/measure-sources.mjs # 逐源测上游返回条数(分页与截断的真相)
node tools/inspect-dsh-catalog.mjs [pkg] # 剖析社区目录包的内部结构
node tools/probe-sources.mjs # 探测各市场端点可用性与响应形状
node tools/probe-mcp-schema.mjs -- [args...] # 体检某个 MCP 服务器的工具 schema
node tools/dump-mcp-tool.mjs -- [args...] # 打印某个 MCP 工具的原始 schema
node tools/fixture-bad-schema-mcp.mjs # 故意带 `^[^\0]*$` 的测试用 MCP 服务器
```
| 文件 | 职责 |
|---|---|
| `lib/scanner.js` | 宿主机 MCP 配置扫描与规范化(纯函数);注册表合并(`mergeIntoRegistry`:手工条目占名、契约变了原地更新、逐字节相同则跳过)与重复行折叠(`collapseDuplicateRows`:同一「名字 + 命令契约」只留一行并报告删了什么) |
| `lib/catalog.js` | 6 个远程开放目录的拉取、归一化、跨目录合并、排序与 GitHub 补充;GitHub 仓库搜索源(`fetchGithubMcp`,请求带 `User-Agent`,有普查索引时改读索引)与限流重试(`withFastRetry`:`429/403/408/425/5xx` 与瞬时网络错误各重试一次、退避 2.5 秒,`404` 从不重试);自定义目录源的解析(`mapCustomEntries`)与 `runSource` 的分支也在这里(纯 fetch)。匿名搜索配额是这里的一份进程级状态(`githubSearchQuota()` / `forgetGithubSearchQuota()`:读 `x-ratelimit-remaining/reset` 与 `Retry-After`,空窗口不再重试、不再发请求、也不把来源标成失败) |
| `lib/github-index.js` | GitHub 普查:按 Star 窗口切分查询、限速走页、跨窗口去重、与上一份索引合并(`firstSeen`/`lastSeen`、30 天滑出)、文件读取与损坏容错、新鲜度与 `note` 文案、停下时的具名原因(`stopReason`:配额用尽 / 请求预算用完 / 第 N 次请求失败),以及每 5 秒一次的中途进度载荷(`onProgress` 带当前行的实时快照,调用方据此增量落盘);纯逻辑,搜索函数由调用方注入 |
| `lib/repo-install.js` | 从仓库证据恢复安装命令:`package.json` 的 `bin` / `pyproject.toml` 的 scripts / `server.json` / README 里作者自己写的代码块 → 先确认包在 npm 或 PyPI 真实存在,再拼出命令;带出处文案(`describeInstallSource`)、限速走页(150ms/请求、≤6000 请求、25 分钟上限)、与上一份证据合并、文件读写与损坏容错;纯逻辑,读文件与查 registry 的函数由调用方注入 |
| `lib/catalog-cache.js` | 两级缓存:URL 层的 ETag/304 复用(LRU 64 个 URL / 8MB)与来源层的 60 秒 TTL(连 `note` / `index` / `dataAt` 一起存,否则命中缓存那一次会把「本地索引 N 行」和普查时间戳吃掉);`forgetSourceId` 用于删源后清账 |
| `lib/custom-sources.js` | 自定义目录源的文档形状:地址校验(只收 https、拒凭据)、id 派生、标签推断、增删与读写容错 |
| `lib/submission.js` | 投稿上行:从 issue 正文解析一条 listings 条目、校验规则、去重键,以及面板用的预填 `issues/new` 地址(`buildIssueUrl`) |
| `lib/install-diagnosis.js` | 安装失败时的诊断文本:系统提示、条目事实抽取(不发 `env`/`headers`)、凭据遮蔽、读取模型回答(纯函数,便于单测) |
| `lib/about.js` | 「这个 MCP 是干什么的」:从服务器 `instructions` / 已注册工具 / 启动命令中取一句人话,含 markdown 剥离、截断与证据标签;以及判断何时需要重读 system prompt 的 `needsInstructionsRead()` |
| `lib/http-status.js` | 把宿主内部的错误映射成 HTTP 状态码(`unknown server|source`→404、缺参数/不可安装传输→400、上游版本查询失败→502、其余 500),供 `index.js` 的 `guard` 使用 |
| `lib/events.js` | 动作日志:`emit()` 只把事件交给注入的 sink(没有 sink 就是空操作,sink 抛错被吞掉),`appendEvents()` 串行追加、按行数/字节数修剪并在最前面留一行 `trim` 记录,`readEvents()` 读尾部并统计读不动的行,`eventFileFacts()` 给 `/status` 报文件现状 |
| `index.js` | cordis 宿主插件(`inject:['tools']`):注册表、每服务器 `ctx.plugin(mcpClient)` 桥接、`defineTool` 管理工具、HTTP 路由 |
| `client/client.js` | `__ModuleLoader__` 客户端插件:注册侧栏入口(`sidebar.panellist`,id `overall-mcp`,order 5)与面板本体(布局的 `main` 键控插槽,同一 id) |
| `cordis.patch.yml` | 宿主 composition 补丁 |
## 发布包内容
`package.json` 增加了 `files` 白名单,进包的只有这 **26 个**(`npm pack --dry-run` 实测,含 npm 强制带上的 `package.json` 与 `LICENSE`):`index.js`、`lib/`(**17 个** `.js`)、`client/client.js`、`cordis.patch.yml`、`registry.json`、`README.md`、`README.zh-CN.md`、`CHANGELOG.md`。刚收窄时是 **76 个文件 / 1.25 MB → 23 个文件 / 0.65 MB**(tarball 410 KB → 229 KB);现在 **26 个文件 / tarball 280.1 kB / 解包 796.2 kB**(`README.md` 与 `CHANGELOG.md` 是后来进白名单的,`lib/` 从 16 个涨到 17 个不需要动白名单——`lib` 是整目录)。
被排除的是开发期资产,没有任何一个是运行时读取的:`tools/`(33 个文件,含渲染探针产出的截图)、`test/`(22 个)、`prototype/`(2 个)、`INTEGRATION-TODO.md`、`.github/`(4 个)、`.gitattributes`。已核对:`index.js` 读的是数据目录下的 `registry.json`(`$DSH_HOME/overall-mcp/`),不是仓库根这一份;`tools/registry-submission.mjs` 只由 CI 在 checkout 上运行;`tools/verify-detail-links.mjs` 等探针只在开发与 CI 里跑。新增 `test/`、`tools/` 下的文件不影响发布体积。
## 常见问题
**「MCP 市场」显示 `HTTP 404` / 一直停在「正在发现…」**
宿主插件的代码在 DSH **启动时**载入一次。若在 DSH 已经打开的情况下更新了插件(本插件用 `link:` 安装在工作区时尤其常见),侧栏页面里的前端是新版、而宿主仍是旧版,`/api/overall-mcp/catalog` 这个路由就还不存在,于是返回 404。
- 完全退出 DSH(桌面版注意托盘图标,关窗口不等于退出)后重新打开;
- 新版页面会直接把这种情况提示为「宿主插件还是旧版本(缺少 /api/overall-mcp/catalog)」,而不是只显示一个 404。
- 先看页头第二行:它写着 `插件 v… · pid … · 已运行 …`,`pid` 不变、`已运行` 一直涨,就说明你看的还是那个进程;如果这一行右边还有一颗「⚠ 代码已更新,重启 DSH 后生效」,那正是本情况被页面自己认出来了(磁盘 mtime 比进程启动时刻新)。
**我改了插件代码,面板里还是旧行为**
同上:宿主在启动时只载入一次,重启 DSH 才会载入新代码。页头的身份行就是为此加的——`插件 v…` 后面那颗「⚠ 代码已更新,重启 DSH 后生效」把「还是坏的」和「还没载入」分开说;`GET /api/overall-mcp/status` 里的 `plugin.source.stale` 是同一个判断的机器可读版本(`plugin.source.files` 列出 `index.js`、`client/client.js`、每个 `lib/*.js` 的 `mtime` 与大小,`plugin.dir` 是宿主实际载入的目录——用 `link:` 装在工作区时,这个目录不是 `node_modules` 里那份)。
**点了分组总开关,为什么要等好几秒**
因为在那个来源下面,每一行都要真的起一个 `dsh-mcp-client` 子进程并完成握手,这不是改一个布尔值。这一步此前是串行的,最坏 `成员数 × 8 秒`(实测五个成员 40 秒);现在一次最多 4 条同时在飞(`CONNECT_CONCURRENCY = 4`),所以 5 个成员约两批、10 个成员约三批。等待期间按钮上写着「正在切换…」就是这段真实工作的进度,不是卡住。
同一轮还保证**同一个名字只挂一次**:注册表里如果因为历史原因存在同名多行(见「注册表里的重复行」),此前它们会各自去挂一次,败者被记成「另一个 mcp-client 已提供」——那是并发自己造出来的假象。现在同名兄弟共享第一行的裁决。
**我只想看本机已经普查到的那些仓库,不想再花请求**
在「MCP 市场」页的「来源」筛选里选 `本地普查(索引)`(就排在 `GitHub` 右边):
- 它读的是本机普查索引,**一次请求都不发**;索引过期也不会顺手去走一遍——想让它刷新,是那张卡上的「开始普查」的事。
- 它**不是一个新来源**:条数仍算在 GitHub 那一个里(一个上游一个数字),只有数字行的名字按你选的那个写(`本地普查(索引) 1.8k`),筛选器和数字行因此不会各说一套。
- 索引还是空的时它直接说「本机还没有普查索引」,并指向 GitHub 访问卡里的「开始普查」;视图只读,不会替你开始任何东西。
- 命令行等价物:`POST /api/overall-mcp/catalog {"source":"census"}`。
**我想现在就跑一次普查,或者让它停下来**
这两个动作都在「已注册」页的 GitHub 访问卡里:那一行写着普查状态(索引多少行、上次读到什么时候、上次是谁停的),右边按状态给「开始普查」或「停止普查」——没在跑时是前者,在跑时是后者并显示进度。
- 「开始普查」不等缓存过期,现在就把缺的仓库补上;搜索额度还没恢复时它会明确回一句「额度未恢复(还需 N 秒)」而不是干等,也不会花一次请求去换一个 403。
- 「停止普查」是立刻生效的:走到一半收到索引里的那些行不会丢(它们本来就在走的过程中落盘),下一次开始会从缺的仓库接着走;被停掉的那次不会再顺带去抓安装证据——按了停止就不该在别处继续花额度。
- 停止之后那一行会写「上次是手动停止的」。这不是多余的话:`crawlGithubRepos` 自己只会说「已中止」,而「我按的」和「插件被卸载」需要分开。
- 命令行等价物是 `POST /api/overall-mcp/census` 带 `{"action":"start"|"stop"}`,`GET` 同一条路由只读状态、不触发任何请求。
**我想现在就跑一遍「恢复安装命令」,或者让它停下来**
这两个动作也在同一张 GitHub 访问卡里,就在普查那一行的下面:那一行写着 `查过 N 个仓库 · 恢复出 M 条安装命令`,右边按状态给「接着恢复」或「停止恢复」。
- 这趟走查读的是仓库里的 `package.json` / `pyproject.toml` / `server.json` / README,走 `raw.githubusercontent.com`(不吃 API 配额),自己限速 150ms/请求、上限 6000 个请求 / 25 分钟;
- 「停止恢复」在两个请求之间生效:当前那条读完后收手,已经恢复的命令照旧写进 `github-install.json`,行上会写「已手动停止(已试 N 个仓库,下次接着走)」;
- 命令行等价物是 `POST /api/overall-mcp/install-evidence` 带 `{"action":"start"|"stop"}`(`{"force":true}` 忽略新鲜度检查),`GET` 同一条路由只读状态、不触发任何请求;
- 停止之后那一行会写「上次是手动停止的」;`dispose`(插件被卸载)会写成另一个理由,因为「我按的」和「宿主被卸了」不是同一件事。
那是 GitHub 侧的配额或网络抖动,不是插件坏了。匿名额度是搜索 10 次/分钟、核心 API 60 次/小时;一次完整普查要读几千个仓库的文件,匿名基本跑不完。
- 在「已注册」页顶部的 GitHub 访问卡里填一个 token(或点「换成我的 GitHub 登录」,机器上登录过 `gh` 就不用填),额度变成 5000 次/小时;
- **不填也能继续**:再点一次「开始普查」是从断点继续——已经有证据的行会被跳过(`needsInstallEvidence`),所以每次点都会往前推一点;这个动词就在同一张卡上,不必等缓存过期;下面那行恢复走查同理,「接着恢复」也在同一张卡上;
- 走到哪了看 `GET /status` 或 `/events` 的普查那一行:`planned / attempted / remaining / retries` 四个数把「计划多少、走到多少、还剩多少、重试了几次」分开说。
**填了 token,面板还是显示「匿名访问」**
只有**校验通过**的 token 才会成为活跃凭据(用 `GET /user` 验一次),打错的 token 立刻退回匿名,这样至少不会把每个请求变成 401。看那张卡的状态行:`error` 会写明是「401(token 无效或已过期)」还是网络不通。另外宿主代码在启动时载入一次,新填的 token 不需要重启(路由是同一个进程),但**本轮新加的 `/auth` 路由**要重启 DSH 之后才存在。
该来源只提供仓库地址(社区 Awesome 清单即如此),无法安全推断启动命令。点「仓库」按该项目的 README 自行配置;官方 Registry 与 Smithery 的条目通常可直接「安装」。
## License
MIT