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