Files
patterm/internal/mcp/protocol_test.go
2026-05-29 13:16:05 +01:00

180 lines
5.5 KiB
Go

package mcp
import (
"encoding/json"
"strings"
"testing"
)
func TestInitializeReturnsCapabilities(t *testing.T) {
s := &Server{}
req := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}`)
resp := s.dispatch("", req)
if resp == nil {
t.Fatal("expected response for initialize")
}
var parsed struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result map[string]interface{} `json:"result"`
Error *struct {
Code int `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(resp, &parsed); err != nil {
t.Fatalf("parse: %v\n%s", err, resp)
}
if parsed.Error != nil {
t.Fatalf("initialize returned error: %+v", parsed.Error)
}
if parsed.Result["protocolVersion"] == nil {
t.Fatalf("missing protocolVersion: %+v", parsed.Result)
}
caps, ok := parsed.Result["capabilities"].(map[string]interface{})
if !ok {
t.Fatalf("capabilities not object: %+v", parsed.Result)
}
if caps["tools"] == nil {
t.Fatalf("tools capability missing: %+v", caps)
}
// patterm-specific orientation: clients show this to the underlying
// LLM, so it's our primary hook for steering vendor TUIs (codex in
// particular) toward the MCP tool surface instead of shell-ing out.
instructions, ok := parsed.Result["instructions"].(string)
if !ok || instructions == "" {
t.Fatalf("instructions missing or wrong type: %+v", parsed.Result)
}
if len(instructions) > 320 {
t.Fatalf("instructions too verbose: %d chars", len(instructions))
}
}
func TestInitializedNotificationSuppressesResponse(t *testing.T) {
s := &Server{}
req := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)
resp := s.dispatch("", req)
if resp != nil {
t.Fatalf("notification produced a response: %s", resp)
}
}
func TestToolsListReturnsConcreteSchemas(t *testing.T) {
s := &Server{}
req := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)
resp := s.dispatch("", req)
if resp == nil {
t.Fatal("expected response for tools/list")
}
var parsed struct {
Result map[string]interface{} `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(resp, &parsed); err != nil {
t.Fatalf("parse: %v\n%s", err, resp)
}
if parsed.Error != nil {
t.Fatalf("tools/list returned error: %+v", parsed.Error)
}
if len(resp) > 12000 {
t.Fatalf("tools/list response too large: %d bytes", len(resp))
}
tools, ok := parsed.Result["tools"].([]interface{})
if !ok {
t.Fatalf("tools not array: %+v", parsed.Result)
}
if len(tools) == 0 {
t.Fatalf("expected at least one tool, got 0")
}
// Every tool must have name, description, and inputSchema with
// `type=object` and a concrete `properties` object — `properties:
// null` trips up strict MCP clients (claude in particular).
for i, tool := range tools {
entry, ok := tool.(map[string]interface{})
if !ok {
t.Fatalf("tool %d not object: %#v", i, tool)
}
if entry["name"] == "" || entry["name"] == nil {
t.Fatalf("tool %d missing name: %#v", i, entry)
}
if entry["description"] == "" || entry["description"] == nil {
t.Fatalf("tool %d missing description: %#v", i, entry)
}
schema, ok := entry["inputSchema"].(map[string]interface{})
if !ok {
t.Fatalf("tool %d inputSchema not object: %#v", i, entry)
}
if schema["type"] != "object" {
t.Fatalf("tool %d schema type != object: %#v", i, schema)
}
props, ok := schema["properties"]
if !ok {
t.Fatalf("tool %s missing properties", entry["name"])
}
if _, ok := props.(map[string]interface{}); !ok {
t.Fatalf("tool %s properties not object (got %T): %#v", entry["name"], props, props)
}
}
}
func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) {
result := ProcessOutput{
Content: strings.Repeat("x", 1024),
Mode: "stream",
NewOffset: 2048,
ContentBytes: 1024,
}
wrapped := wrapToolResult(result)
if wrapped["structuredContent"] == nil {
t.Fatalf("structuredContent missing: %#v", wrapped)
}
content := wrapped["content"].([]map[string]any)
text := content[0]["text"].(string)
if strings.Contains(text, result.Content) {
t.Fatalf("content duplicated structured payload: %q", text)
}
if !strings.Contains(text, "stream output") {
t.Fatalf("summary text should identify output, got %q", text)
}
}
func TestPingReturnsEmptyObject(t *testing.T) {
s := &Server{}
req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`)
resp := s.dispatch("", req)
if resp == nil {
t.Fatal("expected response for ping")
}
var parsed struct {
Result map[string]interface{} `json:"result"`
Error *struct{ Code int } `json:"error"`
}
if err := json.Unmarshal(resp, &parsed); err != nil {
t.Fatalf("parse: %v\n%s", err, resp)
}
if parsed.Error != nil {
t.Fatalf("ping returned error: %+v", parsed.Error)
}
if parsed.Result == nil {
t.Fatal("ping result missing")
}
}
func TestTypedInvalidArgsMapToInvalidParams(t *testing.T) {
for _, errKind := range []string{ErrorKindInvalidArgs, ErrorKindInvalidKind} {
_, code, msg, data := mapToolError(Errorf(errKind, "bad args"))
if code != codeInvalidParams {
t.Fatalf("%s code = %d, want %d", errKind, code, codeInvalidParams)
}
if msg != "bad args" {
t.Fatalf("%s message = %q", errKind, msg)
}
kind, ok := data.(map[string]string)
if !ok || kind["kind"] != errKind {
t.Fatalf("%s data = %#v", errKind, data)
}
}
}