// 「插件与能力」页的**来源分组**逻辑(纯函数、零 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(); 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:`)。 */ 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)); }