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