You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
171 lines
7.4 KiB
171 lines
7.4 KiB
package controlplane
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestMCPToolRepositoryValidatesDefinitions(t *testing.T) {
|
|
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
|
|
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer controller.Close()
|
|
|
|
// The legacy narrow tool is seeded as the only builtin an Agent implements.
|
|
tools, err := controller.MCPTools()
|
|
if err != nil || len(tools) != 1 || tools[0].ID != "desktop-filesystem" || tools[0].Kind != toolKindBuiltin {
|
|
t.Fatalf("expected the seeded builtin tool, got %#v / %v", tools, err)
|
|
}
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "docker", Name: "Docker", Kind: toolKindBuiltin}); err == nil {
|
|
t.Fatal("an unknown builtin tool must be rejected")
|
|
}
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "imageforge", Name: "ImageForge", Kind: toolKindHTTP, Endpoint: "not-a-url"}); err == nil {
|
|
t.Fatal("an invalid MCP endpoint must be rejected")
|
|
}
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "Bad ID", Name: "x", Kind: toolKindHTTP, Endpoint: "http://127.0.0.1:9099/mcp"}); err == nil {
|
|
t.Fatal("an invalid tool identifier must be rejected")
|
|
}
|
|
tool, err := controller.SaveMCPTool(MCPToolInput{ID: "imageforge", Name: "ImageForge MCP", Description: "本地桌面控制", Kind: toolKindHTTP, Endpoint: "http://127.0.0.1:9099/mcp/"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tool.Endpoint != "http://127.0.0.1:9099/mcp" || tool.Version != "1.0.0" || tool.AgentCount != 0 {
|
|
t.Fatalf("unexpected saved tool: %#v", tool)
|
|
}
|
|
}
|
|
|
|
func TestMCPToolRepositoryValidatesStdioCommands(t *testing.T) {
|
|
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
|
|
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer controller.Close()
|
|
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "local-plugin", Name: "本地插件", Kind: toolKindStdio}); err == nil {
|
|
t.Fatal("a stdio tool without a command must be rejected")
|
|
}
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "local-plugin", Name: "本地插件", Kind: toolKindStdio, Command: "python", Args: []string{"line\nbreak"}}); err == nil {
|
|
t.Fatal("an argument containing a newline must be rejected")
|
|
}
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "local-plugin", Name: "本地插件", Kind: toolKindStdio, Command: "python", Args: make([]string, maxToolArguments+1)}); err == nil {
|
|
t.Fatal("too many arguments must be rejected")
|
|
}
|
|
|
|
tool, err := controller.SaveMCPTool(MCPToolInput{
|
|
ID: "local-plugin", Name: "本地插件", Kind: toolKindStdio,
|
|
Command: "python", Args: []string{"-m", "my_mcp_server", "--stdio"}, Version: "1.0.0",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tool.Command != "python" || len(tool.Args) != 3 || tool.Endpoint != "" {
|
|
t.Fatalf("unexpected stdio tool: %#v", tool)
|
|
}
|
|
// The command line must survive a reload, since it is what the Agent spawns.
|
|
stored, err := controller.MCPTools()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
persisted := findTool(t, stored, "local-plugin")
|
|
if persisted.Command != "python" || len(persisted.Args) != 3 || persisted.Args[2] != "--stdio" {
|
|
t.Fatalf("expected the persisted stdio command line, got %#v", persisted)
|
|
}
|
|
// A rename with an unchanged command line must not create a second tool.
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "local-plugin", Name: "本地插件(改名)", Kind: toolKindStdio, Command: "python", Args: []string{"-m", "my_mcp_server", "--stdio"}, Version: "1.0.0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tools, err := controller.MCPTools()
|
|
if err != nil || len(tools) != 2 || findTool(t, tools, "local-plugin").Name != "本地插件(改名)" {
|
|
t.Fatalf("unexpected repository state: %#v / %v", tools, err)
|
|
}
|
|
}
|
|
|
|
func findTool(t *testing.T, tools []MCPTool, toolID string) MCPTool {
|
|
t.Helper()
|
|
for _, tool := range tools {
|
|
if tool.ID == toolID {
|
|
return tool
|
|
}
|
|
}
|
|
t.Fatalf("tool %q not found in %#v", toolID, tools)
|
|
return MCPTool{}
|
|
}
|
|
|
|
func TestMCPToolAssignmentBumpsOnlyChangedDefinitions(t *testing.T) {
|
|
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
|
|
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer controller.Close()
|
|
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('agent-1', '测试机', 'hash', 'offline', '2026-01-01T00:00:00Z')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "imageforge", Name: "ImageForge MCP", Kind: toolKindHTTP, Endpoint: "http://127.0.0.1:9099/mcp"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := controller.SetAgentMCPTools("agent-1", []string{"imageforge"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if version := assignmentVersion(t, controller, "agent-1", "imageforge"); version != 1 {
|
|
t.Fatalf("expected the first deployment version, got %d", version)
|
|
}
|
|
if err := controller.SetAgentMCPTools("agent-1", []string{"imageforge"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if version := assignmentVersion(t, controller, "agent-1", "imageforge"); version != 1 {
|
|
t.Fatalf("re-selecting an unchanged tool must not bump the version, got %d", version)
|
|
}
|
|
|
|
// A changed definition must be re-applied instead of being rejected as stale.
|
|
if _, err := controller.SaveMCPTool(MCPToolInput{ID: "imageforge", Name: "ImageForge MCP", Kind: toolKindHTTP, Endpoint: "http://127.0.0.1:9100/mcp", Version: "1.1.0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if version := assignmentVersion(t, controller, "agent-1", "imageforge"); version != 2 {
|
|
t.Fatalf("expected the changed definition to be redeployed, got version %d", version)
|
|
}
|
|
statuses, err := controller.AgentMCPStatuses()
|
|
if err != nil || len(statuses) != 1 || statuses[0].ToolID != "imageforge" || statuses[0].ApplyStatus != "pending" || statuses[0].Endpoint != "http://127.0.0.1:9100/mcp" {
|
|
t.Fatalf("unexpected tool deployment status: %#v / %v", statuses, err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMCPToolWithdrawsEveryAssignment(t *testing.T) {
|
|
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
|
|
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer controller.Close()
|
|
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('agent-1', '测试机', 'hash', 'offline', '2026-01-01T00:00:00Z')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := controller.SetAgentMCPTools("agent-1", []string{"desktop-filesystem"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := controller.DeleteMCPTool("desktop-filesystem"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := controller.DeleteMCPTool("desktop-filesystem"); err == nil {
|
|
t.Fatal("deleting a missing tool must fail")
|
|
}
|
|
var assignments int
|
|
if err := controller.db.QueryRow("SELECT COUNT(*) FROM agent_mcp_assignments").Scan(&assignments); err != nil || assignments != 0 {
|
|
t.Fatalf("expected every assignment to be withdrawn, got %d / %v", assignments, err)
|
|
}
|
|
if tools, err := controller.MCPTools(); err != nil || len(tools) != 0 {
|
|
t.Fatalf("expected an empty tool repository, got %#v / %v", tools, err)
|
|
}
|
|
}
|
|
|
|
func assignmentVersion(t *testing.T, controller *ControlPlane, agentID, toolID string) int {
|
|
t.Helper()
|
|
var version int
|
|
if err := controller.db.QueryRow("SELECT deployment_version FROM agent_mcp_assignments WHERE agent_id = ? AND tool_id = ?", agentID, toolID).Scan(&version); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return version
|
|
}
|
|
|