diff --git a/internal/capability/plugin/manager.go b/internal/capability/plugin/manager.go index 72130a7..7248fa8 100644 --- a/internal/capability/plugin/manager.go +++ b/internal/capability/plugin/manager.go @@ -33,6 +33,19 @@ const ( mcpCapabilityPrefix = "mcp:" ) +// MCPServerName 从 `mcp:` 登记条目的注册键里取出 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 { diff --git a/internal/services/gateway/admin_plugins.go b/internal/services/gateway/admin_plugins.go index 330d970..2365660 100644 --- a/internal/services/gateway/admin_plugins.go +++ b/internal/services/gateway/admin_plugins.go @@ -215,7 +215,7 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { // 该 server 的 names 账本不同步(下次 refresh 会把工具当「新出现的」再注册回来, // 或让 KindMCP 条目与工具各自消失一半)。判据是**注册时的来源章**,不是名字前缀。 if cap, err := reg.Get(name); err == nil { - if reason := serverScopedUninstall(name, cap); reason != "" { + 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 @@ -234,22 +234,59 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true}) } -// serverScopedUninstall 报告「这个名字必须在『MCP 服务器』页按整台 server 处理, -// 不能在这里单删」的原因;返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。 +// serverScopedUninstall 报告「这个名字必须按整台 server 处理、不能在这里单删」的原因; +// 返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。 // // 它拦住两类真实事故(同一条根因:绕开插件管理器裸删): // - 一把 MCP 工具:server 的 names 账本不同步,重连/refresh 后工具复活,用户以为卸了; // - `mcp:` 登记条目:server 条目消失了而它的工具还在,注册表里留下无法归属的孤儿。 -func serverScopedUninstall(name string, cap capreg.Capability) string { +// +// 文案按**接入路径**分流,因为两条路的入口不同(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 { - return fmt.Sprintf("%q 是一台 MCP server 的登记条目;请在「MCP 服务器」页卸载该 server", name) + 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 的工具,单把工具不能单独卸载;请在「MCP 服务器」页启停或卸载该 server", name, cap.Origin.Server) + 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 值。 diff --git a/internal/services/gateway/plugin_origin_test.go b/internal/services/gateway/plugin_origin_test.go index 6a9e4bb..019d6ec 100644 --- a/internal/services/gateway/plugin_origin_test.go +++ b/internal/services/gateway/plugin_origin_test.go @@ -139,7 +139,7 @@ func rawEntryOf(t *testing.T, raw []byte, name string) string { } // TestAdminPluginUninstallRejectsMCPTool 是那条 high finding 的服务端一半: -// 单把 MCP 工具不能绕过插件管理器裸删,必须指回「MCP 服务器」页。 +// 单把 MCP 工具不能绕过插件管理器裸删(消息该指向哪,按接入路径另有专门用例)。 func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) @@ -156,14 +156,111 @@ func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) { if !strings.Contains(string(raw), "plugin_uninstall_scope") { t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw) } - if !strings.Contains(string(raw), "MCP 服务器") { - t.Fatalf("错误消息没说该去哪里卸:%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) {