fix: preserve MCP stop state and validate recovery evidence before mutation

This commit is contained in:
Ed1s0nZ committed 2026-10-09 14:12:27 +08:00
1 parent 7f5a088c61
commit fab7e60318
11 files changed
+892 -13

No files matched your search

+9 -8
View File
@@ -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)
}
})
}
}
+41
View File
@@ -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
}