Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 86 additions & 68 deletions mcp-client-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)

// A string literal rather than an anthropic.Model constant: the pinned SDK
// version predates this model and has no constant for it.
var model anthropic.Model = "claude-sonnet-5"

// Sonnet 5 thinks adaptively unless told otherwise, and max_tokens caps thinking
// plus the reply, so leave room for both.
const maxTokens = 10000
const maxToolTurns = 10

type MCPClient struct {
anthropic *anthropic.Client
session *mcp.ClientSession
Expand Down Expand Up @@ -166,98 +169,113 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, err
anthropic.NewUserMessage(anthropic.NewTextBlock(query)),
}

var finalText []string

// Initial Claude API call with tools
response, err := c.anthropic.Messages.New(ctx, anthropic.MessageNewParams{
Model: model,
MaxTokens: 1024,
Messages: messages,
Tools: c.tools,
})
response, err := c.createMessage(ctx, messages)
if err != nil {
return "", fmt.Errorf("anthropic API request failed: %w", err)
return "", err
}

var toolUseBlocks []anthropic.ToolUseBlock
var finalText []string
for _, block := range response.Content {
switch b := block.AsAny().(type) {
case anthropic.TextBlock:
finalText = append(finalText, b.Text)
case anthropic.ToolUseBlock:
toolUseBlocks = append(toolUseBlocks, b)
// Keep calling tools until Claude answers without one, up to a cap.
for turn := 0; turn < maxToolTurns; turn++ {
var toolUseBlocks []anthropic.ToolUseBlock
for _, block := range response.Content {
switch b := block.AsAny().(type) {
case anthropic.TextBlock:
finalText = append(finalText, b.Text)
case anthropic.ToolUseBlock:
toolUseBlocks = append(toolUseBlocks, b)
}
}
}

if len(toolUseBlocks) == 0 {
return strings.Join(finalText, "\n"), nil
}

// Append assistant's response to message history
messages = append(messages, response.ToParam())

// Execute each tool call and collect responses
var anthropicToolResults []anthropic.ContentBlockParamUnion
for _, toolUseBlock := range toolUseBlocks {
// Add information about the tool call to final text
finalText = append(finalText, fmt.Sprintf("[Calling tool %s with args %s]", toolUseBlock.Name, string(toolUseBlock.Input)))

// Call the MCP server tool
mcpToolResult, err := c.session.CallTool(ctx, &mcp.CallToolParams{
Name: toolUseBlock.Name,
Arguments: toolUseBlock.Input,
})
if err != nil {
return "", fmt.Errorf("tool call %s failed: %w", toolUseBlock.Name, err)
if len(toolUseBlocks) == 0 {
return strings.Join(finalText, "\n"), nil
}

if err := c.validateToolOutput(toolUseBlock.Name, mcpToolResult); err != nil {
return "", err
}
// Execute every tool call in this response and collect the results
var anthropicToolResults []anthropic.ContentBlockParamUnion
for _, toolUseBlock := range toolUseBlocks {
// Add information about the tool call to final text
finalText = append(finalText, fmt.Sprintf("[Calling tool %s with args %s]", toolUseBlock.Name, string(toolUseBlock.Input)))

// Call the MCP server tool
mcpToolResult, err := c.session.CallTool(ctx, &mcp.CallToolParams{
Name: toolUseBlock.Name,
Arguments: toolUseBlock.Input,
})
if err != nil {
return "", fmt.Errorf("tool call %s failed: %w", toolUseBlock.Name, err)
}

// StructuredContent is data the application can use directly.
if items, ok := mcpToolResult.StructuredContent.([]any); ok {
finalText = append(finalText, fmt.Sprintf("[%s returned %d items]", toolUseBlock.Name, len(items)))
}
if err := c.validateToolOutput(toolUseBlock.Name, mcpToolResult); err != nil {
return "", err
}

// Content is a list of block types; forward only the text ones.
var texts []string
for _, block := range mcpToolResult.Content {
if text, ok := block.(*mcp.TextContent); ok {
texts = append(texts, text.Text)
// StructuredContent is data the application can use directly.
if items, ok := mcpToolResult.StructuredContent.([]any); ok {
finalText = append(finalText, fmt.Sprintf("[%s returned %d items]", toolUseBlock.Name, len(items)))
}
}

anthropicToolResults = append(anthropicToolResults, anthropic.NewToolResultBlock(
toolUseBlock.ID,
strings.Join(texts, "\n"),
mcpToolResult.IsError,
))
}
// Content is a list of block types; forward only the text ones.
var texts []string
for _, block := range mcpToolResult.Content {
if text, ok := block.(*mcp.TextContent); ok {
texts = append(texts, text.Text)
}
}

// Append tool responses to message history
messages = append(messages, anthropic.NewUserMessage(anthropicToolResults...))
anthropicToolResults = append(anthropicToolResults, anthropic.NewToolResultBlock(
toolUseBlock.ID,
strings.Join(texts, "\n"),
mcpToolResult.IsError,
))
}

// Make another API call with tool results
response, err = c.anthropic.Messages.New(ctx, anthropic.MessageNewParams{
Model: model,
MaxTokens: 1024,
Messages: messages,
})
if err != nil {
return "", fmt.Errorf("anthropic API request with tool results failed: %w", err)
// Append the assistant's response and all tool results, in one user
// message, to the history. Every tool_use needs a matching tool_result.
messages = append(messages, response.ToParam())
messages = append(messages, anthropic.NewUserMessage(anthropicToolResults...))

response, err = c.createMessage(ctx, messages)
if err != nil {
return "", err
}
}

// Collect text from final response
// The turn cap was hit. Keep the last response's text. If it asked for
// more tools, say they were not run.
wantsTools := false
for _, block := range response.Content {
switch b := block.AsAny().(type) {
case anthropic.TextBlock:
finalText = append(finalText, b.Text)
case anthropic.ToolUseBlock:
wantsTools = true
}
}
if wantsTools {
finalText = append(finalText, fmt.Sprintf("[Stopped after %d tool-use turns]", maxToolTurns))
}

return strings.Join(finalText, "\n"), nil
}

// createMessage calls Claude with the current history. Tools are passed on
// every call so Claude can keep calling them across turns.
func (c *MCPClient) createMessage(ctx context.Context, messages []anthropic.MessageParam) (*anthropic.Message, error) {
response, err := c.anthropic.Messages.New(ctx, anthropic.MessageNewParams{
Model: model,
MaxTokens: maxTokens,
Messages: messages,
Tools: c.tools,
})
if err != nil {
return nil, fmt.Errorf("anthropic API request failed: %w", err)
}
return response, nil
}

func (c *MCPClient) ChatLoop(ctx context.Context) error {
fmt.Println("\nMCP Client Started!")
fmt.Println("Type your queries or 'quit' to exit.")
Expand Down
7 changes: 5 additions & 2 deletions mcp-client-python/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

load_dotenv() # load environment variables from .env

# Claude model constant
ANTHROPIC_MODEL = "claude-sonnet-5"
# Sonnet 5 thinks adaptively unless told otherwise, and max_tokens caps thinking
# plus the reply, so leave room for both.
Expand Down Expand Up @@ -119,7 +118,11 @@ async def process_query(self, query: str) -> str:
tools=available_tools,
)

final_text.append(f"[Stopped after {MAX_TOOL_TURNS} tool-use turns]")
# The turn cap was hit. Keep the last response's text. If it asked for
# more tools, say they were not run.
final_text.extend(content.text for content in response.content if content.type == "text")
if any(content.type == "tool_use" for content in response.content):
final_text.append(f"[Stopped after {MAX_TOOL_TURNS} tool-use turns]")
return "\n".join(final_text)

async def chat_loop(self):
Expand Down
111 changes: 70 additions & 41 deletions mcp-client-ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

class MCPClient
ANTHROPIC_MODEL = "claude-sonnet-5"
# Sonnet 5 thinks adaptively unless told otherwise, and max_tokens caps thinking
# plus the reply, so leave room for both.
MAX_TOKENS = 10000
MAX_TOOL_TURNS = 10

def initialize
@mcp_client = nil
Expand Down Expand Up @@ -49,6 +53,7 @@ def chat_loop

loop do
print "\nQuery: "
$stdout.flush # a pipe is not line-buffered, so show the prompt before waiting
line = $stdin.gets
break if line.nil?

Expand Down Expand Up @@ -78,71 +83,95 @@ def process_query(query)
{ name: tool.name, description: tool.description, input_schema: tool.input_schema }
end

# Initial Claude API call.
response = chat(messages, tools: available_tools)
final_text = []

# Process response and handle tool calls.
if response.content.any?(Anthropic::Models::ToolUseBlock)
assistant_content = response.content.filter_map do |content_block|
case content_block
# Initial Claude API call with tools.
response = chat(messages, available_tools)

# Keep calling tools until Claude answers without one, up to a cap.
MAX_TOOL_TURNS.times do
tool_uses = []
response.content.each do |block|
case block
when Anthropic::Models::TextBlock
{ type: "text", text: content_block.text }
final_text << block.text
when Anthropic::Models::ToolUseBlock
{ type: "tool_use", id: content_block.id, name: content_block.name, input: content_block.input }
tool_uses << block
end
end
messages << { role: "assistant", content: assistant_content }
end

response.content.each_with_object([]) do |content, response_parts|
case content
when Anthropic::Models::TextBlock
response_parts << content.text
when Anthropic::Models::ToolUseBlock
# Execute tool call via MCP.
result = @mcp_client.call_tool(name: content.name, arguments: content.input)
response_parts << "[Calling tool #{content.name} with args #{content.input.to_json}]"
return final_text.join("\n") if tool_uses.empty?

# Execute every tool call in this response and collect the results.
tool_results = tool_uses.map do |tool_use|
result = @mcp_client.call_tool(name: tool_use.name, arguments: tool_use.input)
final_text << "[Calling tool #{tool_use.name} with args #{tool_use.input.to_json}]"

# structured_content is data the application can use directly; when a
# tool returns an array, count its items rather than re-reading prose.
is_error = result.dig("result", "isError") == true
structured = result.dig("result", "structuredContent")
unless result.dig("result", "isError")
@output_schemas[content.name]&.validate_result(structured)
response_parts << "[#{content.name} returned #{structured.length} items]" if structured.is_a?(Array)
unless is_error
@output_schemas[tool_use.name]&.validate_result(structured)
final_text << "[#{tool_use.name} returned #{structured.length} items]" if structured.is_a?(Array)
end

# content is what the model reads.
# content is a list of block types; forward only the text ones.
tool_result_content = result.dig("result", "content")
result_text = if tool_result_content.is_a?(Array)
tool_result_content.filter_map { |content_item| content_item["text"] }.join("\n")
else
tool_result_content.to_s
end

messages << {
role: "user",
content: [{
type: "tool_result",
tool_use_id: content.id,
content: result_text
}]
}
{ type: "tool_result", tool_use_id: tool_use.id, content: result_text, is_error: is_error }
end

# Get next response from Claude.
response = chat(messages)
# Append the assistant's response and all tool results, in one user
# message, to the history. Every tool_use needs a matching tool_result.
messages << { role: "assistant", content: assistant_content(response) }
messages << { role: "user", content: tool_results }

response.content.each do |content_block|
response_parts << content_block.text if content_block.is_a?(Anthropic::Models::TextBlock)
end
end
end.join("\n")
response = chat(messages, available_tools)
end

# The turn cap was hit. Keep the last response's text. If it asked for
# more tools, say they were not run.
response.content.each do |block|
final_text << block.text if block.is_a?(Anthropic::Models::TextBlock)
end
if response.content.any?(Anthropic::Models::ToolUseBlock)
final_text << "[Stopped after #{MAX_TOOL_TURNS} tool-use turns]"
end
final_text.join("\n")
end

def chat(messages, tools: nil)
params = { model: ANTHROPIC_MODEL, max_tokens: 1000, messages: messages }
params[:tools] = tools if tools
# Convert a response's content blocks back into request parameters. Thinking
# blocks must go back unchanged, signature included, or the API rejects the
# follow-up request.
def assistant_content(response)
response.content.filter_map do |block|
case block
when Anthropic::Models::TextBlock
{ type: "text", text: block.text }
when Anthropic::Models::ToolUseBlock
{ type: "tool_use", id: block.id, name: block.name, input: block.input }
when Anthropic::Models::ThinkingBlock
{ type: "thinking", thinking: block.thinking, signature: block.signature }
when Anthropic::Models::RedactedThinkingBlock
{ type: "redacted_thinking", data: block.data }
end
end
end

anthropic_client.messages.create(**params)
# Tools are passed on every call so Claude can keep calling them across turns.
def chat(messages, tools)
anthropic_client.messages.create(
model: ANTHROPIC_MODEL,
max_tokens: MAX_TOKENS,
messages: messages,
tools: tools
)
end

def anthropic_client
Expand Down
Loading
Loading