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:` 登记条目同样要按 // 接入路径给指引 —— 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)) } }