主人 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` 通过。
75 lines
3.2 KiB
Go
75 lines
3.2 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|