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
2 changes: 2 additions & 0 deletions mcp-client-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,5 @@ Without an `ANTHROPIC_API_KEY`, the client still connects, prints the server's t
The SDK does not validate tool output, so this client compiles each declared `outputSchema` at connect time and checks results against it — the spec's client-side SHOULD. It uses [`jsonschema-go`](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema), already an SDK dependency.

The two channels go to different readers: `Content` is forwarded to the model, while `StructuredContent` is used as data — the client counts the items it returns. See [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content).

Before a tool result goes to the model, the client converts it to the Messages API's block types. Text stays text, a PNG, JPEG, GIF or WebP image becomes an image block, and a text resource becomes its text. Anything else, such as audio, becomes a placeholder like `[unsupported result type: audio]`, so the model knows something was there. Empty text is dropped, because the Messages API rejects empty text blocks. A result with structured content and no text is sent as its JSON. A call the server rejects goes back to the model as a failed tool result, so it can correct itself, rather than ending the query.
106 changes: 92 additions & 14 deletions mcp-client-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
Expand All @@ -24,6 +25,80 @@ var model = anthropic.ModelClaudeSonnet5
const maxTokens = 10000
const maxToolTurns = 10

// Image types the Messages API accepts.
var supportedImageTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}

// toolResultContent translates an MCP tool result into content for an
// Anthropic tool_result.
//
// MCP and Anthropic content blocks differ, so each kind is converted. A kind
// the model cannot take becomes a placeholder rather than vanishing, so the
// model knows something was there.
func toolResultContent(result *mcp.CallToolResult) []anthropic.ToolResultBlockParamContentUnion {
var content []anthropic.ToolResultBlockParamContentUnion
text := func(s string) {
if s == "" {
return
}
content = append(content, anthropic.ToolResultBlockParamContentUnion{OfText: &anthropic.TextBlockParam{Text: s}})
}

hasText := false
for _, block := range result.Content {
switch b := block.(type) {
case *mcp.TextContent:
hasText = hasText || b.Text != ""
text(b.Text)
case *mcp.ImageContent:
if !supportedImageTypes[b.MIMEType] {
text("[unsupported result type: image]")
continue
}
content = append(content, anthropic.ToolResultBlockParamContentUnion{OfImage: &anthropic.ImageBlockParam{
Source: anthropic.ImageBlockParamSourceUnion{OfBase64: &anthropic.Base64ImageSourceParam{
// The SDK holds the decoded bytes; the API wants base64.
Data: base64.StdEncoding.EncodeToString(b.Data),
MediaType: anthropic.Base64ImageSourceMediaType(b.MIMEType),
}},
}})
case *mcp.EmbeddedResource:
if b.Resource == nil || b.Resource.Blob != nil {
text("[unsupported result type: resource]")
continue
}
text(b.Resource.Text)
default:
text(fmt.Sprintf("[unsupported result type: %s]", contentType(block)))
}
}

// A structured result SHOULD also carry serialized JSON in a text block, but
// only SHOULD, so serialize it when no text came with it.
if !hasText && result.StructuredContent != nil {
if data, err := json.Marshal(result.StructuredContent); err == nil {
text(string(data))
}
}

return content
}

// contentType returns an MCP content block's wire type, such as "audio".
func contentType(block mcp.Content) string {
var wire struct {
Type string `json:"type"`
}
if data, err := block.MarshalJSON(); err == nil && json.Unmarshal(data, &wire) == nil && wire.Type != "" {
return wire.Type
}
return "unknown"
}

type MCPClient struct {
anthropic *anthropic.Client
session *mcp.ClientSession
Expand Down Expand Up @@ -205,7 +280,16 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, err
Arguments: toolUseBlock.Input,
})
if err != nil {
return "", fmt.Errorf("tool call %s failed: %w", toolUseBlock.Name, err)
// A rejected call is the model's mistake to correct, so it goes
// back as a failed tool_result rather than ending the query.
// Every tool_use still needs a matching result.
finalText = append(finalText, fmt.Sprintf("[%s failed: %v]", toolUseBlock.Name, err))
anthropicToolResults = append(anthropicToolResults, anthropic.NewToolResultBlock(
toolUseBlock.ID,
fmt.Sprintf("Tool call %s failed: %v", toolUseBlock.Name, err),
true,
))
continue
}

if err := c.validateToolOutput(toolUseBlock.Name, mcpToolResult); err != nil {
Expand All @@ -217,19 +301,13 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, err
finalText = append(finalText, fmt.Sprintf("[%s returned %d items]", toolUseBlock.Name, len(items)))
}

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

anthropicToolResults = append(anthropicToolResults, anthropic.NewToolResultBlock(
toolUseBlock.ID,
strings.Join(texts, "\n"),
mcpToolResult.IsError,
))
anthropicToolResults = append(anthropicToolResults, anthropic.ContentBlockParamUnion{
OfToolResult: &anthropic.ToolResultBlockParam{
ToolUseID: toolUseBlock.ID,
Content: toolResultContent(mcpToolResult),
IsError: anthropic.Bool(mcpToolResult.IsError),
},
})
}

// Append the assistant's response and all tool results, in one user
Expand Down
2 changes: 2 additions & 0 deletions mcp-client-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ Without an `ANTHROPIC_API_KEY`, the client still connects, prints the server's t

The two channels go to different readers: `content` is forwarded to the model, while `structured_content` is used as data — when a tool returns an array, the client counts its items rather than re-reading the prose. See [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content).

Before a tool result goes to the model, the client converts it to the Messages API's block types. Text stays text, a PNG, JPEG, GIF or WebP image becomes an image block, and a text resource becomes its text. Anything else, such as audio, becomes a placeholder like `[unsupported result type: audio]`, so the model knows something was there. Empty text is dropped, because the Messages API rejects empty text blocks. A result with structured content and no text is sent as its JSON. A call the server rejects goes back to the model as a failed tool result, so it can correct itself, rather than ending the query.

`Client(server_params, mode="auto")` launches the server from its `StdioServerParameters`, then probes `server/discover` and falls back to the `2025-11-25` handshake; `client.protocol_version` reports which era you got. See [Protocol versions](https://py.sdk.modelcontextprotocol.io/v2/protocol-versions/).
60 changes: 54 additions & 6 deletions mcp-client-python/client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import asyncio
import json
import os
from contextlib import AsyncExitStack
from pathlib import Path

from anthropic import Anthropic
from dotenv import load_dotenv
from mcp import Client, StdioServerParameters
from mcp_types import TextContent
from mcp import Client, MCPError, StdioServerParameters
from mcp_types import CallToolResult, EmbeddedResource, ImageContent, TextContent, TextResourceContents

load_dotenv() # load environment variables from .env

Expand All @@ -15,6 +16,39 @@
# plus the reply, so leave room for both.
MAX_TOKENS = 10000
MAX_TOOL_TURNS = 10
# Image types the Messages API accepts.
SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}


def tool_result_content(result: CallToolResult) -> list[dict]:
"""Translate an MCP tool result into content for an Anthropic tool_result.

MCP and Anthropic content blocks differ, so each kind is converted. A kind
the model cannot take becomes a placeholder rather than vanishing, so the
model knows something was there.
"""
content: list[dict] = []
for block in result.content:
if isinstance(block, TextContent):
content.append({"type": "text", "text": block.text})
elif isinstance(block, ImageContent) and block.mime_type in SUPPORTED_IMAGE_TYPES:
content.append(
{
"type": "image",
"source": {"type": "base64", "media_type": block.mime_type, "data": block.data},
}
)
elif isinstance(block, EmbeddedResource) and isinstance(block.resource, TextResourceContents):
content.append({"type": "text", "text": block.resource.text})
else:
content.append({"type": "text", "text": f"[unsupported result type: {block.type}]"})

content = [block for block in content if block.get("text") != ""]
has_text = any(isinstance(block, TextContent) and block.text for block in result.content)
if result.structured_content is not None and not has_text:
content.append({"type": "text", "text": json.dumps(result.structured_content)})

return content
Comment thread
Copilot marked this conversation as resolved.
Resolved


class MCPClient:
Expand Down Expand Up @@ -90,20 +124,34 @@ async def process_query(self, query: str) -> str:

tool_results = []
for tool_use in tool_uses:
# call_tool validates the result against the declared schema.
result = await self.client.call_tool(tool_use.name, tool_use.input)
final_text.append(f"[Calling tool {tool_use.name} with args {tool_use.input}]")
try:
# call_tool validates the result against the declared schema.
result = await self.client.call_tool(tool_use.name, tool_use.input)
except MCPError as error:
# A rejected call is the model's mistake to correct, so it goes
# back as a failed tool_result rather than ending the query.
# Every tool_use still needs a matching result.
final_text.append(f"[{tool_use.name} failed: {error}]")
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": f"Tool call {tool_use.name} failed: {error}",
"is_error": True,
}
)
continue

# structured_content is data the application can use directly.
if isinstance(result.structured_content, list):
final_text.append(f"[{tool_use.name} returned {len(result.structured_content)} items]")

# content is a list of block types; forward only the text ones.
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": "\n".join(block.text for block in result.content if isinstance(block, TextContent)),
"content": tool_result_content(result),
"is_error": bool(result.is_error),
}
)
Expand Down
2 changes: 2 additions & 0 deletions mcp-client-ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ The spec says clients SHOULD validate structured results against the schema the

The two channels go to different readers: `content` is forwarded to the model, while `structured_content` is used as data — when a tool returns an array, the client counts its items rather than re-reading the prose. See [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content).

Before a tool result goes to the model, the client converts it to the Messages API's block types. Text stays text, a PNG, JPEG, GIF or WebP image becomes an image block, and a text resource becomes its text. Anything else, such as audio, becomes a placeholder like `[unsupported result type: audio]`, so the model knows something was there. Empty text is dropped, because the Messages API rejects empty text blocks. A result with structured content and no text is sent as its JSON. A call the server rejects goes back to the model as a failed tool result, so it can correct itself, rather than ending the query.

`MCP::Client::Stdio` offers `2026-07-28` by default, so no negotiation setup is needed here.
61 changes: 51 additions & 10 deletions mcp-client-ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class MCPClient
# plus the reply, so leave room for both.
MAX_TOKENS = 10000
MAX_TOOL_TURNS = 10
# Image types the Messages API accepts.
SUPPORTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"].freeze

def initialize
@mcp_client = nil
Expand Down Expand Up @@ -76,6 +78,35 @@ def cleanup

private

# Translate an MCP tool result into content for an Anthropic tool_result.
#
# MCP and Anthropic content blocks differ, so each kind is converted. A kind
# the model cannot take becomes a placeholder rather than vanishing, so the
# model knows something was there.
def tool_result_content(result)
blocks = Array(result["content"])
content = blocks.map do |block|
if block["type"] == "text"
{ type: "text", text: block["text"] }
elsif block["type"] == "image" && SUPPORTED_IMAGE_TYPES.include?(block["mimeType"])
{ type: "image", source: { type: "base64", media_type: block["mimeType"], data: block["data"] } }
elsif block["type"] == "resource" && block.dig("resource", "text")
{ type: "text", text: block.dig("resource", "text") }
else
{ type: "text", text: "[unsupported result type: #{block["type"]}]" }
end
end

content.reject! { |block| block[:text] == "" }
structured = result["structuredContent"]
has_text = blocks.any? { |block| block["type"] == "text" && !block["text"].to_s.empty? }
if !structured.nil? && !has_text
content << { type: "text", text: structured.to_json }
end

content
Comment thread
Copilot marked this conversation as resolved.
Resolved
end

def process_query(query)
messages = [{ role: "user", content: query }]

Expand Down Expand Up @@ -104,8 +135,21 @@ def process_query(query)

# 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}]"
begin
result = @mcp_client.call_tool(name: tool_use.name, arguments: tool_use.input)
rescue MCP::Client::ServerError => e
# A rejected call is the model's mistake to correct, so it goes back as
# a failed tool_result rather than ending the query. Every tool_use
# still needs a matching result.
final_text << "[#{tool_use.name} failed: #{e.message}]"
next {
type: "tool_result",
tool_use_id: tool_use.id,
content: "Tool call #{tool_use.name} failed: #{e.message}",
is_error: true
}
end

# structured_content is data the application can use directly; when a
# tool returns an array, count its items rather than re-reading prose.
Expand All @@ -116,15 +160,12 @@ def process_query(query)
final_text << "[#{tool_use.name} returned #{structured.length} items]" if structured.is_a?(Array)
end

# 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

{ type: "tool_result", tool_use_id: tool_use.id, content: result_text, is_error: is_error }
{
type: "tool_result",
tool_use_id: tool_use.id,
content: tool_result_content(result["result"]),
is_error: is_error
}
end

# Append the assistant's response and all tool results, in one user
Expand Down
2 changes: 2 additions & 0 deletions mcp-client-rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ rmcp's `serve` only performs the legacy `2025-11-25` `initialize` handshake, so
rmcp does not validate tool output, so this client compiles each declared `outputSchema` at connect time and checks results against it — the spec's client-side SHOULD. It uses the [`jsonschema`](https://docs.rs/jsonschema) crate, which rmcp's own documentation recommends.

The two channels go to different readers: `content` is forwarded to the model, while `structured_content` is used as data — the client counts the items it returns. See [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content).

Before a tool result goes to the model, the client converts it to the Messages API's block types. Text stays text, a PNG, JPEG, GIF or WebP image becomes an image block, and a text resource becomes its text. Anything else, such as audio, becomes a placeholder like `[unsupported result type: audio]`, so the model knows something was there. Empty text is dropped, because the Messages API rejects empty text blocks. A result with structured content and no text is sent as its JSON. A call the server rejects goes back to the model as a failed tool result, so it can correct itself, rather than ending the query.
Loading
Loading