Files
Cyrene_v2/internal/capability/mcp/origin_test.go
T
AskaEth 86aef7b454 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` 通过。
2026-10-24 20:21:22 +08:00

75 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
})
}
}