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>
178 lines
4.4 KiB
Go
178 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestIntegrationBinarySubprocess builds the binary (or uses an existing
|
|
// bin/devicemesh-mcp) and exercises a full initialize -> tools/list ->
|
|
// tools/call sequence over a real OS pipe. This validates that the same
|
|
// code path that claude will invoke (subprocess + stdio) works end-to-end.
|
|
//
|
|
// Skipped when the binary cannot be built or located, so the rest of the
|
|
// unit tests still run cleanly on minimal sandboxes.
|
|
func TestIntegrationBinarySubprocess(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test skipped in -short mode")
|
|
}
|
|
|
|
binPath := buildOrLocateBinary(t)
|
|
if binPath == "" {
|
|
t.Skip("cannot build/locate devicemesh-mcp binary")
|
|
}
|
|
|
|
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body := map[string]any{}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"request_id": body["request_id"],
|
|
"ok": true,
|
|
"duration_ms": 7,
|
|
"result": map[string]any{
|
|
"stdout": "subprocess hi",
|
|
"stderr": "",
|
|
"exit_code": 0,
|
|
},
|
|
})
|
|
}))
|
|
defer mock.Close()
|
|
|
|
cmd := exec.Command(binPath,
|
|
"--device-agent", mock.URL,
|
|
"--mode", "user",
|
|
"--server-name", "devicemesh",
|
|
)
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
t.Fatalf("stdin pipe: %v", err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatalf("stdout pipe: %v", err)
|
|
}
|
|
cmd.Stderr = io.Discard
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer func() {
|
|
_ = stdin.Close()
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
}()
|
|
|
|
// Real MCP clients send `notifications/initialized` after the
|
|
// initialize response is received before sending any other requests.
|
|
// We mirror the same sequence — without it the server may queue
|
|
// follow-up frames behind the not-yet-initialized session.
|
|
frames := []string{
|
|
initFrame(1),
|
|
notifInitializedFrame(),
|
|
toolsListFrame(2),
|
|
toolsCallFrame(3, "exec", map[string]any{"argv": []any{"echo", "subprocess"}}),
|
|
}
|
|
for _, f := range frames {
|
|
if !strings.HasSuffix(f, "\n") {
|
|
f += "\n"
|
|
}
|
|
if _, err := stdin.Write([]byte(f)); err != nil {
|
|
t.Fatalf("write frame: %v", err)
|
|
}
|
|
}
|
|
|
|
// Read responses (up to 3 with timeout).
|
|
reader := bufio.NewReader(stdout)
|
|
deadline := time.After(5 * time.Second)
|
|
responses := make([]map[string]any, 0, 3)
|
|
|
|
readCh := make(chan map[string]any, 4)
|
|
go func() {
|
|
defer close(readCh)
|
|
dec := json.NewDecoder(reader)
|
|
for {
|
|
var msg map[string]any
|
|
if err := dec.Decode(&msg); err != nil {
|
|
return
|
|
}
|
|
readCh <- msg
|
|
}
|
|
}()
|
|
|
|
readLoop:
|
|
for {
|
|
select {
|
|
case msg, ok := <-readCh:
|
|
if !ok {
|
|
break readLoop
|
|
}
|
|
responses = append(responses, msg)
|
|
if len(responses) >= 3 {
|
|
break readLoop
|
|
}
|
|
case <-deadline:
|
|
break readLoop
|
|
}
|
|
}
|
|
|
|
if len(responses) < 3 {
|
|
t.Fatalf("expected 3 responses, got %d: %v", len(responses), responses)
|
|
}
|
|
|
|
// Validate the tools/call (id=3) response.
|
|
r := responses[2]
|
|
if r["id"] != float64(3) {
|
|
t.Errorf("expected id=3, got %v", r["id"])
|
|
}
|
|
result, _ := r["result"].(map[string]any)
|
|
contents, _ := result["content"].([]any)
|
|
if len(contents) == 0 {
|
|
t.Fatalf("missing content in tools/call response: %v", r)
|
|
}
|
|
first, _ := contents[0].(map[string]any)
|
|
text, _ := first["text"].(string)
|
|
if !strings.Contains(text, "subprocess hi") {
|
|
t.Errorf("expected text to contain 'subprocess hi', got %q", text)
|
|
}
|
|
}
|
|
|
|
// buildOrLocateBinary returns the absolute path to bin/devicemesh-mcp,
|
|
// building it under a temp dir if it is missing. Returns "" if neither
|
|
// option works (the test then skips).
|
|
func buildOrLocateBinary(t *testing.T) string {
|
|
t.Helper()
|
|
// First, try ../../bin/devicemesh-mcp relative to this file (CWD when
|
|
// `go test ./cmd/devicemesh-mcp/` is the cmd dir itself).
|
|
candidates := []string{
|
|
filepath.Join("..", "..", "bin", "devicemesh-mcp"),
|
|
filepath.Join("bin", "devicemesh-mcp"),
|
|
}
|
|
for _, c := range candidates {
|
|
if abs, err := filepath.Abs(c); err == nil {
|
|
if st, err := os.Stat(abs); err == nil && !st.IsDir() {
|
|
return abs
|
|
}
|
|
}
|
|
}
|
|
// Build into a tmpdir.
|
|
tmpDir := t.TempDir()
|
|
out := filepath.Join(tmpDir, "devicemesh-mcp")
|
|
cmd := exec.Command("/usr/local/go/bin/go", "build", "-tags", "goolm", "-o", out, ".")
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
t.Logf("build failed: %v", err)
|
|
return ""
|
|
}
|
|
return out
|
|
}
|