d1fd78324b
cmd/devicemesh-mcp/main_test.go (10 tests): - TestInitialize: JSON-RPC initialize frame → serverInfo + capabilities. - TestToolsList: tools/list → 16 user-mode entries, cada uno con name + inputSchema valido. - TestToolsCallExec: tools/call name=exec → mock device-agent (httptest) recibe capability=shell.exec, MCP response content contiene "hi". - TestToolsCallInvalidTool: name desconocido → isError o error envelope. - TestNotificationsInitializedNoResponse: notification (sin id) → cero responses. - TestUserModeFiltersPkgInstall: --mode user oculta pkg.install, --mode sudo la expone. - TestToolsAllowedNarrows: --tools-allowed exec,fs.read → solo 2. - TestSplitCSV, TestParseMode, TestIsCleanShutdown: helpers. cmd/devicemesh-mcp/integration_test.go: - TestIntegrationBinarySubprocess: build el binario en tmp + spawn como child via exec.Command + pipe real + secuencia initialize -> notifications/initialized -> tools/list -> tools/call. Valida el path identico al que usara claude. devagents/mcp_bridge_test.go (9 tests): - Disabled paths (nil DM, ExposeViaMCP=false, provider!=claude-code). - Applied path: /tmp/<agent>-mcp-config.json JSON valido, mode 0600, mcpServers.devicemesh con command apuntando al binario fake. - AllowedTools formato mcp__<server>__<tool>. - DisableTools=true overrideado a false. - URLEnv override gana sobre YAML. - Binary missing → ok=false sin panico. - BuildClaudeAllowedToolNames default server name. - ResolveBridgedToolNames respeta mode + ToolsAllowed. - ShouldExposeViaMCP cubre nil/disabled/default/explicit-true/false. shell/llm/claudecode_test.go: - TestBuildClaudeArgs_DisableTools actualizado: solo emite --tools "" cuando AllowedTools ESTA vacio. La regla nueva (issue 0145) da precedencia a AllowedTools. - Anadido TestBuildClaudeArgs_DisableToolsButAllowedToolsWins. - Anadido TestBuildClaudeArgs_MCPConfigPath. bridge.go fix: cambio NewTool + WithRawInputSchema a NewToolWithRawSchema porque NewTool inicializa ToolInputSchema.Type="object" por default, lo cual entra en conflicto con RawInputSchema en MarshalJSON del SDK. Suite completa pasa con -tags goolm -count=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
7.5 KiB
Go
264 lines
7.5 KiB
Go
package devagents
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/enmanuel/agents/internal/config"
|
|
)
|
|
|
|
func newSilentLogger() *slog.Logger {
|
|
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
|
}
|
|
|
|
// withBinary creates a fake bin/devicemesh-mcp under tmpDir so the bridge's
|
|
// binary resolver finds something on disk. Returns the previous CWD.
|
|
func withBinary(t *testing.T, tmpDir string) func() {
|
|
t.Helper()
|
|
binDir := filepath.Join(tmpDir, "bin")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
binPath := filepath.Join(binDir, "devicemesh-mcp")
|
|
if err := os.WriteFile(binPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake binary: %v", err)
|
|
}
|
|
prevDir, _ := os.Getwd()
|
|
if err := os.Chdir(tmpDir); err != nil {
|
|
t.Fatalf("chdir: %v", err)
|
|
}
|
|
return func() { _ = os.Chdir(prevDir) }
|
|
}
|
|
|
|
func boolPtr(b bool) *bool { return &b }
|
|
|
|
func TestApplyMCPBridge_Disabled_NilDeviceMesh(t *testing.T) {
|
|
cfg := &config.AgentConfig{}
|
|
_, ok := ApplyMCPBridge(cfg, newSilentLogger())
|
|
if ok {
|
|
t.Errorf("expected ok=false when DeviceMesh is nil")
|
|
}
|
|
}
|
|
|
|
func TestApplyMCPBridge_Disabled_ExposeFalse(t *testing.T) {
|
|
cfg := &config.AgentConfig{
|
|
DeviceMesh: &config.DeviceMeshConfig{
|
|
Enabled: true,
|
|
ExposeViaMCP: boolPtr(false),
|
|
},
|
|
}
|
|
cfg.LLM.Primary.Provider = "claude-code"
|
|
_, ok := ApplyMCPBridge(cfg, newSilentLogger())
|
|
if ok {
|
|
t.Errorf("expected ok=false when ExposeViaMCP=false")
|
|
}
|
|
}
|
|
|
|
func TestApplyMCPBridge_Disabled_WrongProvider(t *testing.T) {
|
|
cfg := &config.AgentConfig{}
|
|
cfg.Agent.ID = "test"
|
|
cfg.LLM.Primary.Provider = "openai"
|
|
cfg.DeviceMesh = &config.DeviceMeshConfig{
|
|
Enabled: true,
|
|
DeviceAgentURL: "http://127.0.0.1:9999",
|
|
Mode: "user",
|
|
}
|
|
_, ok := ApplyMCPBridge(cfg, newSilentLogger())
|
|
if ok {
|
|
t.Errorf("expected ok=false for non-claude-code provider")
|
|
}
|
|
}
|
|
|
|
func TestApplyMCPBridge_Applied_DefaultExpose(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
defer withBinary(t, tmp)()
|
|
|
|
cfg := &config.AgentConfig{}
|
|
cfg.Agent.ID = "agent-test"
|
|
cfg.LLM.Primary.Provider = "claude-code"
|
|
cfg.LLM.Primary.ClaudeCode.DisableTools = true // expect override to false
|
|
cfg.DeviceMesh = &config.DeviceMeshConfig{
|
|
Enabled: true,
|
|
DeviceAgentURL: "http://10.42.0.10:7474",
|
|
Mode: "user",
|
|
ToolsAllowed: []string{"exec", "fs.read"},
|
|
}
|
|
|
|
result, ok := ApplyMCPBridge(cfg, newSilentLogger())
|
|
if !ok {
|
|
t.Fatalf("expected ok=true; bridge should have been applied")
|
|
}
|
|
|
|
// 1. Config path written and valid JSON.
|
|
if result.ConfigPath == "" {
|
|
t.Fatalf("missing ConfigPath in result")
|
|
}
|
|
defer os.Remove(result.ConfigPath)
|
|
raw, err := os.ReadFile(result.ConfigPath)
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
t.Fatalf("config not valid JSON: %v\n%s", err, raw)
|
|
}
|
|
servers, _ := doc["mcpServers"].(map[string]any)
|
|
srv, _ := servers["devicemesh"].(map[string]any)
|
|
if srv == nil {
|
|
t.Fatalf("mcpServers.devicemesh missing in config: %s", raw)
|
|
}
|
|
if cmd, _ := srv["command"].(string); !strings.HasSuffix(cmd, "devicemesh-mcp") {
|
|
t.Errorf("expected command to end with devicemesh-mcp, got %q", cmd)
|
|
}
|
|
|
|
// 2. AllowedTools formatted as mcp__<server>__<tool>.
|
|
if len(cfg.LLM.Primary.ClaudeCode.AllowedTools) != 2 {
|
|
t.Fatalf("expected 2 allowed tools, got %v", cfg.LLM.Primary.ClaudeCode.AllowedTools)
|
|
}
|
|
for _, n := range cfg.LLM.Primary.ClaudeCode.AllowedTools {
|
|
if !strings.HasPrefix(n, "mcp__devicemesh__") {
|
|
t.Errorf("allowed tool %q missing mcp__devicemesh__ prefix", n)
|
|
}
|
|
}
|
|
|
|
// 3. MCPConfigPath set on cfg.
|
|
if cfg.LLM.Primary.ClaudeCode.MCPConfigPath != result.ConfigPath {
|
|
t.Errorf("MCPConfigPath not propagated to cfg: got %q want %q",
|
|
cfg.LLM.Primary.ClaudeCode.MCPConfigPath, result.ConfigPath)
|
|
}
|
|
|
|
// 4. DisableTools override applied.
|
|
if cfg.LLM.Primary.ClaudeCode.DisableTools {
|
|
t.Errorf("expected DisableTools=false after override, got true")
|
|
}
|
|
|
|
// 5. /tmp file mode is 0600.
|
|
st, err := os.Stat(result.ConfigPath)
|
|
if err == nil && st.Mode().Perm() != 0o600 {
|
|
t.Errorf("expected config file mode 0600, got %v", st.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestApplyMCPBridge_URLEnvOverride(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
defer withBinary(t, tmp)()
|
|
|
|
t.Setenv("AGENT_TEST_DM_URL", "http://envurl.example:1234")
|
|
|
|
cfg := &config.AgentConfig{}
|
|
cfg.Agent.ID = "agent-test"
|
|
cfg.LLM.Primary.Provider = "claude-code"
|
|
cfg.DeviceMesh = &config.DeviceMeshConfig{
|
|
Enabled: true,
|
|
DeviceAgentURL: "http://yaml-loses:9999",
|
|
URLEnv: "AGENT_TEST_DM_URL",
|
|
Mode: "user",
|
|
}
|
|
|
|
result, ok := ApplyMCPBridge(cfg, newSilentLogger())
|
|
if !ok {
|
|
t.Fatalf("expected ok=true")
|
|
}
|
|
defer os.Remove(result.ConfigPath)
|
|
if result.DeviceAgentURL != "http://envurl.example:1234" {
|
|
t.Errorf("env URL override not applied: got %q", result.DeviceAgentURL)
|
|
}
|
|
}
|
|
|
|
func TestApplyMCPBridge_BinaryMissing(t *testing.T) {
|
|
// No fake binary on disk → should skip cleanly.
|
|
tmp := t.TempDir()
|
|
prev, _ := os.Getwd()
|
|
_ = os.Chdir(tmp)
|
|
defer os.Chdir(prev)
|
|
|
|
cfg := &config.AgentConfig{}
|
|
cfg.Agent.ID = "agent-test"
|
|
cfg.LLM.Primary.Provider = "claude-code"
|
|
cfg.DeviceMesh = &config.DeviceMeshConfig{
|
|
Enabled: true,
|
|
DeviceAgentURL: "http://10.42.0.10:7474",
|
|
}
|
|
if _, ok := ApplyMCPBridge(cfg, newSilentLogger()); ok {
|
|
t.Errorf("expected ok=false when binary is missing")
|
|
}
|
|
}
|
|
|
|
func TestBuildClaudeAllowedToolNames(t *testing.T) {
|
|
got := BuildClaudeAllowedToolNames("devicemesh", []string{"exec", "fs.read", "git.clone"})
|
|
if len(got) != 3 {
|
|
t.Fatalf("expected 3 names, got %d", len(got))
|
|
}
|
|
for _, n := range got {
|
|
if !strings.HasPrefix(n, "mcp__devicemesh__") {
|
|
t.Errorf("name %q missing prefix", n)
|
|
}
|
|
}
|
|
// Sorted output for determinism.
|
|
if got[0] >= got[1] || got[1] >= got[2] {
|
|
t.Errorf("expected sorted output, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildClaudeAllowedToolNames_DefaultServer(t *testing.T) {
|
|
got := BuildClaudeAllowedToolNames("", []string{"exec"})
|
|
if len(got) != 1 || !strings.HasPrefix(got[0], "mcp__devicemesh__") {
|
|
t.Errorf("expected default server name 'devicemesh', got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveBridgedToolNames_UserMode(t *testing.T) {
|
|
names, err := ResolveBridgedToolNames(&config.DeviceMeshConfig{
|
|
Enabled: true,
|
|
Mode: "user",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if len(names) == 0 {
|
|
t.Fatalf("expected non-empty names")
|
|
}
|
|
for _, n := range names {
|
|
if n == "pkg.install" {
|
|
t.Errorf("user mode should not include pkg.install")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveBridgedToolNames_Filter(t *testing.T) {
|
|
names, err := ResolveBridgedToolNames(&config.DeviceMeshConfig{
|
|
Enabled: true,
|
|
Mode: "user",
|
|
ToolsAllowed: []string{"exec", "fs.read", "unknown"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if len(names) != 2 {
|
|
t.Errorf("expected 2 names after filter, got %d (%v)", len(names), names)
|
|
}
|
|
}
|
|
|
|
func TestShouldExposeViaMCP(t *testing.T) {
|
|
if (*config.DeviceMeshConfig)(nil).ShouldExposeViaMCP() {
|
|
t.Errorf("nil should not expose")
|
|
}
|
|
if (&config.DeviceMeshConfig{}).ShouldExposeViaMCP() {
|
|
t.Errorf("disabled should not expose")
|
|
}
|
|
if !(&config.DeviceMeshConfig{Enabled: true}).ShouldExposeViaMCP() {
|
|
t.Errorf("enabled + nil pointer should default to expose=true")
|
|
}
|
|
if (&config.DeviceMeshConfig{Enabled: true, ExposeViaMCP: boolPtr(false)}).ShouldExposeViaMCP() {
|
|
t.Errorf("enabled + false should not expose")
|
|
}
|
|
if !(&config.DeviceMeshConfig{Enabled: true, ExposeViaMCP: boolPtr(true)}).ShouldExposeViaMCP() {
|
|
t.Errorf("enabled + true should expose")
|
|
}
|
|
}
|