Files
agents_and_robots/devagents/mcp_bridge_test.go
T
egutierrez d1fd78324b test(0145): unit + integration + launcher + claudecode coverage
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>
2026-05-24 18:33:24 +02:00

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