feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照

design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。

- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
  向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
  注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
  · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
  · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
  · 新增 Reconnect(对已停用者拒绝,不替用户启用);
  · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
    lastError 在记录那一刻即脱敏截断);
  · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
    disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
  · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
This commit is contained in:
AskaEth committed 2026-10-24 19:31:31 +08:00
1 parent 4ed5925296
commit 9654ce8861
14 files changed
+1969 -115

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
package plugin
// 本文件锁定「按 server 独立启停」的三条硬要求(切片 M20):
// ① 热切换:停用后工具**真的**从注册表消失(模型看不见它们),启用后回来;
// ② 持久化:`enabled:false` 落进 plugins.json,重启(Restore)后仍是停用 —— 而且
// **一次都不去连**(不是「连了一下再判定停用」);
// ③ 失败如实:启用失败时不假装开着,落盘的 enabled 保持 false,快照给 lastError。
import (
"context"
"errors"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// newEnabledManager 构造一台连到假 MCP server 的已安装插件,返回管理器与它的注册表。
func newEnabledManager(t *testing.T, path string, endpoint string, tools ...string) (*Manager, *registry.Registry) {
t.Helper()
reg := registry.New()
mgr := NewManager(reg, path, nil)
t.Cleanup(func() { mgr.Close(context.Background()) })
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint,
}); err != nil {
t.Fatalf("Install: %v", err)
}
return mgr, reg
}
// TestDisableUnregistersToolsAndPersists 锁定 ①+② 的一半:停用即摘工具、写 enabled:false。
func TestDisableUnregistersToolsAndPersists(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("安装后工具 = %v,期望 add+mul", names)
}
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 工具必须真的不在注册表里:模型的面就是这样来的。
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用后工具仍可被模型看到 = %v", names)
}
if _, err := reg.Get("mcp:calc"); err == nil {
t.Fatal("停用后 mcp:calc 能力仍在")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘配置 = %+v,期望仅一条且已停用", cfgs)
}
if cfgs[0].Enabled == nil {
t.Fatal("停用必须写显式的 enabled=false(缺席=启用,无法表达停用)")
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Connected == nil || *st.Connected {
t.Fatalf("停用后状态 = %+v,期望 disabled/connected=false", st)
}
if st.ToolCount != nil {
t.Fatalf("停用的 server 不该报工具数(不知道它有什么),得到 %d", *st.ToolCount)
}
}
// TestEnableBringsToolsBack 锁定 ①的另一半:启用重新连接并重新注册。
func TestEnableBringsToolsBack(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if err := mgr.SetEnabled(ctx, "calc", true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("启用后工具 = %v,期望 add+mul 回来", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || !cfgs[0].IsEnabled() {
t.Fatalf("启用后落盘 = %+v,期望 enabled=true", cfgs)
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateConnected || st.ToolCount == nil || *st.ToolCount != 2 {
t.Fatalf("启用后状态 = %+v,期望 connected 且 2 把工具", st)
}
if len(st.Tools) != 2 {
t.Fatalf("工具明细 = %+v,期望 2 条", st.Tools)
}
}
// TestRestoreHonoursDisabledFlag 锁定 ②:重启后停用仍然是停用,而且**一次都不连**。
func TestRestoreHonoursDisabledFlag(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
mgr.Close(context.Background())
before := srv.Config.Handler.(*fakeMCP).hitCount()
reg2 := registry.New()
mgr2 := NewManager(reg2, path, nil)
defer mgr2.Close(context.Background())
mgr2.Restore(context.Background())
if names := toolNames(reg2); len(names) != 0 {
t.Fatalf("重启后停用的 server 仍注册了工具 = %v", names)
}
if after := srv.Config.Handler.(*fakeMCP).hitCount(); after != before {
t.Fatalf("重启期对停用的 server 发起了连接:hits %d -> %d", before, after)
}
st := statusOfOne(t, mgr2, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("重启后状态 = %+v,期望仍是 disabled", st)
}
}
// TestInstallDisabledDoesNotConnect 锁定 Install 也尊重 enabled:装上但停用 = 落盘且不连。
func TestInstallDisabledDoesNotConnect(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
reg := registry.New()
mgr := NewManager(reg, path, nil)
defer mgr.Close(context.Background())
got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Endpoint: srv.URL, Enabled: enabledFlag(false),
})
if err != nil {
t.Fatalf("Install(disabled): %v", err)
}
if got.Live {
t.Fatalf("停用安装不该有活 server:%+v", got)
}
if hits := srv.Config.Handler.(*fakeMCP).hitCount(); hits != 0 {
t.Fatalf("停用安装发起了 %d 次连接", hits)
}
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用安装注册了工具 = %v", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘 = %+v(err=%v),期望一条停用配置", cfgs, err)
}
}
// TestEnableFailureKeepsDisabled 锁定 ③:启用失败 ⇒ 返回错误 + 落盘仍是停用 + 快照给 lastError。
func TestEnableFailureKeepsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 让 server 连不上:关掉进程内的假 server。
srv.Close()
if err := mgr.SetEnabled(ctx, "calc", true); err == nil {
t.Fatal("启用一台连不上的 server 应当报错")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("落盘 = %+v(err=%v)", cfgs, err)
}
if cfgs[0].IsEnabled() {
t.Fatalf("启用失败却把 enabled 写成了 true:%+v", cfgs[0])
}
// 状态仍是 disabled(启用没成功,它就还是停用态),而 lastError 说清了这次失败 ——
// 「有意停用」与「想启用但失败」由这两列分别表达,不互相冒充。
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("启用失败后状态 = %q/enabled=%v,期望 disabled/false", st.State, st.Enabled)
}
if st.Connected == nil || *st.Connected {
t.Fatalf("启用失败后 connected = %v,期望 false(确认没连)", st.Connected)
}
if st.LastError == "" {
t.Fatal("启用失败后 lastError 为空:面板说不出为什么挂了")
}
if st.LastErrorAt == "" {
t.Fatal("启用失败后 lastErrorAt 为空")
}
}
// TestReconnectRejectsDisabled 锁定:重连不替用户启用(那会让「它为什么忽然活了」变成谜)。
func TestReconnectRejectsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if _, err := mgr.Reconnect(ctx, "calc"); !errors.Is(err, ErrPluginDisabled) {
t.Fatalf("对停用的 server 重连 = %v,期望 ErrPluginDisabled", err)
}
if _, err := mgr.Reconnect(ctx, "ghost"); !errors.Is(err, ErrPluginNotFound) {
t.Fatalf("重连不存在的 server = %v,期望 ErrPluginNotFound", err)
}
}
// enabledFlag 是 `&false` 的简写(PluginConfig.Enabled 是三态指针)。
func enabledFlag(v bool) *bool { return &v }