主人 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` 通过。
352 lines
15 KiB
TypeScript
352 lines
15 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import { adminListPlugins, adminSetPluginPolicy, adminPluginUninstall, getStoredRole } from "../../lib/api";
|
||
import type { AdminPluginRecord } from "../../lib/types";
|
||
import { useAutoReload } from "../hooks";
|
||
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";
|
||
|
||
const KIND_LABELS: Record<CapKind, string> = {
|
||
tool: "工具 Tool",
|
||
skill: "技能 Skill",
|
||
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.
|
||
function channelColumns(): AdminColumn<AdminPluginRecord>[] {
|
||
return [
|
||
{ key: "name", title: "名称", render: (p) => <span className="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)]">渠道配置见「渠道管理」页</span> },
|
||
];
|
||
}
|
||
|
||
/** 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,
|
||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||
uninstall: (name: string) => void,
|
||
): AdminColumn<AdminPluginRecord>[] {
|
||
return [
|
||
{ 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> },
|
||
policyColumn(isOwner, busy, setPolicy),
|
||
{
|
||
key: "actions",
|
||
title: "操作",
|
||
render: (p) => (
|
||
<Button size="sm" variant="ghost" disabled={!isOwner || busy !== null} onClick={() => uninstall(p.name)}>
|
||
卸载
|
||
</Button>
|
||
),
|
||
},
|
||
];
|
||
}
|
||
|
||
/** 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);
|
||
const isOwner = getStoredRole() === "owner";
|
||
const log = useAuditStore((s) => s.log);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [fail, setFail] = useState<string | null>(null);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
|
||
const uninstall = (name: string) => {
|
||
setBusy(name);
|
||
setFail(null);
|
||
setMsg(null);
|
||
adminPluginUninstall(name)
|
||
.then(() => {
|
||
log("plugin.uninstall", "ok");
|
||
setMsg(`已卸载 ${name}`);
|
||
reload();
|
||
})
|
||
.catch((err: unknown) => {
|
||
log("plugin.uninstall", "error");
|
||
setFail(err instanceof Error ? err.message : "卸载失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => {
|
||
setBusy(name);
|
||
setFail(null);
|
||
adminSetPluginPolicy(name, allowInGroup, allowInStranger)
|
||
.then(() => {
|
||
log(`policy.set:${name}`, "ok");
|
||
reload();
|
||
})
|
||
.catch((err: unknown) => {
|
||
log(`policy.set:${name}`, "error");
|
||
setFail(err instanceof Error ? err.message : "策略更新失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const byKind = useMemo(() => {
|
||
const groups: Record<CapKind, AdminPluginRecord[]> = { tool: [], skill: [], channel: [] };
|
||
for (const cap of data ?? []) {
|
||
const k = cap.kind as CapKind;
|
||
if (groups[k]) groups[k].push(cap);
|
||
}
|
||
return groups;
|
||
}, [data]);
|
||
|
||
// 工具段的两级分节:先按上游 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 server 分节 / 技能 / 渠道)+ 运行时 MCP 安装 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||
/>
|
||
|
||
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
|
||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||
{msg ? <Notice tone="ok">{msg}</Notice> : null}
|
||
|
||
<InstalledPluginsPanel />
|
||
|
||
<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 · 工具按上游 server 分节</span>
|
||
</div>
|
||
|
||
<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={mcpToolCols}
|
||
rows={loading ? null : group.rows}
|
||
loading={loading}
|
||
error={error}
|
||
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">
|
||
<Tag tone="neutral">{KIND_LABELS.channel}</Tag>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.channel.length} 个</span>
|
||
</div>
|
||
<AdminTable
|
||
columns={channelCols}
|
||
rows={loading ? null : byKind.channel}
|
||
loading={loading}
|
||
error={error}
|
||
emptyTitle="无渠道"
|
||
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>
|
||
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
|
||
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
|
||
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
|
||
</Notice>
|
||
</div>
|
||
);
|
||
}
|