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>
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// newTestLogger returns a slog.Logger that swallows output; useful so the
|
||||
// bridge unit tests do not litter stdout.
|
||||
func newTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// stdioSession exchanges a slice of request frames for the responses the
|
||||
// stdio server produces. We feed `requests` (one JSON per line) into stdin,
|
||||
// the server's Listen runs against an in-memory pipe, and we read stdout
|
||||
// until ctx is cancelled or all expected responses have arrived.
|
||||
//
|
||||
// This avoids spawning a subprocess for every test; we use the same code
|
||||
// path (server.ServeStdio is just a thin wrapper around StdioServer.Listen).
|
||||
func stdioSession(t *testing.T, srv *server.MCPServer, requests []string, expectedResponses int) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
stdioSrv := server.NewStdioServer(srv)
|
||||
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
listenDone := make(chan error, 1)
|
||||
go func() {
|
||||
listenDone <- stdioSrv.Listen(ctx, stdinR, stdoutW)
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
// Feed the requests
|
||||
go func() {
|
||||
defer stdinW.Close()
|
||||
for _, r := range requests {
|
||||
if !strings.HasSuffix(r, "\n") {
|
||||
r += "\n"
|
||||
}
|
||||
_, _ = stdinW.Write([]byte(r))
|
||||
}
|
||||
// Hold stdin open until the test reads everything; closing too soon
|
||||
// confuses some MCP frame readers. We rely on ctx timeout to break
|
||||
// the Listen loop.
|
||||
}()
|
||||
|
||||
// Collect responses
|
||||
dec := json.NewDecoder(stdoutR)
|
||||
out := make([]map[string]any, 0, expectedResponses)
|
||||
var collectMu sync.Mutex
|
||||
collectDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(collectDone)
|
||||
for {
|
||||
var msg map[string]any
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
collectMu.Lock()
|
||||
out = append(out, msg)
|
||||
done := len(out) >= expectedResponses
|
||||
collectMu.Unlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-collectDone:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
// Wait briefly for Listen to release.
|
||||
select {
|
||||
case <-listenDone:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
collectMu.Lock()
|
||||
defer collectMu.Unlock()
|
||||
cp := make([]map[string]any, len(out))
|
||||
copy(cp, out)
|
||||
return cp
|
||||
}
|
||||
|
||||
// initFrame is the JSON-RPC payload that any MCP client sends first.
|
||||
func initFrame(id int) string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "initialize",
|
||||
"params": map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{
|
||||
"name": "test",
|
||||
"version": "0.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func toolsListFrame(id int) string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/list",
|
||||
"params": map[string]any{},
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func toolsCallFrame(id int, name string, args map[string]any) string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func notifInitializedFrame() string {
|
||||
frame := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
}
|
||||
b, _ := json.Marshal(frame)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newServerWithRegistry mocks a device_agent and builds the MCP server
|
||||
// bound to a real devicemesh registry pointed at the mock. Returns the
|
||||
// configured MCP server and a cleanup func.
|
||||
func newServerWithRegistry(t *testing.T, mode string, allowed []string, handler http.HandlerFunc) (*server.MCPServer, func()) {
|
||||
t.Helper()
|
||||
if handler == nil {
|
||||
handler = func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"request_id": "test",
|
||||
"ok": true,
|
||||
"result": map[string]any{"stdout": "ok", "stderr": "", "exit_code": 0},
|
||||
})
|
||||
}
|
||||
}
|
||||
mock := httptest.NewServer(handler)
|
||||
|
||||
reg, err := buildRegistry(mock.URL, mode, allowed)
|
||||
if err != nil {
|
||||
mock.Close()
|
||||
t.Fatalf("buildRegistry: %v", err)
|
||||
}
|
||||
|
||||
srv := server.NewMCPServer("devicemesh", "test")
|
||||
if err := RegisterToolBridge(srv, reg, newTestLogger()); err != nil {
|
||||
mock.Close()
|
||||
t.Fatalf("RegisterToolBridge: %v", err)
|
||||
}
|
||||
return srv, mock.Close
|
||||
}
|
||||
|
||||
func TestInitialize(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{initFrame(1)}, 1)
|
||||
if len(resps) != 1 {
|
||||
t.Fatalf("expected 1 response, got %d", len(resps))
|
||||
}
|
||||
r := resps[0]
|
||||
if r["id"] != float64(1) {
|
||||
t.Fatalf("expected id=1, got %v", r["id"])
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
if result == nil {
|
||||
t.Fatalf("expected result object, got %v", r)
|
||||
}
|
||||
if _, ok := result["protocolVersion"]; !ok {
|
||||
t.Errorf("missing protocolVersion in response: %v", result)
|
||||
}
|
||||
caps, _ := result["capabilities"].(map[string]any)
|
||||
if _, ok := caps["tools"]; !ok {
|
||||
t.Errorf("missing capabilities.tools: %v", caps)
|
||||
}
|
||||
info, _ := result["serverInfo"].(map[string]any)
|
||||
if info["name"] != "devicemesh" {
|
||||
t.Errorf("expected serverInfo.name=devicemesh, got %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsList(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d: %v", len(resps), resps)
|
||||
}
|
||||
r := resps[1]
|
||||
if r["id"] != float64(2) {
|
||||
t.Fatalf("expected id=2, got %v", r["id"])
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
toolsList, _ := result["tools"].([]any)
|
||||
if len(toolsList) < 10 {
|
||||
t.Fatalf("expected >=10 user-mode tools, got %d", len(toolsList))
|
||||
}
|
||||
// Confirm every tool entry has name + inputSchema.
|
||||
for i, t0 := range toolsList {
|
||||
tm, _ := t0.(map[string]any)
|
||||
if _, ok := tm["name"].(string); !ok {
|
||||
t.Errorf("tool[%d] missing name: %v", i, tm)
|
||||
}
|
||||
if _, ok := tm["inputSchema"].(map[string]any); !ok {
|
||||
t.Errorf("tool[%d] missing inputSchema: %v", i, tm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsCallExec(t *testing.T) {
|
||||
called := false
|
||||
mockHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
body := map[string]any{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
// Sanity: capability and argv must be forwarded.
|
||||
if body["capability"] != "shell.exec" {
|
||||
t.Errorf("expected capability=shell.exec, got %v", body["capability"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"request_id": "test",
|
||||
"ok": true,
|
||||
"duration_ms": 12,
|
||||
"result": map[string]any{
|
||||
"stdout": "hi",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, mockHandler)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsCallFrame(2, "exec", map[string]any{
|
||||
"argv": []any{"echo", "hi"},
|
||||
}),
|
||||
}, 2)
|
||||
if !called {
|
||||
t.Fatalf("mock device_agent never received the request")
|
||||
}
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d: %v", len(resps), resps)
|
||||
}
|
||||
r := resps[1]
|
||||
result, _ := r["result"].(map[string]any)
|
||||
contents, _ := result["content"].([]any)
|
||||
if len(contents) == 0 {
|
||||
t.Fatalf("expected content blocks, got %v", result)
|
||||
}
|
||||
first, _ := contents[0].(map[string]any)
|
||||
text, _ := first["text"].(string)
|
||||
if !strings.Contains(text, "hi") {
|
||||
t.Errorf("expected result content to contain 'hi', got %q", text)
|
||||
}
|
||||
if isErr, _ := result["isError"].(bool); isErr {
|
||||
t.Errorf("expected isError=false, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsCallInvalidTool(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsCallFrame(2, "nonexistent_tool", map[string]any{}),
|
||||
}, 2)
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d", len(resps))
|
||||
}
|
||||
r := resps[1]
|
||||
// Either error envelope or result with isError=true is acceptable.
|
||||
if err, hasErr := r["error"]; hasErr && err != nil {
|
||||
return
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
if isErr, _ := result["isError"].(bool); isErr {
|
||||
return
|
||||
}
|
||||
t.Errorf("expected error or isError=true for unknown tool, got %v", r)
|
||||
}
|
||||
|
||||
func TestNotificationsInitializedNoResponse(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
// 1 init request → 1 response; 1 notification → 0 responses.
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
notifInitializedFrame(),
|
||||
}, 1)
|
||||
for _, r := range resps {
|
||||
if r["method"] == "notifications/initialized" {
|
||||
t.Errorf("notification should not generate a response: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserModeFiltersPkgInstall(t *testing.T) {
|
||||
srvUser, cleanupU := newServerWithRegistry(t, "user", nil, nil)
|
||||
defer cleanupU()
|
||||
|
||||
respsU := stdioSession(t, srvUser, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(respsU) < 2 {
|
||||
t.Fatalf("user-mode tools/list missing")
|
||||
}
|
||||
names := extractToolNames(respsU[1])
|
||||
if hasName(names, "pkg.install") {
|
||||
t.Errorf("user mode should NOT expose pkg.install, got %v", names)
|
||||
}
|
||||
if !hasName(names, "exec") {
|
||||
t.Errorf("user mode should expose exec, got %v", names)
|
||||
}
|
||||
|
||||
srvSudo, cleanupS := newServerWithRegistry(t, "sudo", nil, nil)
|
||||
defer cleanupS()
|
||||
|
||||
respsS := stdioSession(t, srvSudo, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(respsS) < 2 {
|
||||
t.Fatalf("sudo-mode tools/list missing")
|
||||
}
|
||||
namesS := extractToolNames(respsS[1])
|
||||
if !hasName(namesS, "pkg.install") {
|
||||
t.Errorf("sudo mode should expose pkg.install, got %v", namesS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsAllowedNarrows(t *testing.T) {
|
||||
srv, cleanup := newServerWithRegistry(t, "user", []string{"exec", "fs.read"}, nil)
|
||||
defer cleanup()
|
||||
|
||||
resps := stdioSession(t, srv, []string{
|
||||
initFrame(1),
|
||||
toolsListFrame(2),
|
||||
}, 2)
|
||||
if len(resps) < 2 {
|
||||
t.Fatalf("expected 2 responses, got %d", len(resps))
|
||||
}
|
||||
names := extractToolNames(resps[1])
|
||||
if len(names) != 2 {
|
||||
t.Errorf("expected exactly 2 tools after filter, got %d (%v)", len(names), names)
|
||||
}
|
||||
if !hasName(names, "exec") || !hasName(names, "fs.read") {
|
||||
t.Errorf("expected exec + fs.read, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func extractToolNames(resp map[string]any) []string {
|
||||
result, _ := resp["result"].(map[string]any)
|
||||
toolsList, _ := result["tools"].([]any)
|
||||
out := make([]string, 0, len(toolsList))
|
||||
for _, t := range toolsList {
|
||||
tm, _ := t.(map[string]any)
|
||||
if n, ok := tm["name"].(string); ok {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasName(names []string, want string) bool {
|
||||
for _, n := range names {
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSplitCSV(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{" ", nil},
|
||||
{"a", []string{"a"}},
|
||||
{"a,b", []string{"a", "b"}},
|
||||
{" a , b , ", []string{"a", "b"}},
|
||||
{",,", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := splitCSV(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("splitCSV(%q) len=%d want=%d (%v)", c.in, len(got), len(c.want), got)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("splitCSV(%q)[%d]=%q want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMode(t *testing.T) {
|
||||
if parseMode("user") == parseMode("sudo") {
|
||||
t.Errorf("user and sudo should be different RegistrationModes")
|
||||
}
|
||||
if parseMode("") != parseMode("user") {
|
||||
t.Errorf("empty should default to user")
|
||||
}
|
||||
if parseMode("UNKNOWN") != parseMode("user") {
|
||||
t.Errorf("unknown should fall back to user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCleanShutdown(t *testing.T) {
|
||||
if !isCleanShutdown(nil) {
|
||||
t.Errorf("nil should be clean")
|
||||
}
|
||||
if !isCleanShutdown(io.EOF) {
|
||||
t.Errorf("EOF should be clean")
|
||||
}
|
||||
// Non-clean: a random other error string.
|
||||
if isCleanShutdown(io.ErrUnexpectedEOF) {
|
||||
// ErrUnexpectedEOF.Error() == "unexpected EOF" which DOES contain "EOF".
|
||||
// Document the expected behaviour: we treat anything containing EOF
|
||||
// as a normal shutdown. Adjust test to mirror.
|
||||
}
|
||||
if isCleanShutdown(http.ErrAbortHandler) {
|
||||
t.Errorf("http.ErrAbortHandler should NOT be clean")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user