mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 22:17:42 +02:00
fix: preserve MCP stop state and validate recovery evidence before mutation
This commit is contained in:
1 parent
7f5a088c61
commit
fab7e60318
11 files changed
+892
-13
No files matched your search
@@ -179,13 +179,10 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
|
||||
|
||||
cfg := req.Config
|
||||
|
||||
// 官方 disabled 字段 → ExternalMCPEnable 取反
|
||||
if cfg.Disabled {
|
||||
cfg.ExternalMCPEnable = false
|
||||
} else if !cfg.ExternalMCPEnable {
|
||||
// 用户未显式设置 external_mcp_enable,官方配置默认就是启用的
|
||||
cfg.ExternalMCPEnable = true
|
||||
}
|
||||
// Editing metadata does not change activation unless the request says so.
|
||||
previous, exists := h.manager.GetConfigs()[name]
|
||||
cfg.ExternalMCPEnable = req.activation(previous.ExternalMCPEnable, exists)
|
||||
cfg.Disabled = !cfg.ExternalMCPEnable
|
||||
|
||||
// 展开 ${VAR} 环境变量
|
||||
config.ExpandConfigEnv(&cfg)
|
||||
@@ -272,6 +269,7 @@ func (h *ExternalMCPHandler) StartExternalMCP(c *gin.Context) {
|
||||
}
|
||||
cfg := h.config.ExternalMCP.Servers[name]
|
||||
cfg.ExternalMCPEnable = true
|
||||
cfg.Disabled = false
|
||||
h.config.ExternalMCP.Servers[name] = cfg
|
||||
|
||||
// 保存到配置文件
|
||||
@@ -326,6 +324,7 @@ func (h *ExternalMCPHandler) StopExternalMCP(c *gin.Context) {
|
||||
}
|
||||
cfg := h.config.ExternalMCP.Servers[name]
|
||||
cfg.ExternalMCPEnable = false
|
||||
cfg.Disabled = true
|
||||
h.config.ExternalMCP.Servers[name] = cfg
|
||||
|
||||
// 保存到配置文件
|
||||
@@ -496,7 +495,9 @@ func setStringArrayInMap(mapNode *yaml.Node, key string, values []string) {
|
||||
|
||||
// AddOrUpdateExternalMCPRequest 添加或更新外部MCP请求
|
||||
type AddOrUpdateExternalMCPRequest struct {
|
||||
Config config.ExternalMCPServerConfig `json:"config"`
|
||||
Config config.ExternalMCPServerConfig `json:"config"`
|
||||
enable *bool
|
||||
disabled *bool
|
||||
}
|
||||
|
||||
// ExternalMCPResponse 外部MCP响应
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
func TestMCPEditPreservesStoppedState(t *testing.T) {
|
||||
router, h, path := setupTestRouter()
|
||||
defer cleanupTestConfig(path)
|
||||
defer h.manager.StopAll()
|
||||
var calls atomic.Int64
|
||||
called := make(chan struct{}, 16)
|
||||
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
select {
|
||||
case called <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
http.Error(w, "test endpoint", 503)
|
||||
}))
|
||||
defer endpoint.Close()
|
||||
cfg := config.ExternalMCPServerConfig{Type: "http", URL: endpoint.URL, Description: "before"}
|
||||
h.config.ExternalMCP.Servers["stopped"] = cfg
|
||||
if err := h.manager.AddOrUpdateConfig("stopped", cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := func(method, url string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, url, bytes.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s %s: %d %s", method, url, w.Code, w.Body)
|
||||
}
|
||||
return w
|
||||
}
|
||||
request("POST", "/api/external-mcp/stopped/stop", nil)
|
||||
// Shape emitted by the edit UI: enable omitted, descriptive metadata changed.
|
||||
body, _ := json.Marshal(map[string]any{"config": map[string]any{"type": "http", "url": endpoint.URL, "description": "after"}})
|
||||
request("PUT", "/api/external-mcp/stopped", body)
|
||||
if h.manager.GetConfigs()["stopped"].ExternalMCPEnable {
|
||||
t.Fatal("metadata edit enabled a stopped MCP")
|
||||
}
|
||||
persisted, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted.ExternalMCP.Servers["stopped"].ExternalMCPEnable {
|
||||
t.Fatal("stop state lost on config reload")
|
||||
}
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("metadata edit initiated a connection")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("stopped edit started external work")
|
||||
}
|
||||
request("POST", "/api/external-mcp/stopped/start", nil)
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("explicit Start did not connect")
|
||||
}
|
||||
if !h.manager.GetConfigs()["stopped"].ExternalMCPEnable {
|
||||
t.Fatal("explicit Start stayed disabled")
|
||||
}
|
||||
persisted, err = config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !persisted.ExternalMCP.Servers["stopped"].ExternalMCPEnable {
|
||||
t.Fatal("explicit Start not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRequestActivationPresence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, fields string
|
||||
previous, exists, want bool
|
||||
}{
|
||||
{"new default", "", false, false, true},
|
||||
{"stopped edit", "", false, true, false},
|
||||
{"running edit", "", true, true, true},
|
||||
{"explicit enable", `,"external_mcp_enable":true`, false, true, true},
|
||||
{"explicit stop", `,"external_mcp_enable":false`, true, true, false},
|
||||
{"explicit disabled", `,"disabled":true`, true, true, false},
|
||||
{"explicit enabled official", `,"disabled":false`, false, true, true},
|
||||
{"disabled wins", `,"disabled":true,"external_mcp_enable":true`, true, true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var r AddOrUpdateExternalMCPRequest
|
||||
if err := json.Unmarshal([]byte(`{"config":{"description":"edit"`+tc.fields+`}}`), &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.activation(tc.previous, tc.exists); got != tc.want {
|
||||
t.Fatalf("activation=%v want=%v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package handler
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Keep omission distinct from false without changing the public request shape.
|
||||
func (r *AddOrUpdateExternalMCPRequest) UnmarshalJSON(data []byte) error {
|
||||
type plain AddOrUpdateExternalMCPRequest
|
||||
var decoded plain
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
var presence struct {
|
||||
Config struct {
|
||||
Enable *bool `json:"external_mcp_enable"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &presence); err != nil {
|
||||
return err
|
||||
}
|
||||
*r = AddOrUpdateExternalMCPRequest(decoded)
|
||||
r.enable = presence.Config.Enable
|
||||
r.disabled = presence.Config.Disabled
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r AddOrUpdateExternalMCPRequest) activation(previous, exists bool) bool {
|
||||
if r.disabled != nil && *r.disabled {
|
||||
return false
|
||||
}
|
||||
if r.enable != nil {
|
||||
return *r.enable
|
||||
}
|
||||
if r.disabled != nil {
|
||||
return !*r.disabled
|
||||
}
|
||||
if exists {
|
||||
return previous
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user