From 86aef7b4548c82818fd322358632af96c90107d9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 20:21:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(capability):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=8C=89=E4=B8=8A=E6=B8=B8=20MCP=20server=20=E5=88=86=E8=8A=82?= =?UTF-8?q?=EF=BC=88=E6=9D=A5=E6=BA=90=E7=AB=A0=20+=20=E5=91=88=E7=8E=B0?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 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:` 登记条目直接 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` 通过。 --- frontend/web/src/admin/modules/Plugins.tsx | 248 +++++++++++++++--- .../src/admin/modules/pluginOrigin.test.ts | 111 ++++++++ .../web/src/admin/modules/pluginOrigin.ts | 93 +++++++ frontend/web/src/lib/types/admin.ts | 13 + internal/capability/mcp/origin_test.go | 74 ++++++ internal/capability/mcp/tool.go | 13 + internal/capability/mcp/transport.go | 24 ++ internal/capability/registry/origin_test.go | 111 ++++++++ internal/capability/registry/registry.go | 28 ++ internal/runtime/tool/origin_test.go | 67 +++++ internal/services/gateway/admin.go | 26 +- internal/services/gateway/admin_plugins.go | 29 ++ internal/services/gateway/admin_query.go | 18 ++ .../services/gateway/plugin_origin_test.go | 234 +++++++++++++++++ pkg/plugins/sdk/origin.go | 53 ++++ 15 files changed, 1094 insertions(+), 48 deletions(-) create mode 100644 frontend/web/src/admin/modules/pluginOrigin.test.ts create mode 100644 frontend/web/src/admin/modules/pluginOrigin.ts create mode 100644 internal/capability/mcp/origin_test.go create mode 100644 internal/capability/registry/origin_test.go create mode 100644 internal/runtime/tool/origin_test.go create mode 100644 internal/services/gateway/plugin_origin_test.go create mode 100644 pkg/plugins/sdk/origin.go diff --git a/frontend/web/src/admin/modules/Plugins.tsx b/frontend/web/src/admin/modules/Plugins.tsx index fb05344..26ca59b 100644 --- a/frontend/web/src/admin/modules/Plugins.tsx +++ b/frontend/web/src/admin/modules/Plugins.tsx @@ -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 = { 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[] { ]; } -/** 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:` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在 + * MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */ +function policyColumn( + isOwner: boolean, + busy: string | null, + setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void, +): AdminColumn { + return { + key: "policy", + title: "环境授权(owner)", + render: (p) => ( + + + + {busy === p.name ? 保存中… : null} + + ), + }; +} + +/** 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) => {p.name} }, { key: "kind", title: "类型", render: (p) => {(KIND_LABELS as Record)[p.kind] ?? p.kind} }, { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, - { - key: "policy", - title: "环境授权(owner)", - render: (p) => ( - - - - {busy === p.name ? 保存中… : null} - - ), - }, + 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[] { + return [ + { key: "name", title: "名称", render: (p) => {p.name} }, + { + key: "origin", + title: "来源", + render: (p) => {p.origin?.server || "未知 server"}, + }, + { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, + policyColumn(isOwner, busy, setPolicy), + { + key: "actions", + title: "操作", + render: () => {MCP_PAGE_HINT}, + }, + ]; +} + +/** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */ +function mcpServerColumns(): AdminColumn[] { + return [ + { key: "name", title: "登记键", render: (p) => {p.name} }, + { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, + { + key: "admin", + title: "管理", + render: () => ( + + 启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略 + + ), + }, + ]; +} + +/** Columns for capability kinds the page does not otherwise model (e.g. iot). + * + * design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */ +function otherColumns(): AdminColumn[] { + return [ + { key: "name", title: "名称", render: (p) => {p.name} }, + { key: "kind", title: "类型", render: (p) => {p.kind} }, + { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, + ]; +} + 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 (
{!isOwner ? 当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。 : null} @@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {

已注册能力

- GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组 + GET /api/v1/admin/plugins · 工具按上游 server 分节
- {(["tool", "skill"] as CapKind[]).map((kind) => { - const rows = byKind[kind]; - return ( -
-
- {KIND_LABELS[kind]} - {rows.length} 个 +
+
+ {KIND_LABELS.tool} + + {byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置 + +
+ + {toolBuckets.mcp.map((group) => ( +
+
+ + {originGroupTitle(group)} + + server 级动作见「MCP 服务器」页
`${kind}:${p.name}`} + emptyTitle="无工具" + rowKey={(p) => `mcp:${group.key}:${p.name}`} />
- ); - })} + ))} + +
+
+ 宿主内置工具 + {toolBuckets.builtin.length} 把 +
+ `tool:${p.name}`} + /> +
+
+ +
+
+ {KIND_LABELS.skill} + {byKind.skill.length} 个 +
+ `skill:${p.name}`} + /> +
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode { rowKey={(p) => `channel:${p.name}`} />
+ + {/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台 + server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关 + (`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */} +
+
+ MCP server 登记 + {mcpServers.length} 台 +
+ `mcpentry:${p.name}`} + /> +
+ + {others.length > 0 ? ( +
+
+ 其它能力 + {others.length} 个 · 本页暂无专用视图,如实列出 +
+ `other:${p.name}`} + /> +
+ ) : null} - 工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。 + + 工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由 + tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 —— + 单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。 +
); } diff --git a/frontend/web/src/admin/modules/pluginOrigin.test.ts b/frontend/web/src/admin/modules/pluginOrigin.test.ts new file mode 100644 index 0000000..52a3c57 --- /dev/null +++ b/frontend/web/src/admin/modules/pluginOrigin.test.ts @@ -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`); diff --git a/frontend/web/src/admin/modules/pluginOrigin.ts b/frontend/web/src/admin/modules/pluginOrigin.ts new file mode 100644 index 0000000..609f8e1 --- /dev/null +++ b/frontend/web/src/admin/modules/pluginOrigin.ts @@ -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(); + 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)); +} diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts index 95417e3..463ddd2 100644 --- a/frontend/web/src/lib/types/admin.ts +++ b/frontend/web/src/lib/types/admin.ts @@ -8,6 +8,17 @@ export interface MemoryRecord { text: string; } +/** 一条能力的上游来源(GET /api/v1/admin/plugins 的可选 `origin` 键,契约 v0.83)。 + * + * 它回答「这条能力来自哪台上游 server」,来自**注册时的元数据**——绝不是从 + * `name` 里解析前缀得来的(MCP 工具名里没有任何前缀)。宿主内置能力的该键 + * **整个缺席**(不是 `{}`),前端据此把它们归「宿主内置」。 */ +export interface CapabilityOrigin { + kind: string; + server?: string; + transport?: string; +} + /** A capability/plugin as returned by GET /api/v1/admin/plugins. */ export interface AdminPluginRecord { name: string; @@ -15,6 +26,8 @@ export interface AdminPluginRecord { description: string; allowInGroup: boolean; allowInStranger: boolean; + /** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */ + origin?: CapabilityOrigin; } /** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28). diff --git a/internal/capability/mcp/origin_test.go b/internal/capability/mcp/origin_test.go new file mode 100644 index 0000000..6d722be --- /dev/null +++ b/internal/capability/mcp/origin_test.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件钉住 MCP 工具的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md): +// 每把注册进注册表的工具都要带「哪台 server + 什么传输」,供面板按 server 分节。 + +// TestRegistryRegisterStampsToolOrigin 覆盖**两条接入路径**的共同汇点: +// - 运行时热装:plugin.Manager.start → mcp.NewRegistry → **Register**; +// - `.env` 的 MCP_SERVERS_JSON 启动期装:cmd/cyrene.registerMCPServer → +// mcp.NewRegistry → **同一个 Register**(不经插件管理器)。 +// +// 两条路都在这里进注册表,因此这一处的断言就同时钉住了它们;也从反面证明 +// 「从插件管理器现场反推归属」会漏掉 env 路径。 +func TestRegistryRegisterStampsToolOrigin(t *testing.T) { + reg := registry.New() + cfg := MCPConfig{Name: "calc", TransportType: "stdio"} + r := NewRegistry(reg, cfg, WithTransport(startFakeServer(t, newFakeServer()))) + if err := r.Register(context.Background()); err != nil { + t.Fatalf("Register: %v", err) + } + t.Cleanup(func() { _ = r.Unregister(context.Background()) }) + + want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "calc", Transport: "stdio"} + tools := reg.List(registry.KindTool) + if len(tools) != 2 { + t.Fatalf("注册进注册表的工具数 = %d, want 2(echo/add)", len(tools)) + } + for _, cap := range tools { + if cap.Origin != want { + t.Fatalf("工具 %q 的来源章 = %+v, want %+v", cap.Name, cap.Origin, want) + } + } + // 来源是 server 名,不是注册键 —— 与 `mcp:calc` 那条 KindMCP 条目无关。 + if err := r.Unregister(context.Background()); err != nil { + t.Fatalf("Unregister: %v", err) + } + if left := reg.List(registry.KindTool); len(left) != 0 { + t.Fatalf("停用 server 后还剩 %d 把工具:条目必须连同来源章一起消失,不留孤儿", len(left)) + } +} + +// TestTransportNameOf 钉住来源章里 transport 的取值:它必须报**实际会用的**传输, +// 而不是原样下发可能为空的 TransportType(配置常见留空,由推断决定)。 +// 判据与 newTransport 的推断同源,因此面板显示的传输永远是真在用的那个。 +func TestTransportNameOf(t *testing.T) { + cases := []struct { + name string + cfg MCPConfig + want string + }{ + {"显式 stdio", MCPConfig{TransportType: "stdio"}, "stdio"}, + {"显式 sse", MCPConfig{TransportType: "sse"}, "sse"}, + {"显式 http", MCPConfig{TransportType: "http"}, "http"}, + {"streamable 归一为 http", MCPConfig{TransportType: "streamable"}, "http"}, + {"留空 + 命令 ⇒ stdio", MCPConfig{Command: "npx"}, "stdio"}, + {"留空 + 地址 ⇒ sse", MCPConfig{URL: "https://example.test/sse"}, "sse"}, + {"命令优先于地址(与 newTransport 同序)", MCPConfig{Command: "npx", URL: "https://example.test"}, "stdio"}, + {"都没有 ⇒ 空串(不编一个)", MCPConfig{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := transportNameOf(tc.cfg); got != tc.want { + t.Fatalf("transportNameOf(%+v) = %q, want %q", tc.cfg, got, tc.want) + } + }) + } +} diff --git a/internal/capability/mcp/tool.go b/internal/capability/mcp/tool.go index 272cbc5..be897b0 100644 --- a/internal/capability/mcp/tool.go +++ b/internal/capability/mcp/tool.go @@ -7,6 +7,8 @@ import ( "strings" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // ToolInfo 是 MCP 服务器暴露的一把工具。 @@ -60,6 +62,17 @@ func (t *mcpTool) Name() string { return t.info.Name } // Description 返回 MCP 工具描述。 func (t *mcpTool) Description() string { return t.info.Description } +// Origin 回报这把工具的上游来源(DevDocs/audit/MCP工具按上游分组-2026-10-24.md): +// server 名与传输形态都在手上的 `client.cfg` 里,因此归属是**注册时的事实**, +// 不靠工具名推断。宿主在 registry.RegisterTool 处读到它并盖在能力条目上。 +func (t *mcpTool) Origin() sdk.Origin { + return sdk.Origin{ + Kind: sdk.OriginKindMCP, + Server: t.client.cfg.Name, + Transport: transportNameOf(t.client.cfg), + } +} + // Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。 func (t *mcpTool) Parameters() (map[string]any, error) { if t.info.InputSchema == nil { diff --git a/internal/capability/mcp/transport.go b/internal/capability/mcp/transport.go index 19e14b1..bbd53d0 100644 --- a/internal/capability/mcp/transport.go +++ b/internal/capability/mcp/transport.go @@ -33,6 +33,30 @@ func newTransport(cfg MCPConfig) (mcpsdk.Transport, error) { } } +// transportNameOf 返回 cfg **实际会用到**的传输名(stdio / sse / http),与 +// newTransport 的推断同源:显式 TransportType 优先(http / streamable 归一为 http), +// 为空则「有 Command 走 stdio,有 URL 走 sse」。两者都没有时返回空串 —— 配置不成立, +// 上游来源里该键缺席,而不是编一个 stdio。 +// +// 它服务于能力条目的来源章(mcpTool.Origin):面板要能写「来自 fs(stdio)」, +// 而 cfg.TransportType 常常留空(由推断决定),因此不能直接下发原始配置值。 +func transportNameOf(cfg MCPConfig) string { + switch cfg.TransportType { + case "http", "streamable": + return "http" + case "sse", "stdio": + return cfg.TransportType + } + switch { + case cfg.Command != "": + return "stdio" + case cfg.URL != "": + return "sse" + default: + return "" + } +} + // httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25 // 规范首选),否则走旧式 SSE。 func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) { diff --git a/internal/capability/registry/origin_test.go b/internal/capability/registry/origin_test.go new file mode 100644 index 0000000..e0d0af9 --- /dev/null +++ b/internal/capability/registry/origin_test.go @@ -0,0 +1,111 @@ +package registry + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件钉住「来源章」(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①): +// 归属只能来自注册时的元数据,因此盖章必须发生在**注册汇点**上,且热替换不能把它抹掉。 +// +// 三条判据各自**能变红**: +// - 去掉 RegisterTool 里的盖章 → 第一条红; +// - 去掉 ReplaceTool 里的盖章(它逐字段重建条目)→ 第二条红; +// - 给所有工具都编一个章(比如按名字猜)→ 第三条红。 + +// originTool 是自报来源的最小工具(宿主内置工具不实现 sdk.OriginAware)。 +type originTool struct { + name string + origin sdk.Origin +} + +func (t originTool) Name() string { return t.name } +func (t originTool) Description() string { return "来自 MCP" } +func (t originTool) Parameters() (map[string]any, error) { + return map[string]any{"type": "object"}, nil +} +func (t originTool) Execute(_ context.Context, _ any) (any, error) { + return nil, nil +} +func (t originTool) Origin() sdk.Origin { return t.origin } + +var _ sdk.OriginAware = originTool{} + +// TestRegisterToolStampsOrigin:注册汇点把工具自报的来源盖在能力条目上。 +func TestRegisterToolStampsOrigin(t *testing.T) { + r := New() + want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"} + if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: want}); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + cap, err := r.Get("read_file_mcp") + if err != nil { + t.Fatalf("Get: %v", err) + } + if cap.Origin != want { + t.Fatalf("条目上的来源章 = %+v, want %+v(RegisterTool 是唯一汇点,必须在这里盖章)", cap.Origin, want) + } +} + +// TestReplaceToolKeepsOrigin:热替换必须重新盖章 —— 它逐字段重建 Capability, +// 漏一位就等于「面板保存一次,这把工具从它的 server 分节里掉进内置」。 +func TestReplaceToolKeepsOrigin(t *testing.T) { + r := New() + origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "http"} + if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + // 热替换成同名的新实现,来源相同(换实现不换 server)。 + if err := r.ReplaceTool(originTool{name: "read_file_mcp", origin: origin}); err != nil { + t.Fatalf("ReplaceTool: %v", err) + } + cap, err := r.Get("read_file_mcp") + if err != nil { + t.Fatalf("Get: %v", err) + } + if cap.Origin != origin { + t.Fatalf("热替换后来源章 = %+v, want %+v(ReplaceTool 重建条目,必须重新盖章)", cap.Origin, origin) + } +} + +// TestRegisterToolLeavesBuiltinOriginZero:宿主内置工具(没实现 OriginAware) +// 的条目**没有**来源章。它是「键缺席」的根据 —— 读面据此省略 origin,而不是编一个。 +func TestRegisterToolLeavesBuiltinOriginZero(t *testing.T) { + r := New() + if err := r.RegisterTool(&stubTool{name: "web_search", desc: "内置"}); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + cap, err := r.Get("web_search") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !cap.Origin.IsZero() { + t.Fatalf("内置工具却带来源章 %+v:没有 OriginAware 就不得编一个来源", cap.Origin) + } +} + +// TestSetPolicyKeepsOrigin:策略写路径不受来源章影响,也不许把它覆盖掉。 +// 这是「tool_policies / 权限闸零影响」的断言 —— 策略仍按**名字**存, +// 来源章只在条目上,两者互不干涉。 +func TestSetPolicyKeepsOrigin(t *testing.T) { + r := New() + origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"} + if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + if err := r.SetAllowInGroup("read_file_mcp", true); err != nil { + t.Fatalf("SetAllowInGroup: %v", err) + } + cap, err := r.Get("read_file_mcp") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !cap.Policy.AllowInGroup || cap.Policy.AllowInStranger { + t.Fatalf("策略 = %+v, want 只有 AllowInGroup=true(按名字写的策略行)", cap.Policy) + } + if cap.Origin != origin { + t.Fatalf("改策略把来源章冲掉了:%+v, want %+v", cap.Origin, origin) + } +} diff --git a/internal/capability/registry/registry.go b/internal/capability/registry/registry.go index 043b716..ad9ff1f 100644 --- a/internal/capability/registry/registry.go +++ b/internal/capability/registry/registry.go @@ -58,6 +58,12 @@ type Capability struct { // Policy is the editable reachability markers for this capability. It // defaults to zero, which the policy engine reads as owner-only. Policy ToolPolicy + // Origin 是能力的**上游来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md + // 方案①):由工具自己通过 sdk.OriginAware 声明,注册汇点 + // RegisterTool/ReplaceTool 盖在这里。零值 = 宿主内置 / 来源未知 —— + // 这枚章**只用于呈现归属**,不参与权限判定、不进模型可见的工具面 + // (restrict 只按名字查策略,ToolSchemas 只取 Name/Description/Parameters)。 + Origin sdk.Origin // Value is the underlying capability object. For KindTool/KindSkill/ // KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and // KindIoT it is owned by the relevant subsystem. @@ -225,6 +231,13 @@ func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) e } // RegisterTool registers t as a KindTool capability. +// +// 它是**来源章的唯一汇点**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①): +// MCP 工具的**两条接入路径**都从这里进注册表 —— 运行时热装 +// (plugin.Manager.start → mcp.NewRegistry → mcp.Registry.Register)与 `.env` 的 +// MCP_SERVERS_JSON 启动期装(cmd/cyrene/mcp.go,不经 Manager)—— 因此在这一处盖章 +// 一次就覆盖两条路(以及 tools/list_changed 的增量 refresh)。 +// 不从 Manager 现场反推归属:那会漏掉 env 路径,把现役接入方式误报成「无来源」。 func (r *Registry) RegisterTool(t sdk.Tool) error { if t == nil { return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument) @@ -233,10 +246,21 @@ func (r *Registry) RegisterTool(t sdk.Tool) error { Name: t.Name(), Kind: KindTool, Description: t.Description(), + Origin: originOf(t), Value: t, }) } +// originOf 返回 t 自报的来源章;没实现 sdk.OriginAware(宿主内置工具)或自报零值时 +// 返回零值 Origin —— 零值在线上表现为**键缺席**,不是一个空对象。 +func originOf(t sdk.Tool) sdk.Origin { + origin, ok := sdk.OriginOf(t) + if !ok { + return sdk.Origin{} + } + return origin +} + // ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5: // 面板保存后下一轮即用新接线,不必重启)。 // @@ -270,6 +294,10 @@ func (r *Registry) ReplaceTool(t sdk.Tool) error { Description: t.Description(), // Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。 Policy: cur.Policy, + // Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源 + // 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的 + // 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。 + Origin: originOf(t), Value: t, } return nil diff --git a/internal/runtime/tool/origin_test.go b/internal/runtime/tool/origin_test.go new file mode 100644 index 0000000..a9bf1f7 --- /dev/null +++ b/internal/runtime/tool/origin_test.go @@ -0,0 +1,67 @@ +package tool + +import ( + "encoding/json" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件钉住「模型可见的工具面**零影响**」(DevDocs/audit/MCP工具按上游分组-2026-10-24.md +// §2 方案①的影响表):来源章只是管理面的呈现归属,绝不进发给模型的函数签名。 +// +// 判据是**泄漏**:工具面上既不能出现 origin 这个键,也不能出现 server 名 —— +// 一旦有人把归属顺手塞进 ToolSpec("让模型知道工具从哪来"),这条就红。 + +// originMockTool 是带来源章的工具(包一层既有 mockTool,只为提供 Origin)。 +type originMockTool struct { + mockTool + origin sdk.Origin +} + +func (m originMockTool) Origin() sdk.Origin { return m.origin } + +var _ sdk.OriginAware = originMockTool{} + +// TestToolSchemasDoNotLeakOrigin:注册一把来自 "fs-origin-probe" 的 MCP 工具, +// 发给模型的 schema 里既没有来源键、也没有 server 名。 +func TestToolSchemasDoNotLeakOrigin(t *testing.T) { + d, reg := newDispatcher(t) + probe := originMockTool{ + mockTool: mockTool{ + name: "read_file", + desc: "读文件", + params: map[string]any{"type": "object"}, + }, + origin: sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs-origin-probe", Transport: "stdio"}, + } + if err := reg.RegisterTool(probe); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + + specs := d.ToolSchemas() + if len(specs) != 1 || specs[0].Name != "read_file" { + t.Fatalf("ToolSchemas = %+v, want 仅 read_file", specs) + } + raw, err := json.Marshal(specs) + if err != nil { + t.Fatalf("marshal schemas: %v", err) + } + wire := string(raw) + if strings.Contains(wire, "origin") { + t.Fatalf("模型可见的工具面带上了来源键:%s(来源只用于管理面呈现)", wire) + } + if strings.Contains(wire, "fs-origin-probe") { + t.Fatalf("模型可见的工具面泄漏了 server 名:%s", wire) + } + + // 来源章仍在管理面上(注册表条目),两者不是同一份事实。 + cap, gerr := reg.Get("read_file") + if gerr != nil { + t.Fatalf("Get: %v", gerr) + } + if cap.Origin.Server != "fs-origin-probe" { + t.Fatalf("管理面上的来源章丢了:%+v", cap.Origin) + } +} diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index a13fbe8..a6a3ef4 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -7,12 +7,28 @@ import "context" const backthinkListLimit = 50 // PluginSummary 是管理列表中一个能力/插件的线上结构。 +// +// Origin(v0.83 契约增量)回答「这条能力来自哪台上游 server」,供面板把 MCP 工具的 +// 呈现按 server 分节(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①)。 +// 它是**可选键**:宿主内置能力根本不实现来源,因此该键整体**缺席**(不是 `{}` 也不是 +// `null`)。归属来自注册时的元数据,绝不解析 `name` 推断 —— MCP 工具名里没有任何前缀。 +// 老客户端忽略未知键即零改动;严格解析器需要容忍/声明该可选字段。 type PluginSummary struct { - Name string `json:"name"` - Kind string `json:"kind"` - Description string `json:"description"` - AllowInGroup bool `json:"allowInGroup"` - AllowInStranger bool `json:"allowInStranger"` + Name string `json:"name"` + Kind string `json:"kind"` + Description string `json:"description"` + AllowInGroup bool `json:"allowInGroup"` + AllowInStranger bool `json:"allowInStranger"` + Origin *PluginOrigin `json:"origin,omitempty"` +} + +// PluginOrigin 是一条能力的上游来源。Server 是 server 名(= plugins.json / +// MCP_SERVERS_JSON 的 name),Transport 是 stdio / sse / http;两者按 src 的实际 +// 已知程度省略,Kind 恒有值(目前只有 "mcp")。 +type PluginOrigin struct { + Kind string `json:"kind"` + Server string `json:"server,omitempty"` + Transport string `json:"transport,omitempty"` } // ChannelSummary 是一个已注册渠道适配器的线上结构。Accounts diff --git a/internal/services/gateway/admin_plugins.go b/internal/services/gateway/admin_plugins.go index e182423..330d970 100644 --- a/internal/services/gateway/admin_plugins.go +++ b/internal/services/gateway/admin_plugins.go @@ -26,6 +26,7 @@ import ( capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手 @@ -208,6 +209,18 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured")) return } + // 🔴 单把 MCP 工具与 `mcp:` 登记条目**不能**走这条裸删路径 + // (DevDocs/audit/MCP工具按上游分组-2026-10-24.md §1.5-2):管理器 + // Uninstall 只按 server 名/ID 匹配,落到这里就等于绕过插件管理器直接改注册表 —— + // 该 server 的 names 账本不同步(下次 refresh 会把工具当「新出现的」再注册回来, + // 或让 KindMCP 条目与工具各自消失一半)。判据是**注册时的来源章**,不是名字前缀。 + if cap, err := reg.Get(name); err == nil { + if reason := serverScopedUninstall(name, cap); reason != "" { + s.auditClaims(r, "plugin.uninstall", "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "plugin_uninstall_scope", "%s", reason)) + return + } + } if err := reg.Remove(name); err != nil { s.auditClaims(r, "plugin.uninstall", "error") if errors.Is(err, capreg.ErrNotFound) { @@ -221,6 +234,22 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true}) } +// serverScopedUninstall 报告「这个名字必须在『MCP 服务器』页按整台 server 处理, +// 不能在这里单删」的原因;返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。 +// +// 它拦住两类真实事故(同一条根因:绕开插件管理器裸删): +// - 一把 MCP 工具:server 的 names 账本不同步,重连/refresh 后工具复活,用户以为卸了; +// - `mcp:` 登记条目:server 条目消失了而它的工具还在,注册表里留下无法归属的孤儿。 +func serverScopedUninstall(name string, cap capreg.Capability) string { + if cap.Kind == capreg.KindMCP { + return fmt.Sprintf("%q 是一台 MCP server 的登记条目;请在「MCP 服务器」页卸载该 server", name) + } + if cap.Origin.Kind == sdk.OriginKindMCP { + return fmt.Sprintf("%q 是 MCP server %q 的工具,单把工具不能单独卸载;请在「MCP 服务器」页启停或卸载该 server", name, cap.Origin.Server) + } + return "" +} + // adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置 // 与每 server 工具数。它是读端点(owner/admin), // 绝不原样返回 API key 或敏感 env 值。 diff --git a/internal/services/gateway/admin_query.go b/internal/services/gateway/admin_query.go index fd71c78..563007c 100644 --- a/internal/services/gateway/admin_query.go +++ b/internal/services/gateway/admin_query.go @@ -39,6 +39,10 @@ func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) { } // adminListPlugins 列出所有已注册能力(管理读)。 +// +// 每条能力带上它的上游来源(若有):来源章的读取面在这里一次性给出, +// 前端「插件与能力」页据此把工具按 server 分节。**策略字段与集合完全不变** —— +// 只是多了一个可选键,权限判定与模型可见的工具面都不读它。 func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) { out := make([]PluginSummary, 0) if s.caps != nil { @@ -49,12 +53,26 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) { Description: cap.Description, AllowInGroup: cap.Policy.AllowInGroup, AllowInStranger: cap.Policy.AllowInStranger, + Origin: pluginOriginOf(cap), }) } } writeJSON(w, http.StatusOK, out) } +// pluginOriginOf 把能力条目上的来源章映射为线上结构;零值(宿主内置 / 来源未知) +// 返回 nil,使 JSON 里**没有** origin 键 —— 缺席是「宿主内置」的表达。 +func pluginOriginOf(cap capreg.Capability) *PluginOrigin { + if cap.Origin.IsZero() { + return nil + } + return &PluginOrigin{ + Kind: cap.Origin.Kind, + Server: cap.Origin.Server, + Transport: cap.Origin.Transport, + } +} + // adminListChannels 列出已注册的渠道适配器(管理读)。当接线了 // 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏, // 确保原始 token 绝不抵达客户端。 diff --git a/internal/services/gateway/plugin_origin_test.go b/internal/services/gateway/plugin_origin_test.go new file mode 100644 index 0000000..6a9e4bb --- /dev/null +++ b/internal/services/gateway/plugin_origin_test.go @@ -0,0 +1,234 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件钉住「插件与能力」读面的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md +// §2 方案① / §6 契约增量),以及与之配套的服务端拒绝: +// +// - 来自 MCP server 的工具带 `origin:{kind,server,transport}`; +// - 宿主内置工具的该键**缺席**(不是 `{}`、不是 `null`)—— 缺席才表示「没有上游」; +// - 只改呈现:`allowInGroup`/`allowInStranger` 逐字不变,`kind` 仍是 `tool`; +// - 对一把 MCP 工具调 `POST /admin/plugins/uninstall` 必须 400(绕开管理器裸删会让 +// server 的 names 账本不同步),而普通能力的卸载行为不变。 + +// originStubTool 是自报来源的最小工具。 +type originStubTool struct { + name string + origin sdk.Origin +} + +func (t originStubTool) Name() string { return t.name } +func (t originStubTool) Description() string { return "MCP 工具" } +func (t originStubTool) Parameters() (map[string]any, error) { + return map[string]any{"type": "object"}, nil +} +func (t originStubTool) Execute(_ context.Context, _ any) (any, error) { + return nil, nil +} +func (t originStubTool) Origin() sdk.Origin { return t.origin } + +// builtinStubTool 是宿主内置工具(不实现 sdk.OriginAware)。 +type builtinStubTool struct{ name string } + +func (t builtinStubTool) Name() string { return t.name } +func (t builtinStubTool) Description() string { return "内置工具" } +func (t builtinStubTool) Parameters() (map[string]any, error) { + return map[string]any{"type": "object"}, nil +} +func (t builtinStubTool) Execute(_ context.Context, _ any) (any, error) { + return nil, nil +} + +// listPlugins 取管理读面的能力清单(保留原始 JSON 以便断言键的**缺席**)。 +func listPlugins(t *testing.T, url, token string) []byte { + t.Helper() + resp, raw := doReq(t, http.MethodGet, url+"/api/v1/admin/plugins", nil, token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("plugins list status = %d, body = %s", resp.StatusCode, raw) + } + return raw +} + +// TestAdminListPluginsExposesOrigin 是读面的主判据:MCP 工具带章、内置工具缺席。 +func TestAdminListPluginsExposesOrigin(t *testing.T) { + t.Parallel() + env := newPluginTestEnv(t, nil) + mcpOrigin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"} + if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: mcpOrigin}); err != nil { + t.Fatalf("register mcp tool: %v", err) + } + if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil { + t.Fatalf("register builtin tool: %v", err) + } + + raw := listPlugins(t, env.ts.URL, env.token) + var list struct { + Data []PluginSummary `json:"data"` + } + if err := json.Unmarshal(raw, &list); err != nil { + t.Fatalf("decode list: %v (%s)", err, raw) + } + byName := make(map[string]PluginSummary, len(list.Data)) + for _, it := range list.Data { + byName[it.Name] = it + } + mcpCap, ok := byName["read_file_mcp"] + if !ok { + t.Fatalf("MCP 工具不在清单里:%s", raw) + } + if mcpCap.Origin == nil { + t.Fatalf("MCP 工具没有 origin:%+v", mcpCap) + } + if *mcpCap.Origin != (PluginOrigin{Kind: "mcp", Server: "fs", Transport: "stdio"}) { + t.Fatalf("origin = %+v, want {mcp fs stdio}", *mcpCap.Origin) + } + // 呈现层新增不改既有语义:kind 与两个策略布尔逐字不变。 + if mcpCap.Kind != "tool" || mcpCap.AllowInGroup || mcpCap.AllowInStranger { + t.Fatalf("既有字段被改了:%+v", mcpCap) + } + builtin, ok := byName["web_search"] + if !ok { + t.Fatalf("内置工具不在清单里:%s", raw) + } + if builtin.Origin != nil { + t.Fatalf("内置工具带上了 origin:%+v", builtin.Origin) + } + // 🔴 线上必须是「键缺席」而不是空对象:`"origin":{}` / `"origin":null` 都会被 + // 前端/严格解析器读成「有来源但内容为空」。 + entry := rawEntryOf(t, raw, "web_search") + if strings.Contains(entry, "origin") { + t.Fatalf("内置工具的条目里出现了 origin 键:%s", entry) + } +} + +// rawEntryOf 从原始响应里截出某个名为 name 的条目的 JSON 片段,用于断言键的缺席 +// (反序列化后的结构体无法区分「缺键」与「零值」)。 +func rawEntryOf(t *testing.T, raw []byte, name string) string { + t.Helper() + var items []json.RawMessage + var envelope struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err == nil { + items = envelope.Data + } else if err := json.Unmarshal(raw, &items); err != nil { + t.Fatalf("decode raw list: %v (%s)", err, raw) + } + for _, item := range items { + var probe struct { + Name string `json:"name"` + } + if err := json.Unmarshal(item, &probe); err != nil { + t.Fatalf("decode item: %v (%s)", err, item) + } + if probe.Name == name { + return string(item) + } + } + t.Fatalf("条目 %q 不在清单里:%s", name, raw) + return "" +} + +// TestAdminPluginUninstallRejectsMCPTool 是那条 high finding 的服务端一半: +// 单把 MCP 工具不能绕过插件管理器裸删,必须指回「MCP 服务器」页。 +func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) { + t.Parallel() + env := newPluginTestEnv(t, nil) + origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"} + if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: origin}); err != nil { + t.Fatalf("register mcp tool: %v", err) + } + + resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", + map[string]any{"name": "read_file_mcp"}, env.token) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400(单把 MCP 工具不能在这里卸载),body = %s", resp.StatusCode, raw) + } + if !strings.Contains(string(raw), "plugin_uninstall_scope") { + t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw) + } + if !strings.Contains(string(raw), "MCP 服务器") { + t.Fatalf("错误消息没说该去哪里卸:%s", raw) + } + if _, err := env.caps.Get("read_file_mcp"); err != nil { + t.Fatalf("被拒的卸载却把工具删了:%v", err) + } +} + +// TestAdminPluginUninstallStillRemovesPlainCapability:普通能力(技能/内置工具) +// 的卸载路径逐字不变 —— 拒绝只针对「有 MCP 来源」的条目。 +func TestAdminPluginUninstallStillRemovesPlainCapability(t *testing.T) { + t.Parallel() + env := newPluginTestEnv(t, nil) + if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil { + t.Fatalf("register builtin tool: %v", err) + } + resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", + map[string]any{"name": "web_search"}, env.token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", resp.StatusCode, raw) + } + if _, err := env.caps.Get("web_search"); err == nil { + t.Fatal("普通能力没有被卸载") + } +} + +// TestInstalledMCPServerToolsCarryOrigin 走**热装整链**(install 端点 → 插件管理器 → +// mcp.Registry → RegisterTool):装完那台 server 的工具在管理读面上带 origin, +// 而按 server 名卸载后条目**连 origin 一起消失**,不留孤儿。 +// +// 它与 `internal/capability/mcp` 的盖章用例互补:那条钉的是两条接入路径的合流处 +// (RegisterTool),这条钉的是运行时这条路径真的走到了那里。 +func TestInstalledMCPServerToolsCarryOrigin(t *testing.T) { + t.Parallel() + env := newPluginTestEnv(t, newFakeMCPHandler("", "add", "mul")) + resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{ + "kind": "mcp", + "name": "calc", + "config": map[string]any{"transport": "http", "endpoint": env.mcpURL}, + }, env.token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw) + } + + var list struct { + Data []PluginSummary `json:"data"` + } + if err := json.Unmarshal(listPlugins(t, env.ts.URL, env.token), &list); err != nil { + t.Fatalf("decode list: %v", err) + } + want := PluginOrigin{Kind: "mcp", Server: "calc", Transport: "http"} + tools := 0 + for _, it := range list.Data { + if it.Kind != "tool" { + continue + } + tools++ + if it.Origin == nil || *it.Origin != want { + t.Fatalf("工具 %q 的 origin = %+v, want %+v", it.Name, it.Origin, want) + } + } + if tools != 2 { + t.Fatalf("工具数 = %d, want 2(add/mul)", tools) + } + + // 按 server 名卸载(这是**合法**路径,管理器会连工具带账本一起收), + // 卸载后工具条目连 origin 一起消失 —— 「停用 server ⇒ 工具消失」语义不变。 + resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", + map[string]any{"name": "calc"}, env.token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw) + } + if left := env.caps.List(capreg.KindTool); len(left) != 0 { + t.Fatalf("卸载 server 后还剩 %d 把工具(不应留下带 origin 的孤儿)", len(left)) + } +} diff --git a/pkg/plugins/sdk/origin.go b/pkg/plugins/sdk/origin.go new file mode 100644 index 0000000..9689f12 --- /dev/null +++ b/pkg/plugins/sdk/origin.go @@ -0,0 +1,53 @@ +package sdk + +// Origin 描述一个能力的**上游来源**:它是谁提供的,而不是它叫什么。 +// +// 它存在的理由是「归属不能从名字里解析」:MCP server 的工具名由 server 自己给, +// 宿主不在上面加任何前缀(同名的两把工具在注册表里至多一个 owner,见 +// internal/capability/mcp/registry.go),因此「这把工具来自哪台 server」只能由 +// **注册那一刻的元数据**回答,不能靠字符串推断(改名、重名、前缀碰撞都会错)。 +// +// 宿主内置能力(plugins/tools/**、web_search、read_file ……)**不实现** OriginAware, +// 它们的来源是「宿主自己」,不需要也不应该编一个章出来 —— 管理层面上「没有 origin +// 键」正是「宿主内置」的表达。 +type Origin struct { + // Kind 是来源种类。目前只有 OriginKindMCP;空串 = 无来源。 + Kind string `json:"kind"` + // Server 是提供该能力的上游 server 名(= plugins.json / MCP_SERVERS_JSON 里的 + // name),不是工具名。 + Server string `json:"server,omitempty"` + // Transport 是该 server 的传输形态(stdio / sse / http),来自配置而不是 + // 工具的自我描述。 + Transport string `json:"transport,omitempty"` +} + +// OriginKindMCP 表示该能力由一台外部 MCP server 提供。 +const OriginKindMCP = "mcp" + +// OriginAware 由**自带来源**的能力实现:宿主在注册时据此给能力条目盖章。 +// +// 它是可选接口 —— 没实现的一律按「宿主内置 / 来源未知」处理,因此既有的工具 +// 实现零改动。实现方只需回报自己的来源,不参与任何路由或权限判定。 +type OriginAware interface { + // Origin 返回该能力的上游来源;返回零值 Origin 表示「无可归属的来源」。 + Origin() Origin +} + +// IsZero 报告 o 是否是「没有来源」的零值。 +func (o Origin) IsZero() bool { + return o.Kind == "" && o.Server == "" && o.Transport == "" +} + +// OriginOf 返回 v 自报的来源。v 没实现 OriginAware,或自报的是零值(= 无来源)时, +// ok 为 false —— 调用方据此**省略**来源字段,而不是下发一个空对象。 +func OriginOf(v any) (Origin, bool) { + oa, ok := v.(OriginAware) + if !ok { + return Origin{}, false + } + o := oa.Origin() + if o.IsZero() { + return Origin{}, false + } + return o, true +} From c608dd4afba76aa702a32aaff313fd08c83e9696 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 20:32:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(gateway):=20=E5=8D=B8=E8=BD=BD=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E8=AF=9D=E6=9C=AF=E6=8C=89=E6=8E=A5=E5=85=A5=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=88=86=E6=B5=81=EF=BC=88.env=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E7=9A=84=20server=20=E4=B8=8D=E5=86=8D=E6=8C=87=E5=90=91=20MCP?= =?UTF-8?q?=20=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 M30-p2-1(r1):`plugin_uninstall_scope` 的 400 一律让主人「去『MCP 服务器』页 卸载」,但 `.env` 的 MCP_SERVERS_JSON 启动期接入的 server **不经**插件管理器、 不在 plugins.json 里,MCP 页(= Manager.Statuses()/List())根本看不到它 ⇒ 那条指引 对该路径是假指引。 - 新增 `plugin.(Manager) MCPServerName`:`mcp:` 注册键 → server 名的**唯一** 解析口(前缀只在 manager.go 出现一次),网关不再自己拼前缀。 - `serverScopedUninstall` 改为 `*Server` 方法,文案经 `uninstallPointer` 分流: · 管理器托管(落在 plugins.json,判据 `Manager.List()`)⇒ 保留「请在『MCP 服务器』 页启停或卸载该 server」; · 否则 ⇒ 明说该 server 由 .env 的 MCP_SERVERS_JSON 在启动时接入、面板不管理它, 并给出可操作步骤(改 .env 里那一项:删掉或设 enabled=false,改完需重启生效)。 接入路径判据来自**管理器持久清单**,不是名字前缀/transport 推断。 - `mcp:` 登记条目同样分流(此前也一律指向 MCP 页)。 测试(gateway):plugins.json 路径断言含「MCP 服务器」页字样且不提 MCP_SERVERS_JSON; .env 路径(工具与登记条目各一条)断言含 MCP_SERVERS_JSON 且**不含**「MCP 服务器」 字样;三条均断言 400 + `plugin_uninstall_scope` 不变、被拒后条目仍在。 修前红:两条 .env 用例(见回报);修后绿。回归 go build/vet/test 全绿。 --- internal/capability/plugin/manager.go | 13 +++ internal/services/gateway/admin_plugins.go | 49 +++++++- .../services/gateway/plugin_origin_test.go | 105 +++++++++++++++++- 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/internal/capability/plugin/manager.go b/internal/capability/plugin/manager.go index 72130a7..7248fa8 100644 --- a/internal/capability/plugin/manager.go +++ b/internal/capability/plugin/manager.go @@ -33,6 +33,19 @@ const ( mcpCapabilityPrefix = "mcp:" ) +// MCPServerName 从 `mcp:` 登记条目的注册键里取出 server 名。 +// 第二个返回值为 false 表示这不是一条 KindMCP 登记条目的键(或者 server 名为空)。 +// +// 它是**注册键格式**的唯一真相(`mcpCapabilityPrefix` 只在这里出现),因此网关在需要 +// 「这条登记条目是哪台 server」时应当调它,而不是自己在别处再拼一遍前缀。 +func MCPServerName(capName string) (string, bool) { + server, ok := strings.CutPrefix(capName, mcpCapabilityPrefix) + if !ok || server == "" { + return "", false + } + return server, true +} + // Installed is one plugin as reported to the admin API: its persisted config, // whether a live server currently backs it, and how many tools it registered. type Installed struct { diff --git a/internal/services/gateway/admin_plugins.go b/internal/services/gateway/admin_plugins.go index 330d970..2365660 100644 --- a/internal/services/gateway/admin_plugins.go +++ b/internal/services/gateway/admin_plugins.go @@ -215,7 +215,7 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { // 该 server 的 names 账本不同步(下次 refresh 会把工具当「新出现的」再注册回来, // 或让 KindMCP 条目与工具各自消失一半)。判据是**注册时的来源章**,不是名字前缀。 if cap, err := reg.Get(name); err == nil { - if reason := serverScopedUninstall(name, cap); reason != "" { + if reason := s.serverScopedUninstall(name, cap); reason != "" { s.auditClaims(r, "plugin.uninstall", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "plugin_uninstall_scope", "%s", reason)) return @@ -234,22 +234,59 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true}) } -// serverScopedUninstall 报告「这个名字必须在『MCP 服务器』页按整台 server 处理, -// 不能在这里单删」的原因;返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。 +// serverScopedUninstall 报告「这个名字必须按整台 server 处理、不能在这里单删」的原因; +// 返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。 // // 它拦住两类真实事故(同一条根因:绕开插件管理器裸删): // - 一把 MCP 工具:server 的 names 账本不同步,重连/refresh 后工具复活,用户以为卸了; // - `mcp:` 登记条目:server 条目消失了而它的工具还在,注册表里留下无法归属的孤儿。 -func serverScopedUninstall(name string, cap capreg.Capability) string { +// +// 文案按**接入路径**分流,因为两条路的入口不同(owner 2026-10-24 复审 M30-p2-1): +// - 由插件管理器托管(落在 plugins.json)⇒ 「MCP 服务器」页确实能启停/卸载它; +// - 由 `.env` 的 MCP_SERVERS_JSON 在启动期接入(cmd/cyrene/mcp.go,**不经** Manager) +// ⇒「MCP 服务器」页只列 manager 托管的那批,**看不到它**,指向那页就是假指引; +// 必须让主人去改 .env 并重启。 +// +// 判据是**管理器持久清单里有没有这台 server**,不是名字前缀/传输推断出来的「路径猜测」。 +func (s *Server) serverScopedUninstall(name string, cap capreg.Capability) string { + server := cap.Origin.Server if cap.Kind == capreg.KindMCP { - return fmt.Sprintf("%q 是一台 MCP server 的登记条目;请在「MCP 服务器」页卸载该 server", name) + if named, ok := pluginmgr.MCPServerName(name); ok { + server = named + } + return fmt.Sprintf("%q 是一台 MCP server 的登记条目;%s", name, s.uninstallPointer(server)) } if cap.Origin.Kind == sdk.OriginKindMCP { - return fmt.Sprintf("%q 是 MCP server %q 的工具,单把工具不能单独卸载;请在「MCP 服务器」页启停或卸载该 server", name, cap.Origin.Server) + return fmt.Sprintf("%q 是 MCP server %q 的工具,单把工具不能单独卸载;%s", name, server, s.uninstallPointer(server)) } return "" } +// uninstallPointer 返回「这台 server 该去哪里启停/卸载」的**可操作**话术。 +func (s *Server) uninstallPointer(server string) string { + if s.pluginManagerHasServer(server) { + return "请在「MCP 服务器」页启停或卸载该 server" + } + return "该 server 由 .env 的 MCP_SERVERS_JSON 在启动时接入(不在 plugins.json 里,后台面板不管理它)——" + + "要停用或移除,请改 .env 里的 MCP_SERVERS_JSON(把对应的 server 删掉或设 enabled=false),改完需重启生效" +} + +// pluginManagerHasServer 报告该 server 名是否由插件管理器托管(= 落在 plugins.json, +// 因此「MCP 服务器」页看得见它)。`.env` 的 MCP_SERVERS_JSON 接入的 server 不经过 +// Manager,这里如实回答 false。没有管理器(测试/未接线)时也回答 false —— 与 +// 「面板看不见它」的事实一致,不给一个到不了的指引。 +func (s *Server) pluginManagerHasServer(server string) bool { + if s.plugins == nil || server == "" { + return false + } + for _, it := range s.plugins.List() { + if it.Config.Name == server { + return true + } + } + return false +} + // adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置 // 与每 server 工具数。它是读端点(owner/admin), // 绝不原样返回 API key 或敏感 env 值。 diff --git a/internal/services/gateway/plugin_origin_test.go b/internal/services/gateway/plugin_origin_test.go index 6a9e4bb..019d6ec 100644 --- a/internal/services/gateway/plugin_origin_test.go +++ b/internal/services/gateway/plugin_origin_test.go @@ -139,7 +139,7 @@ func rawEntryOf(t *testing.T, raw []byte, name string) string { } // TestAdminPluginUninstallRejectsMCPTool 是那条 high finding 的服务端一半: -// 单把 MCP 工具不能绕过插件管理器裸删,必须指回「MCP 服务器」页。 +// 单把 MCP 工具不能绕过插件管理器裸删(消息该指向哪,按接入路径另有专门用例)。 func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) @@ -156,14 +156,111 @@ func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) { if !strings.Contains(string(raw), "plugin_uninstall_scope") { t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw) } - if !strings.Contains(string(raw), "MCP 服务器") { - t.Fatalf("错误消息没说该去哪里卸:%s", raw) - } if _, err := env.caps.Get("read_file_mcp"); err != nil { t.Fatalf("被拒的卸载却把工具删了:%v", err) } } +// installMCPPlugin 通过 install 端点装一台 server(写进 plugins.json)。 +// 用于「这台 server 由插件管理器托管」的路径判定。 +func installMCPPlugin(t *testing.T, env *pluginTestEnv, name string) { + t.Helper() + resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{ + "kind": "mcp", + "name": name, + "config": map[string]any{"transport": "http", "endpoint": env.mcpURL}, + }, env.token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("install %q status = %d, body = %s", name, resp.StatusCode, raw) + } +} + +// TestAdminPluginUninstallMCPToolPointsToPluginPage:server 在 plugins.json 里 +// ⇒ 文案指向「MCP 服务器」页(那页确实能卸载它),**不**提 .env。 +func TestAdminPluginUninstallMCPToolPointsToPluginPage(t *testing.T) { + t.Parallel() + env := newPluginTestEnv(t, newFakeMCPHandler("", "add")) + installMCPPlugin(t, env, "calc") + + resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", + map[string]any{"name": "add"}, env.token) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw) + } + if !strings.Contains(string(raw), "plugin_uninstall_scope") { + t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw) + } + if !strings.Contains(string(raw), "MCP 服务器") { + t.Fatalf("plugins.json 装出来的 server 应指向「MCP 服务器」页:%s", raw) + } + if strings.Contains(string(raw), "MCP_SERVERS_JSON") { + t.Fatalf("plugins.json 路径不该提 .env 的 MCP_SERVERS_JSON:%s", raw) + } + if _, err := env.caps.Get("add"); err != nil { + t.Fatalf("被拒的卸载却把工具删了:%v", err) + } +} + +// TestAdminPluginUninstallMCPToolPointsToEnvPath:server **不在** plugins.json +// ⇒ 它是 `.env` 的 MCP_SERVERS_JSON 启动期接入的,而「MCP 服务器」页只列 manager +// 托管的那批(看不见它)⇒ 文案必须让主人去改 .env,且**不得**指向那页。 +func TestAdminPluginUninstallMCPToolPointsToEnvPath(t *testing.T) { + t.Parallel() + env := newPluginTestEnv(t, nil) + origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "envcalc", Transport: "stdio"} + if err := env.caps.RegisterTool(originStubTool{name: "env_add", origin: origin}); err != nil { + t.Fatalf("register env-path tool: %v", err) + } + + resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", + map[string]any{"name": "env_add"}, env.token) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw) + } + if !strings.Contains(string(raw), "plugin_uninstall_scope") { + t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw) + } + if !strings.Contains(string(raw), "MCP_SERVERS_JSON") { + t.Fatalf(".env 接入的 server 应指向 MCP_SERVERS_JSON:%s", raw) + } + if strings.Contains(string(raw), "MCP 服务器") { + t.Fatalf(".env 接入的 server 不得指向「MCP 服务器」页(那页看不到它):%s", raw) + } + if _, err := env.caps.Get("env_add"); err != nil { + t.Fatalf("被拒的卸载却把工具删了:%v", err) + } +} + +// TestAdminPluginUninstallMCPEntryPointsToEnvPath:`mcp:` 登记条目同样要按 +// 接入路径给指引 —— env 路径下也**不得**指向「MCP 服务器」页。 +func TestAdminPluginUninstallMCPEntryPointsToEnvPath(t *testing.T) { + t.Parallel() + env := newPluginTestEnv(t, nil) + if err := env.caps.Register(capreg.Capability{ + Name: "mcp:envcalc", Kind: capreg.KindMCP, Description: "MCP server envcalc", Value: struct{}{}, + }); err != nil { + t.Fatalf("register mcp entry: %v", err) + } + + resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", + map[string]any{"name": "mcp:envcalc"}, env.token) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw) + } + if !strings.Contains(string(raw), "plugin_uninstall_scope") { + t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw) + } + if !strings.Contains(string(raw), "MCP_SERVERS_JSON") { + t.Fatalf(".env 接入的登记条目应指向 MCP_SERVERS_JSON:%s", raw) + } + if strings.Contains(string(raw), "MCP 服务器") { + t.Fatalf(".env 接入的登记条目不得指向「MCP 服务器」页:%s", raw) + } + if _, err := env.caps.Get("mcp:envcalc"); err != nil { + t.Fatalf("被拒的卸载却把登记条目删了:%v", err) + } +} + // TestAdminPluginUninstallStillRemovesPlainCapability:普通能力(技能/内置工具) // 的卸载路径逐字不变 —— 拒绝只针对「有 MCP 来源」的条目。 func TestAdminPluginUninstallStillRemovesPlainCapability(t *testing.T) {