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。
352 lines
10 KiB
Go
352 lines
10 KiB
Go
package plugin
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
|
)
|
|
|
|
// TestMCPStdioHelper is re-executed as the child process of a stdio install
|
|
// test (Command=os.Args[0], -test.run=TestMCPStdioHelper). It speaks the MCP
|
|
// JSON-RPC handshake on stdin/stdout and requires MCP_HELPER_TOKEN=ok, which
|
|
// proves the plugin config's Env reached the subprocess. Without the marker it
|
|
// returns immediately, so a normal test run ignores it.
|
|
func TestMCPStdioHelper(t *testing.T) {
|
|
if os.Getenv("GO_WANT_MCP_STDIO_HELPER") != "1" {
|
|
return
|
|
}
|
|
if os.Getenv("MCP_HELPER_TOKEN") != "ok" {
|
|
os.Exit(3)
|
|
}
|
|
sc := bufio.NewScanner(os.Stdin)
|
|
for sc.Scan() {
|
|
line := sc.Bytes()
|
|
var req struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
}
|
|
if json.Unmarshal(line, &req) != nil || len(req.ID) == 0 {
|
|
continue
|
|
}
|
|
var result any
|
|
switch req.Method {
|
|
case "initialize":
|
|
result = map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "helper", "version": "1.0"}}
|
|
case "tools/list":
|
|
result = map[string]any{"tools": []map[string]any{{"name": "ping", "description": "ping", "inputSchema": map[string]any{"type": "object"}}}}
|
|
default:
|
|
result = map[string]any{}
|
|
}
|
|
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID), "result": result})
|
|
_, _ = fmt.Fprintln(os.Stdout, string(resp))
|
|
}
|
|
}
|
|
|
|
// fakeMCP is a minimal Streamable HTTP MCP server: it answers initialize and
|
|
// tools/list over POST and keeps a standalone GET SSE stream open until the
|
|
// request context ends. wantAuth, when set, requires the bearer header.
|
|
type fakeMCP struct {
|
|
wantAuth string
|
|
tools []string
|
|
|
|
mu sync.Mutex
|
|
auth string
|
|
hits int
|
|
}
|
|
|
|
// hitCount 返回该 server 收到的 POST 次数(「有没有真的连过」的证据:停用的 server
|
|
// 在重启后不该被连一次)。
|
|
func (f *fakeMCP) hitCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.hits
|
|
}
|
|
|
|
func (f *fakeMCP) setTools(tools ...string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.tools = tools
|
|
}
|
|
|
|
func (f *fakeMCP) authHeader() string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.auth
|
|
}
|
|
|
|
func (f *fakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
if fl, ok := w.(http.Flusher); ok {
|
|
fl.Flush()
|
|
}
|
|
<-r.Context().Done()
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
f.auth = r.Header.Get("Authorization")
|
|
f.hits++
|
|
tools := append([]string(nil), f.tools...)
|
|
f.mu.Unlock()
|
|
if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
var req struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
}
|
|
_ = json.Unmarshal(body, &req)
|
|
if len(req.ID) == 0 || string(req.ID) == "null" {
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
var result any
|
|
switch req.Method {
|
|
case "initialize":
|
|
result = map[string]any{
|
|
"protocolVersion": "2025-11-25",
|
|
"capabilities": map[string]any{},
|
|
"serverInfo": map[string]any{"name": "fake", "version": "1.0"},
|
|
}
|
|
case "tools/list":
|
|
list := make([]map[string]any, 0, len(tools))
|
|
for _, name := range tools {
|
|
list = append(list, map[string]any{
|
|
"name": name,
|
|
"description": name,
|
|
"inputSchema": map[string]any{"type": "object"},
|
|
})
|
|
}
|
|
result = map[string]any{"tools": list}
|
|
default:
|
|
result = map[string]any{}
|
|
}
|
|
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID), "result": result})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(resp)
|
|
}
|
|
|
|
func newFakeMCP(t *testing.T, wantAuth string, tools ...string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(&fakeMCP{wantAuth: wantAuth, tools: tools})
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func toolNames(reg *registry.Registry) map[string]bool {
|
|
out := make(map[string]bool)
|
|
for _, c := range reg.List(registry.KindTool) {
|
|
out[c.Name] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestManagerInstall_RegistersToolsAndPersists(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "plugins.json")
|
|
reg := registry.New()
|
|
srv := newFakeMCP(t, "sk-1234", "add", "mul")
|
|
mgr := NewManager(reg, path, nil)
|
|
defer mgr.Close(context.Background())
|
|
|
|
got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
|
Name: "calc",
|
|
Transport: runtimeconfig.TransportHTTP,
|
|
Endpoint: srv.URL,
|
|
APIKey: "sk-1234",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if !got.Live || got.ToolCount != 2 {
|
|
t.Fatalf("installed = %+v, want live with 2 tools", got)
|
|
}
|
|
names := toolNames(reg)
|
|
if !names["add"] || !names["mul"] {
|
|
t.Fatalf("registered tools = %v, want add+mul", names)
|
|
}
|
|
if _, err := reg.Get("mcp:calc"); err != nil {
|
|
t.Fatalf("mcp:calc capability: %v", err)
|
|
}
|
|
|
|
persisted, err := runtimeconfig.LoadPlugins(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPlugins: %v", err)
|
|
}
|
|
if len(persisted) != 1 || persisted[0].Name != "calc" || persisted[0].APIKey != "sk-1234" {
|
|
t.Fatalf("persisted = %+v", persisted)
|
|
}
|
|
if persisted[0].CreatedAt == "" || persisted[0].ID == "" {
|
|
t.Fatalf("persisted identity incomplete: %+v", persisted[0])
|
|
}
|
|
if masked := runtimeconfig.MaskedPlugin(persisted[0]); masked.APIKey == "sk-1234" || masked.APIKey != "****1234" {
|
|
t.Fatalf("masked key = %q, want ****1234", masked.APIKey)
|
|
}
|
|
if auth := srv.Config.Handler.(*fakeMCP).authHeader(); auth != "Bearer sk-1234" {
|
|
t.Fatalf("server saw auth %q, want bearer key", auth)
|
|
}
|
|
if list := mgr.List(); len(list) != 1 || !list[0].Live || list[0].ToolCount != 2 {
|
|
t.Fatalf("List = %+v", list)
|
|
}
|
|
}
|
|
|
|
func TestManagerUninstall_RemovesToolsAndConfig(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "plugins.json")
|
|
reg := registry.New()
|
|
srv := newFakeMCP(t, "", "add")
|
|
mgr := NewManager(reg, path, nil)
|
|
defer mgr.Close(context.Background())
|
|
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{Name: "calc", Endpoint: srv.URL}); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
removed, err := mgr.Uninstall(context.Background(), "calc")
|
|
if err != nil {
|
|
t.Fatalf("Uninstall: %v", err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("removed = false, want true")
|
|
}
|
|
if names := toolNames(reg); len(names) != 0 {
|
|
t.Fatalf("tools after uninstall = %v, want none", names)
|
|
}
|
|
if _, err := reg.Get("mcp:calc"); err == nil {
|
|
t.Fatal("mcp:calc capability still registered")
|
|
}
|
|
persisted, err := runtimeconfig.LoadPlugins(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPlugins: %v", err)
|
|
}
|
|
if len(persisted) != 0 {
|
|
t.Fatalf("persisted = %+v, want empty", persisted)
|
|
}
|
|
if removed, err := mgr.Uninstall(context.Background(), "calc"); err != nil || removed {
|
|
t.Fatalf("second uninstall = %v, %v; want false, nil", removed, err)
|
|
}
|
|
}
|
|
|
|
func TestManagerInstall_ConnectFailureDoesNotPersist(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "plugins.json")
|
|
reg := registry.New()
|
|
srv := httptest.NewServer(http.NotFoundHandler())
|
|
url := srv.URL
|
|
srv.Close()
|
|
mgr := NewManager(reg, path, nil)
|
|
defer mgr.Close(context.Background())
|
|
|
|
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{Name: "down", Endpoint: url}); err == nil {
|
|
t.Fatal("Install succeeded against a closed server")
|
|
}
|
|
if names := toolNames(reg); len(names) != 0 {
|
|
t.Fatalf("tools = %v, want none", names)
|
|
}
|
|
persisted, err := runtimeconfig.LoadPlugins(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPlugins: %v", err)
|
|
}
|
|
if len(persisted) != 0 {
|
|
t.Fatalf("persisted = %+v, want empty", persisted)
|
|
}
|
|
if list := mgr.List(); len(list) != 0 {
|
|
t.Fatalf("List = %+v, want empty", list)
|
|
}
|
|
}
|
|
|
|
func TestManagerInstall_RefreshReplacesTools(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "plugins.json")
|
|
reg := registry.New()
|
|
srv := newFakeMCP(t, "", "add")
|
|
mgr := NewManager(reg, path, nil)
|
|
defer mgr.Close(context.Background())
|
|
ctx := context.Background()
|
|
first, err := mgr.Install(ctx, runtimeconfig.PluginConfig{Name: "calc", Endpoint: srv.URL})
|
|
if err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
srv.Config.Handler.(*fakeMCP).setTools("div")
|
|
|
|
second, err := mgr.Install(ctx, runtimeconfig.PluginConfig{Name: "calc", Endpoint: srv.URL})
|
|
if err != nil {
|
|
t.Fatalf("reinstall: %v", err)
|
|
}
|
|
if second.ToolCount != 1 {
|
|
t.Fatalf("toolCount = %d, want 1", second.ToolCount)
|
|
}
|
|
if second.Config.ID != first.Config.ID {
|
|
t.Fatalf("id changed on refresh: %q -> %q", first.Config.ID, second.Config.ID)
|
|
}
|
|
names := toolNames(reg)
|
|
if names["add"] {
|
|
t.Fatalf("stale tool add still registered: %v", names)
|
|
}
|
|
if !names["div"] {
|
|
t.Fatalf("new tool div missing: %v", names)
|
|
}
|
|
persisted, err := runtimeconfig.LoadPlugins(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPlugins: %v", err)
|
|
}
|
|
if len(persisted) != 1 {
|
|
t.Fatalf("persisted = %+v, want one entry", persisted)
|
|
}
|
|
}
|
|
|
|
func TestManagerInstall_StdioWithEnv(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "plugins.json")
|
|
reg := registry.New()
|
|
mgr := NewManager(reg, path, nil)
|
|
defer mgr.Close(context.Background())
|
|
|
|
got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
|
Name: "stdio",
|
|
Transport: runtimeconfig.TransportStdio,
|
|
Command: os.Args[0],
|
|
Args: []string{"-test.run=TestMCPStdioHelper"},
|
|
Env: map[string]string{"GO_WANT_MCP_STDIO_HELPER": "1", "MCP_HELPER_TOKEN": "ok"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if got.ToolCount != 1 || !toolNames(reg)["ping"] {
|
|
t.Fatalf("installed = %+v, tools = %v; want one ping tool", got, toolNames(reg))
|
|
}
|
|
}
|
|
|
|
func TestManagerRestore_ReinstallsPersistedPlugins(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "plugins.json")
|
|
srv := newFakeMCP(t, "", "add")
|
|
reg := registry.New()
|
|
mgr := NewManager(reg, path, nil)
|
|
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{Name: "calc", Endpoint: srv.URL}); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
mgr.Close(context.Background())
|
|
|
|
reg2 := registry.New()
|
|
mgr2 := NewManager(reg2, path, nil)
|
|
defer mgr2.Close(context.Background())
|
|
mgr2.Restore(context.Background())
|
|
if names := toolNames(reg2); !names["add"] {
|
|
t.Fatalf("restored tools = %v, want add", names)
|
|
}
|
|
}
|