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` 通过。
This commit is contained in:
1 parent
7ea2850f18
commit
86aef7b454
15 files changed
+1094
-48
No files matched your search
@@ -0,0 +1,111 @@
|
||||
// 「插件与能力」页按上游 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`);
|
||||
Reference in new issue
Block a user