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
@@ -7,6 +7,12 @@ import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Switch, Tag } from "../../components/primitives";
|
||||
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
|
||||
import {
|
||||
mcpServerEntries,
|
||||
originGroupTitle,
|
||||
otherCapabilityEntries,
|
||||
toolOriginBuckets,
|
||||
} from "./pluginOrigin";
|
||||
|
||||
type CapKind = "tool" | "skill" | "channel";
|
||||
|
||||
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
|
||||
channel: "渠道 Channel",
|
||||
};
|
||||
|
||||
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
|
||||
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
|
||||
|
||||
// Channel adapters are not gated by the tool policy (group/stranger markers are a
|
||||
// tool concept; a channel is configured via its own 渠道管理 page), so the
|
||||
// channel block shows identity + a pointer rather than the policy switches.
|
||||
@@ -27,7 +36,44 @@ function channelColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a tool / skill row (has environment markers, owner can edit). */
|
||||
/** The environment-marker switches, shared by tool and skill rows.
|
||||
*
|
||||
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
|
||||
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
|
||||
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
|
||||
function policyColumn(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||
): AdminColumn<AdminPluginRecord> {
|
||||
return {
|
||||
key: "policy",
|
||||
title: "环境授权(owner)",
|
||||
render: (p) => (
|
||||
<span className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInGroup}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInStranger}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
|
||||
</label>
|
||||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
|
||||
function registeredColumns(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
@@ -38,31 +84,7 @@ function registeredColumns(
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
{
|
||||
key: "policy",
|
||||
title: "环境授权(owner)",
|
||||
render: (p) => (
|
||||
<span className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInGroup}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInStranger}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
|
||||
</label>
|
||||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
policyColumn(isOwner, busy, setPolicy),
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
@@ -75,6 +97,61 @@ function registeredColumns(
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a tool that belongs to an MCP server.
|
||||
*
|
||||
* 🔴 行内**没有**「卸载」:单把 MCP 工具不是独立安装单元 —— 卸载它只能绕过插件管理器
|
||||
* 裸删注册表条目,而该 server 的 names 账本不同步(重连/refresh 后工具复活,用户以为
|
||||
* 卸掉了)。服务端同样拒绝这条路径(`plugin_uninstall_scope`),这里给出该去哪里做。 */
|
||||
function mcpToolColumns(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||
): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{
|
||||
key: "origin",
|
||||
title: "来源",
|
||||
render: (p) => <Tag tone="neutral" title="注册时由后端盖章的上游归属,不从工具名推断">{p.origin?.server || "未知 server"}</Tag>,
|
||||
},
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
policyColumn(isOwner, busy, setPolicy),
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: () => <span className="text-xs text-[var(--mu-text-lo)]">{MCP_PAGE_HINT}</span>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */
|
||||
function mcpServerColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "登记键", render: (p) => <span className="font-mono text-xs font-medium">{p.name}</span> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
{
|
||||
key: "admin",
|
||||
title: "管理",
|
||||
render: () => (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for capability kinds the page does not otherwise model (e.g. iot).
|
||||
*
|
||||
* design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */
|
||||
function otherColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{ key: "kind", title: "类型", render: (p) => <Tag tone="neutral">{p.kind}</Tag> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
];
|
||||
}
|
||||
|
||||
export function PluginsModule(): React.ReactNode {
|
||||
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
|
||||
useAutoReload(reload, 10000);
|
||||
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
|
||||
return groups;
|
||||
}, [data]);
|
||||
|
||||
const toolSkillColumns = registeredColumns(isOwner, busy, setPolicy, uninstall);
|
||||
// 工具段的两级分节:先按上游 server,再剩「宿主内置」。
|
||||
const toolBuckets = useMemo(() => toolOriginBuckets(byKind.tool), [byKind.tool]);
|
||||
const mcpServers = useMemo(() => mcpServerEntries(data ?? []), [data]);
|
||||
const others = useMemo(() => otherCapabilityEntries(data ?? []), [data]);
|
||||
|
||||
const toolCols = registeredColumns(isOwner, busy, setPolicy, uninstall);
|
||||
const mcpToolCols = mcpToolColumns(isOwner, busy, setPolicy);
|
||||
const channelCols = channelColumns();
|
||||
const serverCols = mcpServerColumns();
|
||||
const otherCols = otherColumns();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="插件与能力"
|
||||
subtitle="已注册能力(按工具 / 技能 / 渠道分组)+ 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||||
subtitle="已注册能力(工具按上游 MCP server 分节 / 技能 / 渠道)+ 运行时 MCP 安装 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||||
/>
|
||||
|
||||
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
|
||||
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">已注册能力</h3>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组</span>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 工具按上游 server 分节</span>
|
||||
</div>
|
||||
|
||||
{(["tool", "skill"] as CapKind[]).map((kind) => {
|
||||
const rows = byKind[kind];
|
||||
return (
|
||||
<div key={kind} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS[kind]}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS.tool}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
{byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{toolBuckets.mcp.map((group) => (
|
||||
<div key={group.key} className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Tag tone="neutral" title="来源由后端在注册时盖章,不从工具名解析">
|
||||
{originGroupTitle(group)}
|
||||
</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">server 级动作见「MCP 服务器」页</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolSkillColumns}
|
||||
rows={loading ? null : rows}
|
||||
columns={mcpToolCols}
|
||||
rows={loading ? null : group.rows}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle={`无${KIND_LABELS[kind]}`}
|
||||
rowKey={(p) => `${kind}:${p.name}`}
|
||||
emptyTitle="无工具"
|
||||
rowKey={(p) => `mcp:${group.key}:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">宿主内置工具</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{toolBuckets.builtin.length} 把</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolCols}
|
||||
rows={loading ? null : toolBuckets.builtin}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无宿主内置工具"
|
||||
rowKey={(p) => `tool:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS.skill}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.skill.length} 个</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolCols}
|
||||
rows={loading ? null : byKind.skill}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle={`无${KIND_LABELS.skill}`}
|
||||
rowKey={(p) => `skill:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
|
||||
rowKey={(p) => `channel:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台
|
||||
server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关
|
||||
(`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">MCP server 登记</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{mcpServers.length} 台</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={serverCols}
|
||||
rows={loading ? null : mcpServers}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无 MCP server 登记(可在「MCP 服务器」页添加)"
|
||||
rowKey={(p) => `mcpentry:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{others.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">其它能力</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{others.length} 个 · 本页暂无专用视图,如实列出</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={otherCols}
|
||||
rows={others}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无其它能力"
|
||||
rowKey={(p) => `other:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Surface>
|
||||
|
||||
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice>
|
||||
<Notice>
|
||||
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
|
||||
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
|
||||
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,93 @@
|
||||
// 「插件与能力」页的**来源分组**逻辑(纯函数、零 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));
|
||||
}
|
||||
Reference in new issue
Block a user