主人 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` 通过。
94 lines
4.7 KiB
TypeScript
94 lines
4.7 KiB
TypeScript
// 「插件与能力」页的**来源分组**逻辑(纯函数、零 React 依赖)。
|
||
//
|
||
// 主人 2026-10-24 的要求:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器
|
||
// 分类并与其他插件相对隔离?」结论(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
|
||
// 归属**只能来自注册时的元数据**(`origin`),不能解析工具名 —— MCP 工具名由 server
|
||
// 自己给,宿主不加任何前缀(`mcp:` 是 KindMCP 条目的注册键,不是工具的命名空间)。
|
||
//
|
||
// 因此本模块只做三件事:
|
||
// ① 把 `kind:"tool"` 的行按 `origin.server` 切成分节,无 MCP 来源的归「宿主内置」;
|
||
// ② 生成分节标题(`来自 fs(stdio)· 12 把工具`)——transport 未报告时省略括号,
|
||
// 绝不编一个默认值;
|
||
// ③ 把不再是「工具/技能/渠道」行的事实摆出来(`mcp` 登记条目、其它未知 kind),
|
||
// 供页面**如实显示**而不是静默丢弃(design/09 §5.2.1 红线③)。
|
||
//
|
||
// 这里判的是「呈现归属」,不涉及权限:工具能不能被调用仍只由 tool_policies 的逐工具
|
||
// 布尔 + actor 元数据决定,分组既不收窄也不放宽任何权限。
|
||
|
||
import type { AdminPluginRecord } from "../../lib/types";
|
||
|
||
/** 一个上游 MCP server 承载的工具分节。 */
|
||
export interface ToolOriginGroup {
|
||
/** React key:同一 server 的所有工具共用一个。 */
|
||
key: string;
|
||
/** server 名(plugins.json / MCP_SERVERS_JSON 的 name);空串 = 报告了 MCP 却没说哪台。 */
|
||
server: string;
|
||
/** stdio / sse / http;空串 = 后端未报告。 */
|
||
transport: string;
|
||
rows: AdminPluginRecord[];
|
||
}
|
||
|
||
/** 工具段的两级分节结果。 */
|
||
export interface ToolOriginBuckets {
|
||
/** 按上游 server 分节(按 server 名排序);只含来自 MCP 的工具。 */
|
||
mcp: ToolOriginGroup[];
|
||
/** 没有 MCP 来源的工具:宿主内置 / 来源未知。 */
|
||
builtin: AdminPluginRecord[];
|
||
}
|
||
|
||
/** 页面自己成段渲染的 kind;其余 kind 必须落到「其它能力」段,不得静默丢弃。 */
|
||
export const RENDERED_KINDS = ["tool", "skill", "channel", "mcp"] as const;
|
||
|
||
/** record 是否来自一台 MCP server(判据是后端盖的来源章,不是名字)。 */
|
||
export function isMCPOrigin(rec: AdminPluginRecord): boolean {
|
||
return rec.origin?.kind === "mcp";
|
||
}
|
||
|
||
/** 把工具行按上游 server 分节;无 MCP 来源的归「宿主内置」桶。
|
||
*
|
||
* 只收 `kind === "tool"` 的行:技能/渠道/MCP 登记条目由页面自己成段渲染(见
|
||
* `RENDERED_KINDS` 与另两个取值口),它们**不是**工具、也不该被算进任何一个工具分节。
|
||
* 传整份清单进来是安全的 —— 忽略 ≠ 丢弃,其余 kind 在页面上各有位置。 */
|
||
export function toolOriginBuckets(rows: AdminPluginRecord[]): ToolOriginBuckets {
|
||
const groups = new Map<string, ToolOriginGroup>();
|
||
const builtin: AdminPluginRecord[] = [];
|
||
for (const rec of rows) {
|
||
if (rec.kind !== "tool") continue;
|
||
if (!isMCPOrigin(rec)) {
|
||
builtin.push(rec);
|
||
continue;
|
||
}
|
||
const server = (rec.origin?.server ?? "").trim();
|
||
const transport = (rec.origin?.transport ?? "").trim();
|
||
const key = `mcp:${server}`;
|
||
const group = groups.get(key);
|
||
if (group) {
|
||
group.rows.push(rec);
|
||
// 同一 server 的传输形态应当一致;万一不一致,保留先到的那个而不是拼接,
|
||
// 免得标题读成「stdio/sse」(拼接会让人以为一台 server 同时两种传输)。
|
||
continue;
|
||
}
|
||
groups.set(key, { key, server, transport, rows: [rec] });
|
||
}
|
||
const mcp = [...groups.values()].sort((a, b) => (a.server < b.server ? -1 : a.server > b.server ? 1 : 0));
|
||
builtin.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||
return { mcp, builtin };
|
||
}
|
||
|
||
/** 分节标题:`来自 fs(stdio)· 12 把工具`;transport / server 缺失时如实省略。 */
|
||
export function originGroupTitle(group: ToolOriginGroup): string {
|
||
const count = `${group.rows.length} 把工具`;
|
||
const server = group.server || "未知 MCP server";
|
||
return group.transport ? `来自 ${server}(${group.transport})· ${count}` : `来自 ${server} · ${count}`;
|
||
}
|
||
|
||
/** `kind:"mcp"` 的登记条目(每台 server 一条,名字是 `mcp:<server>`)。 */
|
||
export function mcpServerEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
|
||
return rows.filter((rec) => rec.kind === "mcp");
|
||
}
|
||
|
||
/** 既不是工具/技能/渠道、也不是 MCP 登记条目的能力(如 iot):必须如实显示。 */
|
||
export function otherCapabilityEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
|
||
return rows.filter((rec) => !(RENDERED_KINDS as readonly string[]).includes(rec.kind));
|
||
}
|