Merge branch 'feat/m30-mcporigin-stamp-tool-origin-and-grou' into dev

This commit is contained in:
AskaEth committed 2026-10-24 20:36:47 +08:00
commit 679c8abf1d
16 files changed
+1241 -48

No files matched your search

+74
View File
@@ -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)
}
})
}
}
+13
View File
@@ -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 {
+24
View File
@@ -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) {
+13
View File
@@ -33,6 +33,19 @@ const (
mcpCapabilityPrefix = "mcp:"
)
// MCPServerName 从 `mcp:<server>` 登记条目的注册键里取出 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 {
+111
View File
@@ -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)
}
}
+28
View File
@@ -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
+67
View File
@@ -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)
}
}
+21 -5
View File
@@ -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
@@ -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:<server>` 登记条目**不能**走这条裸删路径
// (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 := s.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,59 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true})
}
// serverScopedUninstall 报告「这个名字必须按整台 server 处理、不能在这里单删」的原因;
// 返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。
//
// 它拦住两类真实事故(同一条根因:绕开插件管理器裸删):
// - 一把 MCP 工具:server 的 names 账本不同步,重连/refresh 后工具复活,用户以为卸了;
// - `mcp:<server>` 登记条目:server 条目消失了而它的工具还在,注册表里留下无法归属的孤儿。
//
// 文案按**接入路径**分流,因为两条路的入口不同(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 {
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 的工具,单把工具不能单独卸载;%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 值。
+18
View File
@@ -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 绝不抵达客户端。
@@ -0,0 +1,331 @@
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 工具不能绕过插件管理器裸删(消息该指向哪,按接入路径另有专门用例)。
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 _, 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:<server>` 登记条目同样要按
// 接入路径给指引 —— 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) {
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))
}
}