// 「插件与能力」页按上游 server 分组的**纯逻辑**测试(DevDocs/audit/MCP工具按上游分组-2026-10-24.md)。 // // 锁四件事: // ① **归属只看 origin,不看名字**:MCP 工具名里没有任何前缀(`mcp:` 是 KindMCP // 条目的注册键),所以判据必须是后端盖的章 —— 本测试给工具起一个带 `mcp:` 字样的 // 名字但**无 origin**,它必须落进「宿主内置」; // ② **transport 不编**:后端没报告 transport 时标题省略括号,而不是假设 stdio; // ③ **空 server 不冒充内置**:报告了 mcp 来源却没说哪台 server 的工具,仍归 MCP 段 // (标题写「未知 MCP server」),不能被并进「宿主内置」—— 那是不同的结论; // ④ **不得静默丢弃**:`kind:"mcp"` 登记条目与未知 kind(如 iot)都有专门的取值口。 // // 项目没有前端测试框架,所以这是一个零依赖的 plain-node 检查(同目录既有手法): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/pluginOrigin.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-plugin-origin.test.cjs \ // && node /tmp/cyrene-plugin-origin.test.cjs // // `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、 // 不 import node:*。 import type { AdminPluginRecord } from "../../lib/types"; import { mcpServerEntries, originGroupTitle, otherCapabilityEntries, toolOriginBuckets, } from "./pluginOrigin"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function same(got: unknown, want: unknown, what: string): void { const g = JSON.stringify(got); const w = JSON.stringify(want); ok(g === w, `${what} — got ${g}, want ${w}`); } /** 造一条能力记录(origin 只在给定时出现,模拟后端 omitempty)。 */ function rec( name: string, kind: string, origin?: { kind: string; server?: string; transport?: string }, ): AdminPluginRecord { const base: AdminPluginRecord = { name, kind, description: "", allowInGroup: false, allowInStranger: false }; return origin ? { ...base, origin } : base; } // ① 两个 server 各两把工具 + 两把内置工具(其中一把名字里带 mcp: 字样的假前缀)。 const rows: AdminPluginRecord[] = [ rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }), rec("write_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }), rec("search", "tool", { kind: "mcp", server: "github", transport: "http" }), rec("mcp:read_file", "tool"), // 名字像 MCP,但没有来源章 ⇒ 内置 rec("web_search", "tool"), ]; const buckets = toolOriginBuckets(rows); // ① 归属只看 origin:两个 MCP 分节(按 server 名排序),其余归内置。 same( buckets.mcp.map((g) => [g.server, g.transport, g.rows.map((r) => r.name)]), [ ["fs", "stdio", ["read_file", "write_file"]], ["github", "http", ["search"]], ], "MCP 工具按 origin.server 分节(按 server 名排序)", ); same( buckets.builtin.map((r) => r.name), ["mcp:read_file", "web_search"], "无 origin 的归宿主内置 —— 名字里带 mcp: 不算来源", ); // ② 标题:transport 有则带括号,无则省略(不编 stdio);计数是「把工具」。 same(originGroupTitle(buckets.mcp[0]), "来自 fs(stdio)· 2 把工具", "带 transport 的组标题"); same( originGroupTitle({ key: "mcp:x", server: "x", transport: "", rows: [rec("a", "tool")] }), "来自 x · 1 把工具", "未报告 transport 时省略括号,不编默认值", ); // ③ 报告了 mcp 却没说哪台 server:仍属 MCP 段,不得冒充内置。 const anonymous = toolOriginBuckets([rec("orphan_tool", "tool", { kind: "mcp" }), rec("web_search", "tool")]); same(anonymous.mcp.length, 1, "缺 server 名的 MCP 工具仍是 MCP 段的一员"); same(anonymous.mcp[0].server, "", "缺 server 名时 server 留空,不编一个名字"); same(originGroupTitle(anonymous.mcp[0]), "来自 未知 MCP server · 1 把工具", "缺 server 名的标题如实写未知"); same(anonymous.builtin.map((r) => r.name), ["web_search"], "内置桶只装真的没有 mcp 来源的工具"); // ④ 不得静默丢弃:KindMCP 登记条目与未知 kind 各有取值口。 const mixed: AdminPluginRecord[] = [ rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }), rec("mcp:fs", "mcp"), rec("kb_search", "skill"), rec("lamp", "iot"), rec("kook", "channel"), ]; same(mcpServerEntries(mixed).map((r) => r.name), ["mcp:fs"], "kind:mcp 登记条目要能被页面拿到"); same(otherCapabilityEntries(mixed).map((r) => r.name), ["lamp"], "未知 kind(iot)不得被静默过滤"); same(toolOriginBuckets(mixed).builtin.length, 0, "只有工具行进工具桶(技能/渠道/登记条目都不进)"); // 空输入不炸,且三个出口都是空数组而不是 undefined。 const empty = toolOriginBuckets([]); same([empty.mcp.length, empty.builtin.length], [0, 0], "空清单得到空结果"); console.log(`pluginOrigin: ${checks} checks passed`);