From 138bbb124227bc6a90e081cbfc025d71436f3f79 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 8 Mar 2026 17:44:50 +0800 Subject: [PATCH] test(mcp): add ClaudeClient wire format tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover all Anthropic-specific format conversions: - system prompt lifted to top-level field - tools use input_schema (not parameters) - tool_choice is object {type:auto} not string - assistant tool calls → content[{type:tool_use}] - consecutive tool results merged into single user turn - parseMCPResponseFull: text, tool_use, and error cases - x-api-key header (not Authorization: Bearer) - /messages endpoint URL --- mcp/claude_client_test.go | 248 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 mcp/claude_client_test.go diff --git a/mcp/claude_client_test.go b/mcp/claude_client_test.go new file mode 100644 index 00000000..268c2849 --- /dev/null +++ b/mcp/claude_client_test.go @@ -0,0 +1,248 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" +) + +// ── buildRequestBodyFromRequest ──────────────────────────────────────────────── + +func TestClaudeClient_BuildRequestBody_SystemPromptLifted(t *testing.T) { + c := newTestClaudeClient() + req := &Request{ + Model: "claude-opus-4-6", + Messages: []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + }, + } + body := c.buildRequestBodyFromRequest(req) + + if body["system"] != "You are helpful." { + t.Errorf("system not lifted to top level: %v", body["system"]) + } + msgs := body["messages"].([]map[string]any) + if len(msgs) != 1 || msgs[0]["role"] != "user" { + t.Errorf("system message should be removed from messages array: %v", msgs) + } +} + +func TestClaudeClient_BuildRequestBody_ToolsUseInputSchema(t *testing.T) { + c := newTestClaudeClient() + req := &Request{ + Model: "claude-opus-4-6", + Messages: []Message{{Role: "user", Content: "hi"}}, + Tools: []Tool{{ + Type: "function", + Function: FunctionDef{ + Name: "my_tool", + Description: "does stuff", + Parameters: map[string]any{"type": "object"}, + }, + }}, + } + body := c.buildRequestBodyFromRequest(req) + + tools, ok := body["tools"].([]map[string]any) + if !ok || len(tools) != 1 { + t.Fatalf("tools not set correctly: %v", body["tools"]) + } + tool := tools[0] + if tool["name"] != "my_tool" { + t.Errorf("tool name wrong: %v", tool["name"]) + } + if tool["input_schema"] == nil { + t.Error("tool must use input_schema, not parameters") + } + if _, hasParams := tool["parameters"]; hasParams { + t.Error("tool must NOT have parameters key (Anthropic uses input_schema)") + } +} + +func TestClaudeClient_BuildRequestBody_ToolChoiceObject(t *testing.T) { + c := newTestClaudeClient() + req := &Request{ + Model: "claude-opus-4-6", + Messages: []Message{{Role: "user", Content: "hi"}}, + ToolChoice: "auto", + } + body := c.buildRequestBodyFromRequest(req) + + tc, ok := body["tool_choice"].(map[string]any) + if !ok { + t.Fatalf("tool_choice must be an object, got: %T %v", body["tool_choice"], body["tool_choice"]) + } + if tc["type"] != "auto" { + t.Errorf("tool_choice.type must be 'auto', got: %v", tc["type"]) + } +} + +// ── convertMessagesToAnthropic ───────────────────────────────────────────────── + +func TestConvertMessages_AssistantToolCall(t *testing.T) { + msgs := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "tc1", + Type: "function", + Function: ToolCallFunction{Name: "api_request", Arguments: `{"method":"GET","path":"/api/x","body":{}}`}, + }}, + }, + } + out := convertMessagesToAnthropic(msgs) + + if len(out) != 1 { + t.Fatalf("expected 1 message, got %d", len(out)) + } + msg := out[0] + if msg["role"] != "assistant" { + t.Errorf("role should be assistant: %v", msg["role"]) + } + blocks := msg["content"].([]map[string]any) + if len(blocks) != 1 || blocks[0]["type"] != "tool_use" { + t.Errorf("content should be tool_use block: %v", blocks) + } + if blocks[0]["id"] != "tc1" { + t.Errorf("tool_use id wrong: %v", blocks[0]["id"]) + } + // Input must be parsed JSON object, not a string. + input, ok := blocks[0]["input"].(map[string]any) + if !ok { + t.Errorf("tool_use input must be map, got %T", blocks[0]["input"]) + } + if input["method"] != "GET" { + t.Errorf("input.method wrong: %v", input) + } +} + +func TestConvertMessages_ToolResultMergedIntoUserTurn(t *testing.T) { + // Anthropic requires strictly alternating turns; consecutive tool results + // must be merged into a single user message. + msgs := []Message{ + {Role: "tool", ToolCallID: "tc1", Content: `{"result":"a"}`}, + {Role: "tool", ToolCallID: "tc2", Content: `{"result":"b"}`}, + } + out := convertMessagesToAnthropic(msgs) + + if len(out) != 1 { + t.Fatalf("consecutive tool results must be merged into one user turn, got %d messages", len(out)) + } + if out[0]["role"] != "user" { + t.Errorf("tool results must become role=user: %v", out[0]["role"]) + } + blocks := out[0]["content"].([]map[string]any) + if len(blocks) != 2 { + t.Errorf("expected 2 tool_result blocks, got %d", len(blocks)) + } + if blocks[0]["type"] != "tool_result" || blocks[1]["type"] != "tool_result" { + t.Errorf("blocks should be tool_result: %v", blocks) + } + if blocks[0]["tool_use_id"] != "tc1" || blocks[1]["tool_use_id"] != "tc2" { + t.Errorf("tool_use_id mismatch: %v", blocks) + } +} + +// ── parseMCPResponseFull ─────────────────────────────────────────────────────── + +func TestClaudeClient_ParseResponse_TextOnly(t *testing.T) { + c := newTestClaudeClient() + body := []byte(`{ + "content": [{"type":"text","text":"Hello from Claude"}], + "usage": {"input_tokens": 10, "output_tokens": 5} + }`) + resp, err := c.parseMCPResponseFull(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Content != "Hello from Claude" { + t.Errorf("content mismatch: %q", resp.Content) + } + if len(resp.ToolCalls) != 0 { + t.Errorf("expected no tool calls: %v", resp.ToolCalls) + } +} + +func TestClaudeClient_ParseResponse_ToolUse(t *testing.T) { + c := newTestClaudeClient() + body := []byte(`{ + "content": [{ + "type": "tool_use", + "id": "toolu_01abc", + "name": "api_request", + "input": {"method":"POST","path":"/api/strategies","body":{"name":"BTC策略"}} + }], + "usage": {"input_tokens": 100, "output_tokens": 30} + }`) + resp, err := c.parseMCPResponseFull(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "toolu_01abc" { + t.Errorf("tool call ID wrong: %v", tc.ID) + } + if tc.Function.Name != "api_request" { + t.Errorf("function name wrong: %v", tc.Function.Name) + } + // Arguments must be a valid JSON string. + var args map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + t.Errorf("arguments not valid JSON: %q — %v", tc.Function.Arguments, err) + } + if args["method"] != "POST" { + t.Errorf("args.method wrong: %v", args) + } +} + +func TestClaudeClient_ParseResponse_APIError(t *testing.T) { + c := newTestClaudeClient() + body := []byte(`{"error":{"type":"authentication_error","message":"invalid x-api-key"}}`) + _, err := c.parseMCPResponseFull(body) + if err == nil { + t.Fatal("expected error for API error response") + } + if err.Error() == "" { + t.Error("error message should not be empty") + } +} + +// ── Auth header ──────────────────────────────────────────────────────────────── + +func TestClaudeClient_SetAuthHeader(t *testing.T) { + c := newTestClaudeClient() + c.APIKey = "sk-ant-test123" + + // net/http.Header canonicalizes keys (x-api-key → X-Api-Key). + h := make(http.Header) + c.setAuthHeader(h) + + if got := h.Get("x-api-key"); got != "sk-ant-test123" { + t.Errorf("x-api-key header not set correctly: %q", got) + } + if h.Get("anthropic-version") == "" { + t.Error("anthropic-version header must be set") + } + // Must NOT use Authorization: Bearer (that's OpenAI format). + if h.Get("Authorization") != "" { + t.Error("Claude must use x-api-key, not Authorization header") + } +} + +func TestClaudeClient_BuildUrl(t *testing.T) { + c := newTestClaudeClient() + url := c.buildUrl() + if url != DefaultClaudeBaseURL+"/messages" { + t.Errorf("URL should be /messages endpoint, got: %s", url) + } +} + +// ── helpers ──────────────────────────────────────────────────────────────────── + +func newTestClaudeClient() *ClaudeClient { + return NewClaudeClientWithOptions().(*ClaudeClient) +}