Files
Cyrene_v2/frontend/web/src/admin/modules/pluginOrigin.test.ts
T
AskaEth 86aef7b454 feat(capability): 工具按上游 MCP server 分节(来源章 + 呈现隔离)
主人 2026-10-24:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器分类
并与其他插件相对隔离?」

归属信息此前后端根本没有(GET /admin/plugins 每条只有 name/kind/description/两个
布尔),而 `mcp:` 是每台 server 一条的 KindMCP 条目的注册键,**不是**工具的命名空间
(工具名由 server 原样给出,连前缀都没有)⇒ 只能靠注册时的元数据,不能解析名字。

后端(唯一汇点盖章):
- 新增 pkg/plugins/sdk/origin.go:Origin{Kind,Server,Transport} + 可选接口
  OriginAware(没实现 = 宿主内置,老工具零改动)+ OriginOf(零值视为无来源)。
- registry.Capability 加 Origin 字段;RegisterTool 盖章(覆盖热装与 .env
  MCP_SERVERS_JSON 两条接入路径的合流处),ReplaceTool **同样重新盖章**(它逐字段
  重建条目,漏了就是「热替换把来源抹掉」)。
- mcp.mcpTool 实现 Origin()(server 名与传输都在 cfg 上),transport 用新的
  transportNameOf 报**实际会用的**形态(stdio/sse/http),与 newTransport 同一推断。
- 网关 PluginSummary 加可选 origin(omitempty):零值 ⇒ 键缺席,不是空对象。
- 卸载守卫:对单把 MCP 工具或 `mcp:<server>` 登记条目直接 400
  (plugin_uninstall_scope)并指向「MCP 服务器」页 —— 原来会绕过插件管理器裸删,
  server 的 names 账本不同步、重连后工具复活。判据是来源章,不是名字前缀。

前端(呈现分组):
- admin/modules/pluginOrigin.ts:纯函数按 origin.server 分节(按 server 名排序),
  transport 未报告时标题省略括号,缺 server 名不冒充内置。
- Plugins.tsx:工具段两级(`来自 fs(stdio)· N 把工具` + 宿主内置),MCP 工具行去掉
  「卸载」改为指向 MCP 页;`kind:"mcp"` 登记条目与未知 kind(iot)各有段如实显示,
  不再静默丢弃(design/09 §5.2.1 红线③)。

零影响(有断言):tool_policies 表/策略写路径未改;ToolSchemas 不发来源(测试断言
模型面既无 origin 键也无 server 名);停用 server ⇒ 条目连来源一起消失,无孤儿。

测试:registry 盖章/保章(两条均已实测变异变红)/内置零值/改策略不清章;mcp 注册
路径盖章 + transport 推断表;网关读面缺席与存在、热装整链、卸载守卫;前端 esbuild+cjs
逻辑测试 12 项。验收:go build/vet/test 全绿,frontend `npm run build` 通过。
2026-10-24 20:21:22 +08:00

112 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 「插件与能力」页按上游 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`);