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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user