diff --git a/mcp/claude_client.go b/mcp/claude_client.go index 57f3b891..d91bdb81 100644 --- a/mcp/claude_client.go +++ b/mcp/claude_client.go @@ -1,3 +1,19 @@ +// Package mcp — ClaudeClient implements the Anthropic Messages API. +// +// Wire-format differences from the OpenAI-compatible base Client: +// +// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐ +// │ Concept │ OpenAI format │ Anthropic format │ +// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤ +// │ Endpoint │ /v1/chat/completions │ /v1/messages │ +// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │ +// │ System prompt │ messages[0] role=system │ top-level "system" field │ +// │ Tool definition │ type=function + parameters │ name + description + input_schema│ +// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │ +// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │ +// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │ +// │ Max tokens │ max_tokens │ max_tokens (same) │ +// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘ package mcp import ( @@ -12,75 +28,64 @@ const ( DefaultClaudeModel = "claude-opus-4-6" ) +// ClaudeClient wraps the base Client and overrides the methods that differ +// for the Anthropic Messages API. All other behaviour (retry, timeout, +// logging) is inherited unchanged. type ClaudeClient struct { *Client } -// NewClaudeClient creates Claude client (backward compatible) +// NewClaudeClient creates a ClaudeClient with default settings. func NewClaudeClient() AIClient { return NewClaudeClientWithOptions() } -// NewClaudeClientWithOptions creates Claude client (supports options pattern) +// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides. func NewClaudeClientWithOptions(opts ...ClientOption) AIClient { - // 1. Create Claude preset options - claudeOpts := []ClientOption{ + baseClient := NewClient(append([]ClientOption{ WithProvider(ProviderClaude), WithModel(DefaultClaudeModel), WithBaseURL(DefaultClaudeBaseURL), - } + }, opts...)...).(*Client) - // 2. Merge user options (user options have higher priority) - allOpts := append(claudeOpts, opts...) - - // 3. Create base client - baseClient := NewClient(allOpts...).(*Client) - - // 4. Create Claude client - claudeClient := &ClaudeClient{ - Client: baseClient, - } - - // 5. Set hooks to point to ClaudeClient (implement dynamic dispatch) - baseClient.hooks = claudeClient - - return claudeClient + c := &ClaudeClient{Client: baseClient} + baseClient.hooks = c // wire dynamic dispatch to ClaudeClient + return c } -func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) { - c.APIKey = apiKey +// ── Hook overrides ──────────────────────────────────────────────────────────── +// SetAPIKey stores credentials and optional custom endpoint / model. +func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) { + c.APIKey = apiKey if len(apiKey) > 8 { c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) } if customURL != "" { c.BaseURL = customURL - c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL) - } else { - c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL) + c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL) } if customModel != "" { c.Model = customModel - c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel) - } else { - c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model) + c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel) } } -// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer -func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) { - reqHeaders.Set("x-api-key", c.APIKey) - reqHeaders.Set("anthropic-version", "2023-06-01") +// setAuthHeader uses x-api-key instead of Authorization: Bearer. +func (c *ClaudeClient) setAuthHeader(h http.Header) { + h.Set("x-api-key", c.APIKey) + h.Set("anthropic-version", "2023-06-01") } -// buildUrl Claude uses /messages endpoint +// buildUrl targets /messages instead of /chat/completions. func (c *ClaudeClient) buildUrl() string { return fmt.Sprintf("%s/messages", c.BaseURL) } -// buildMCPRequestBody Claude has different request format +// buildMCPRequestBody builds the Anthropic wire format for the simple +// CallWithMessages path (no tool support). func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { - requestBody := map[string]any{ + return map[string]any{ "model": c.Model, "max_tokens": c.MaxTokens, "system": systemPrompt, @@ -88,13 +93,169 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[ {"role": "user", "content": userPrompt}, }, } - - return requestBody } -// parseMCPResponseFull Claude response format — handles both text and tool_use blocks. +// buildRequestBodyFromRequest converts a *Request into the Anthropic Messages +// API wire format. This is the key override that makes tool calling work +// correctly with Claude. +// +// Conversions applied: +// +// - System messages are lifted to the top-level "system" field. +// - Tool definitions: parameters → input_schema, wrapper removed. +// - Assistant messages with ToolCalls → content[{type:tool_use,...}]. +// - Tool result messages (role=tool) → role=user with tool_result blocks. +// Consecutive tool results are merged into a single user turn (Anthropic +// requires strictly alternating user/assistant turns). +// - tool_choice "auto"/"any" → {"type":"auto"/"any"} object. +func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any { + // ── 1. Separate system prompt from conversation messages ────────────────── + var systemPrompt string + var convMsgs []Message + for _, m := range req.Messages { + if m.Role == "system" { + systemPrompt = m.Content + } else { + convMsgs = append(convMsgs, m) + } + } + + // ── 2. Convert messages to Anthropic format ─────────────────────────────── + anthropicMsgs := convertMessagesToAnthropic(convMsgs) + + // ── 3. Convert tool definitions (parameters → input_schema) ────────────── + var anthropicTools []map[string]any + for _, t := range req.Tools { + anthropicTools = append(anthropicTools, map[string]any{ + "name": t.Function.Name, + "description": t.Function.Description, + "input_schema": t.Function.Parameters, + }) + } + + // ── 4. Assemble request body ────────────────────────────────────────────── + body := map[string]any{ + "model": req.Model, + "max_tokens": c.MaxTokens, + "system": systemPrompt, + "messages": anthropicMsgs, + } + + if len(anthropicTools) > 0 { + body["tools"] = anthropicTools + } + + // tool_choice: Anthropic uses an object, not a string. + switch req.ToolChoice { + case "auto": + body["tool_choice"] = map[string]any{"type": "auto"} + case "any": + body["tool_choice"] = map[string]any{"type": "any"} + case "none", "": + // omit — no tool_choice sent + } + + if req.Temperature != nil { + body["temperature"] = *req.Temperature + } + + return body +} + +// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message +// slice to Anthropic's messages array. +// +// Rules: +// 1. role=assistant + ToolCalls → role=assistant, content=[tool_use, ...] +// 2. role=tool (result) → role=user, content=[tool_result, ...] +// Consecutive tool-result messages are merged into one user turn so the +// conversation always alternates user/assistant. +// 3. All other messages → {role, content} as-is. +func convertMessagesToAnthropic(msgs []Message) []map[string]any { + var out []map[string]any + + for i := 0; i < len(msgs); { + msg := msgs[i] + + switch { + // ── Assistant message carrying tool calls ───────────────────────────── + case msg.Role == "assistant" && len(msg.ToolCalls) > 0: + var blocks []map[string]any + for _, tc := range msg.ToolCalls { + // Arguments are a JSON string; Claude wants a parsed object. + var input map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil { + input = map[string]any{"_raw": tc.Function.Arguments} + } + blocks = append(blocks, map[string]any{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Function.Name, + "input": input, + }) + } + out = append(out, map[string]any{ + "role": "assistant", + "content": blocks, + }) + i++ + + // ── Tool result message(s) → single user turn ───────────────────────── + case msg.Role == "tool": + // Collect all consecutive tool-result messages. + var blocks []map[string]any + for i < len(msgs) && msgs[i].Role == "tool" { + blocks = append(blocks, map[string]any{ + "type": "tool_result", + "tool_use_id": msgs[i].ToolCallID, + "content": msgs[i].Content, + }) + i++ + } + out = append(out, map[string]any{ + "role": "user", + "content": blocks, + }) + + // ── Regular user / assistant text message ───────────────────────────── + default: + out = append(out, map[string]any{ + "role": msg.Role, + "content": msg.Content, + }) + i++ + } + } + + return out +} + +// ── Response parsers ────────────────────────────────────────────────────────── + +// parseMCPResponse extracts the plain-text reply from an Anthropic response. +// Used by CallWithMessages / CallWithRequest (no tool support). +func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) { + r, err := c.parseMCPResponseFull(body) + if err != nil { + return "", err + } + return r.Content, nil +} + +// parseMCPResponseFull extracts both text and tool calls from an Anthropic +// response envelope. +// +// Anthropic response shape: +// +// { +// "content": [ +// {"type": "text", "text": "..."}, +// {"type": "tool_use", "id": "...", "name": "...", "input": {...}} +// ], +// "stop_reason": "tool_use" | "end_turn" +// } func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) { - var response struct { + var raw struct { Content []struct { Type string `json:"type"` Text string `json:"text,omitempty"` @@ -112,31 +273,33 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) { } `json:"error"` } - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body)) + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("failed to parse Anthropic response: %w — body: %s", err, body) } - if response.Error != nil { - return nil, fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message) + if raw.Error != nil { + return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message) } - totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens - if TokenUsageCallback != nil && totalTokens > 0 { + total := raw.Usage.InputTokens + raw.Usage.OutputTokens + if TokenUsageCallback != nil && total > 0 { TokenUsageCallback(TokenUsage{ Provider: c.Provider, Model: c.Model, - PromptTokens: response.Usage.InputTokens, - CompletionTokens: response.Usage.OutputTokens, - TotalTokens: totalTokens, + PromptTokens: raw.Usage.InputTokens, + CompletionTokens: raw.Usage.OutputTokens, + TotalTokens: total, }) } result := &LLMResponse{} - for _, block := range response.Content { + for _, block := range raw.Content { switch block.Type { case "text": result.Content = block.Text + case "tool_use": - // Claude returns arguments as a JSON object (Input field); convert to string. + // Input is a JSON object; serialise back to a JSON string so it + // matches the ToolCallFunction.Arguments field (always a string). argsJSON, err := json.Marshal(block.Input) if err != nil { argsJSON = []byte("{}") @@ -153,54 +316,3 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) { } return result, nil } - -// parseMCPResponse Claude has different response format -func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) { - var response struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` - Usage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - } `json:"usage"` - Error *struct { - Type string `json:"type"` - Message string `json:"message"` - } `json:"error"` - } - - if err := json.Unmarshal(body, &response); err != nil { - return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body)) - } - - if response.Error != nil { - return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message) - } - - if len(response.Content) == 0 { - return "", fmt.Errorf("Claude returned empty content, body: %s", string(body)) - } - - // Report token usage if callback is set - totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens - if TokenUsageCallback != nil && totalTokens > 0 { - TokenUsageCallback(TokenUsage{ - Provider: c.Provider, - Model: c.Model, - PromptTokens: response.Usage.InputTokens, - CompletionTokens: response.Usage.OutputTokens, - TotalTokens: totalTokens, - }) - } - - // Find text content - for _, content := range response.Content { - if content.Type == "text" { - return content.Text, nil - } - } - - return "", fmt.Errorf("no text content in Claude response") -} diff --git a/mcp/client.go b/mcp/client.go index 3b46c365..5b914965 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -477,7 +477,7 @@ func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) { func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) { client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL) - requestBody := client.buildRequestBodyFromRequest(req) + requestBody := client.hooks.buildRequestBodyFromRequest(req) jsonData, err := client.hooks.marshalRequestBody(requestBody) if err != nil { return nil, err @@ -512,44 +512,36 @@ func (client *Client) callWithRequest(req *Request) (string, error) { client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL) client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages)) - // Build request body (from Request object) - requestBody := client.buildRequestBodyFromRequest(req) + requestBody := client.hooks.buildRequestBodyFromRequest(req) - // Serialize request body jsonData, err := client.hooks.marshalRequestBody(requestBody) if err != nil { return "", err } - // Build URL url := client.hooks.buildUrl() client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url) - // Create HTTP request httpReq, err := client.hooks.buildRequest(url, jsonData) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - // Send HTTP request resp, err := client.httpClient.Do(httpReq) if err != nil { return "", fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) } - // Check HTTP status code if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body)) } - // Parse response result, err := client.hooks.parseMCPResponse(body) if err != nil { return "", fmt.Errorf("fail to parse AI server response: %w", err) @@ -651,7 +643,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string)) } req.Stream = true - requestBody := client.buildRequestBodyFromRequest(req) + requestBody := client.hooks.buildRequestBodyFromRequest(req) jsonData, err := client.hooks.marshalRequestBody(requestBody) if err != nil { return "", err diff --git a/mcp/interface.go b/mcp/interface.go index 33604861..c7e62edc 100644 --- a/mcp/interface.go +++ b/mcp/interface.go @@ -22,19 +22,40 @@ type AIClient interface { CallWithRequestFull(req *Request) (*LLMResponse, error) } -// clientHooks internal hook interface (for subclass to override specific steps) -// These methods are only used inside the package to implement dynamic dispatch +// clientHooks is the internal dispatch interface used to implement per-provider +// polymorphism without Go's lack of virtual methods. +// +// Each method can be overridden by an embedding struct (e.g. ClaudeClient). +// The base *Client provides OpenAI-compatible defaults; providers with a +// different wire format (Anthropic, Gemini native, etc.) override only what +// differs. All call-path methods in client.go invoke these via c.hooks so +// that the override is always picked up at runtime. type clientHooks interface { - // Hook methods that can be overridden by subclass - + // ── Simple CallWithMessages path ──────────────────────────────────────── call(systemPrompt, userPrompt string) (string, error) - buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any + + // ── Shared request plumbing ───────────────────────────────────────────── buildUrl() string buildRequest(url string, jsonData []byte) (*http.Request, error) setAuthHeader(reqHeaders http.Header) marshalRequestBody(requestBody map[string]any) ([]byte, error) + + // ── Advanced (Request-object) path ────────────────────────────────────── + // buildRequestBodyFromRequest converts a *Request into the provider's + // native wire-format map. Providers that use a different protocol (e.g. + // Anthropic uses "input_schema" for tools, "tool_use" content blocks, and + // a top-level "system" field) override this method. + buildRequestBodyFromRequest(req *Request) map[string]any + + // parseMCPResponse extracts the plain-text reply from a non-streaming + // response body. parseMCPResponse(body []byte) (string, error) + + // parseMCPResponseFull extracts both text and tool calls. Providers whose + // response envelope differs from the OpenAI choices[] structure (e.g. + // Anthropic content[] with tool_use blocks) override this method. parseMCPResponseFull(body []byte) (*LLMResponse, error) + isRetryableError(err error) bool }