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) } }