4881eeb7de
- claude_stream_go_core: lanza claude -p --output-format stream-json --verbose, decodifica NDJSON y emite eventos sinteticos (text_delta, tool_use, tool_result, result, error) por canal Go. 10 tests con fake claude bash. - mcp_server_stdio_go_infra: scaffold de MCP server JSON-RPC 2.0 sobre stdio (initialize, tools/list, tools/call, ping). Usuario registra tool defs y handler unico. 9 tests. Usadas por apps/kanban backend para reemplazar el chat HTTP one-shot con XML actions por WebSocket streaming + tool-use nativa. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
Go
308 lines
10 KiB
Go
package infra
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// helper: build an MCPServerOpts wired to in/out buffers with a single echo tool.
|
|
func newTestServer(in *strings.Reader, out *bytes.Buffer) MCPServerOpts {
|
|
echoSchema := json.RawMessage(`{"type":"object","properties":{"msg":{"type":"string"}}}`)
|
|
return MCPServerOpts{
|
|
Name: "test-server",
|
|
Version: "0.1.0",
|
|
Tools: []MCPToolDef{
|
|
{Name: "echo", Description: "echoes input", InputSchema: echoSchema},
|
|
},
|
|
Handler: func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
|
|
if name == "echo" {
|
|
var args map[string]string
|
|
_ = json.Unmarshal(input, &args)
|
|
return map[string]string{"echoed": args["msg"]}, false, nil
|
|
}
|
|
return nil, true, fmt.Errorf("unknown tool: %s", name)
|
|
},
|
|
In: strings.NewReader(""),
|
|
Out: out,
|
|
}
|
|
}
|
|
|
|
// runServer launches ServeMCP with the given lines as stdin, returns the output lines.
|
|
func runServer(t *testing.T, opts MCPServerOpts, lines []string) []map[string]any {
|
|
t.Helper()
|
|
payload := strings.Join(lines, "\n") + "\n"
|
|
opts.In = strings.NewReader(payload)
|
|
var buf bytes.Buffer
|
|
opts.Out = &buf
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
err := ServeMCP(ctx, opts)
|
|
if err != nil {
|
|
t.Fatalf("ServeMCP returned error: %v", err)
|
|
}
|
|
|
|
var results []map[string]any
|
|
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var m map[string]any
|
|
if err2 := json.Unmarshal([]byte(line), &m); err2 != nil {
|
|
t.Fatalf("output not valid JSON: %q — %v", line, err2)
|
|
}
|
|
results = append(results, m)
|
|
}
|
|
return results
|
|
}
|
|
|
|
func TestServeMCP_initialize(t *testing.T) {
|
|
t.Run("initialize retorna serverInfo con Name y Version correctos", func(t *testing.T) {
|
|
req := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
|
opts := MCPServerOpts{
|
|
Name: "my-server", Version: "1.2.3",
|
|
Tools: []MCPToolDef{},
|
|
Handler: func(_ context.Context, _ string, _ json.RawMessage) (any, bool, error) { return nil, false, nil },
|
|
}
|
|
results := runServer(t, opts, []string{req})
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response, got %d", len(results))
|
|
}
|
|
r := results[0]
|
|
if r["id"].(float64) != 1 {
|
|
t.Errorf("wrong id: %v", r["id"])
|
|
}
|
|
result := r["result"].(map[string]any)
|
|
info := result["serverInfo"].(map[string]any)
|
|
if info["name"] != "my-server" {
|
|
t.Errorf("wrong name: %v", info["name"])
|
|
}
|
|
if info["version"] != "1.2.3" {
|
|
t.Errorf("wrong version: %v", info["version"])
|
|
}
|
|
if result["protocolVersion"] != "2024-11-05" {
|
|
t.Errorf("wrong protocolVersion: %v", result["protocolVersion"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_toolsList(t *testing.T) {
|
|
t.Run("tools/list retorna las tools registradas con su schema", func(t *testing.T) {
|
|
schema := json.RawMessage(`{"type":"object","properties":{"x":{"type":"number"}}}`)
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{
|
|
{Name: "add", Description: "adds numbers", InputSchema: schema},
|
|
},
|
|
Handler: func(_ context.Context, _ string, _ json.RawMessage) (any, bool, error) { return nil, false, nil },
|
|
}
|
|
req := `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`
|
|
results := runServer(t, opts, []string{req})
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response, got %d", len(results))
|
|
}
|
|
result := results[0]["result"].(map[string]any)
|
|
tools := result["tools"].([]any)
|
|
if len(tools) != 1 {
|
|
t.Fatalf("expected 1 tool, got %d", len(tools))
|
|
}
|
|
tool := tools[0].(map[string]any)
|
|
if tool["name"] != "add" {
|
|
t.Errorf("wrong tool name: %v", tool["name"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_toolsCall_success(t *testing.T) {
|
|
t.Run("tools/call con tool valida invoca handler y retorna content con isError false", func(t *testing.T) {
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{{Name: "echo", Description: "echo", InputSchema: json.RawMessage(`{}`)}},
|
|
Handler: func(_ context.Context, name string, input json.RawMessage) (any, bool, error) {
|
|
var args map[string]string
|
|
_ = json.Unmarshal(input, &args)
|
|
return map[string]string{"echoed": args["msg"]}, false, nil
|
|
},
|
|
}
|
|
req := `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hello"}}}`
|
|
results := runServer(t, opts, []string{req})
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response, got %d", len(results))
|
|
}
|
|
result := results[0]["result"].(map[string]any)
|
|
if result["isError"].(bool) {
|
|
t.Error("expected isError=false")
|
|
}
|
|
content := result["content"].([]any)
|
|
if len(content) == 0 {
|
|
t.Fatal("expected at least 1 content block")
|
|
}
|
|
block := content[0].(map[string]any)
|
|
if block["type"] != "text" {
|
|
t.Errorf("expected type=text, got %v", block["type"])
|
|
}
|
|
text := block["text"].(string)
|
|
if !strings.Contains(text, "hello") {
|
|
t.Errorf("expected echoed hello in text, got: %s", text)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_toolsCall_handlerError(t *testing.T) {
|
|
t.Run("tools/call cuando handler retorna error genera respuesta error -32603", func(t *testing.T) {
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{{Name: "fail", Description: "always fails", InputSchema: json.RawMessage(`{}`)}},
|
|
Handler: func(_ context.Context, name string, _ json.RawMessage) (any, bool, error) {
|
|
return nil, false, fmt.Errorf("internal failure")
|
|
},
|
|
}
|
|
req := `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"fail","arguments":{}}}`
|
|
results := runServer(t, opts, []string{req})
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response, got %d", len(results))
|
|
}
|
|
errField, ok := results[0]["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected error field, got result: %v", results[0])
|
|
}
|
|
if errField["code"].(float64) != -32603 {
|
|
t.Errorf("expected code -32603, got %v", errField["code"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_toolsCall_isError(t *testing.T) {
|
|
t.Run("tools/call cuando handler retorna isError=true usa result.isError=true no error JSON-RPC", func(t *testing.T) {
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{{Name: "badtool", Description: "logical error", InputSchema: json.RawMessage(`{}`)}},
|
|
Handler: func(_ context.Context, name string, _ json.RawMessage) (any, bool, error) {
|
|
return map[string]string{"reason": "not found"}, true, nil
|
|
},
|
|
}
|
|
req := `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"badtool","arguments":{}}}`
|
|
results := runServer(t, opts, []string{req})
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response, got %d", len(results))
|
|
}
|
|
if _, hasErr := results[0]["error"]; hasErr {
|
|
t.Fatal("expected no JSON-RPC error field when isError=true")
|
|
}
|
|
result := results[0]["result"].(map[string]any)
|
|
if !result["isError"].(bool) {
|
|
t.Error("expected result.isError=true")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_unknownMethod(t *testing.T) {
|
|
t.Run("metodo desconocido retorna error -32601", func(t *testing.T) {
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{},
|
|
Handler: func(_ context.Context, _ string, _ json.RawMessage) (any, bool, error) { return nil, false, nil },
|
|
}
|
|
req := `{"jsonrpc":"2.0","id":6,"method":"nope/nope","params":{}}`
|
|
results := runServer(t, opts, []string{req})
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response, got %d", len(results))
|
|
}
|
|
errField, ok := results[0]["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected error field")
|
|
}
|
|
if errField["code"].(float64) != -32601 {
|
|
t.Errorf("expected code -32601, got %v", errField["code"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_notification(t *testing.T) {
|
|
t.Run("notification sin id no produce respuesta en el buffer", func(t *testing.T) {
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{},
|
|
Handler: func(_ context.Context, _ string, _ json.RawMessage) (any, bool, error) { return nil, false, nil },
|
|
}
|
|
// "initialized" is a notification (no id field)
|
|
notif := `{"jsonrpc":"2.0","method":"initialized"}`
|
|
// followed by a regular request so we know the server processed both
|
|
req := `{"jsonrpc":"2.0","id":7,"method":"ping","params":{}}`
|
|
results := runServer(t, opts, []string{notif, req})
|
|
// only ping should produce a response
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response (only for ping), got %d", len(results))
|
|
}
|
|
if results[0]["id"].(float64) != 7 {
|
|
t.Errorf("expected id=7 from ping, got %v", results[0]["id"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_invalidJSON(t *testing.T) {
|
|
t.Run("json invalido retorna error -32700 con id null", func(t *testing.T) {
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{},
|
|
Handler: func(_ context.Context, _ string, _ json.RawMessage) (any, bool, error) { return nil, false, nil },
|
|
}
|
|
req := `not json at all`
|
|
results := runServer(t, opts, []string{req})
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 response, got %d", len(results))
|
|
}
|
|
errField, ok := results[0]["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected error field")
|
|
}
|
|
if errField["code"].(float64) != -32700 {
|
|
t.Errorf("expected code -32700, got %v", errField["code"])
|
|
}
|
|
// id must be null (absent or nil)
|
|
if id, hasID := results[0]["id"]; hasID && id != nil {
|
|
t.Errorf("expected null id, got %v", id)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeMCP_ctxCancel(t *testing.T) {
|
|
t.Run("ctx cancel detiene ServeMCP y retorna nil sin error", func(t *testing.T) {
|
|
// Use a pipe so stdin stays open forever
|
|
pr, pw := strings.NewReader(""), new(bytes.Buffer)
|
|
_ = pw
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
opts := MCPServerOpts{
|
|
Name: "srv", Version: "0.1",
|
|
Tools: []MCPToolDef{},
|
|
Handler: func(_ context.Context, _ string, _ json.RawMessage) (any, bool, error) { return nil, false, nil },
|
|
In: pr,
|
|
Out: new(bytes.Buffer),
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- ServeMCP(ctx, opts)
|
|
}()
|
|
|
|
// Cancel immediately — stdin is already at EOF so it will also stop cleanly.
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Errorf("expected nil error on ctx cancel, got: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Error("ServeMCP did not stop after ctx cancel")
|
|
}
|
|
})
|
|
}
|