Skip to content
Merged
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Each example lives in its own directory with its own README covering prerequisit

All servers communicate over stdio and expose the same two tools: `get_forecast` and `get_alerts` (or `get-forecast` and `get-alerts` in TypeScript).

All clients launch a server, list its tools, and start an interactive chat loop in which Claude can call those tools. The Python, TypeScript, and Ruby clients take a path to a server script; the Go and Rust clients take the command to run, plus any arguments.
All clients launch a server, list its tools, and start an interactive chat loop in which Claude can call those tools and the conversation is kept until you type `clear` or `quit`. The Python, TypeScript, and Ruby clients take a path to a server script; the Go and Rust clients take the command to run, plus any arguments.

Note: These example clients need an `ANTHROPIC_API_KEY` to operate. Without a key, each client still connects and lists the server's tools before exiting, so you can verify the MCP wiring without credentials.

Expand Down
4 changes: 3 additions & 1 deletion mcp-client-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ go run main.go ../weather-server-go/weather
go run main.go uv --directory ../weather-server-python run weather.py
```

Type a question (for example, "What's the weather in Sacramento?") and Claude answers using the server's tools. Type `quit` to exit.
Type a question (for example, "What's the weather in Sacramento?") and Claude answers using the server's tools. The client keeps the conversation for the whole session, so a follow-up such as "And the alerts for that state?" works. Type `clear` to start a new conversation, or `quit` to exit.

Without an `ANTHROPIC_API_KEY`, the client still connects, prints the server's tools, and exits — useful for verifying the MCP wiring without credentials.

Expand All @@ -43,3 +43,5 @@ The SDK does not validate tool output, so this client compiles each declared `ou
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.

Every request sends the same short system prompt. When a reply stops early, the client says so: `[Reply cut off at the max_tokens limit]` when it ran out of tokens, and `[The model declined to answer; the query is not kept in history]` when the model refused. A reply that was cut off, refused, empty or stopped at the tool-turn limit is not added to the history, and a query that fails with an error is dropped from it, so the next request stays valid.
114 changes: 55 additions & 59 deletions mcp-client-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var model = anthropic.ModelClaudeSonnet5
// plus the reply, so leave room for both.
const maxTokens = 10000
const maxToolTurns = 10
const systemPrompt = "You are a helpful assistant. Use the available tools when they help answer the question."

// Image types the Messages API accepts.
var supportedImageTypes = map[string]bool{
Expand Down Expand Up @@ -103,6 +104,7 @@ type MCPClient struct {
anthropic *anthropic.Client
session *mcp.ClientSession
tools []anthropic.ToolUnionParam
messages []anthropic.MessageParam
// Compiled outputSchema per tool name, for validating results.
outputSchemas map[string]*jsonschema.Resolved
}
Expand Down Expand Up @@ -240,37 +242,59 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, err
return "", fmt.Errorf("client is not connected to any server")
}

messages := []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(query)),
}

historyLength := len(c.messages)
c.messages = append(c.messages, anthropic.NewUserMessage(anthropic.NewTextBlock(query)))
var finalText []string

// Initial Claude API call with tools
response, err := c.createMessage(ctx, messages)
if err != nil {
return "", err
}
for turn := 0; turn <= maxToolTurns; turn++ {
// Tools are passed on every call so Claude can keep calling them.
response, err := c.anthropic.Messages.New(ctx, anthropic.MessageNewParams{
Model: model,
MaxTokens: maxTokens,
System: []anthropic.TextBlockParam{{Text: systemPrompt}},
Messages: c.messages,
Tools: c.tools,
})
if err != nil {
return "", fmt.Errorf("anthropic API request failed: %w", err)
}

if response.StopReason == anthropic.StopReasonRefusal {
// Drop the refused query so a follow-up doesn't send it again.
c.messages = c.messages[:historyLength]
finalText = append(finalText, "[The model declined to answer; the query is not kept in history]")
break
}

// 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:
if b, ok := block.AsAny().(anthropic.TextBlock); ok {
finalText = append(finalText, b.Text)
case anthropic.ToolUseBlock:
toolUseBlocks = append(toolUseBlocks, b)
}
}

if len(toolUseBlocks) == 0 {
return strings.Join(finalText, "\n"), nil
if response.StopReason == anthropic.StopReasonMaxTokens {
finalText = append(finalText, "[Reply cut off at the max_tokens limit]")
break
}
if response.StopReason != anthropic.StopReasonToolUse {
// The API rejects an empty assistant message, so an empty reply isn't kept.
if len(response.Content) > 0 {
c.messages = append(c.messages, response.ToParam())
}
break
}
if turn == maxToolTurns {
finalText = append(finalText, fmt.Sprintf("[Stopped after %d tool-use turns]", maxToolTurns))
break
}

// Execute every tool call in this response and collect the results
var anthropicToolResults []anthropic.ContentBlockParamUnion
for _, toolUseBlock := range toolUseBlocks {
for _, block := range response.Content {
toolUseBlock, ok := block.AsAny().(anthropic.ToolUseBlock)
if !ok {
continue
}
// 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)))

Expand Down Expand Up @@ -310,53 +334,17 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, 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
}
}

// 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))
// Every tool_use needs a matching tool_result, all in one user message.
c.messages = append(c.messages, response.ToParam())
c.messages = append(c.messages, anthropic.NewUserMessage(anthropicToolResults...))
}

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.")
fmt.Println("Type your queries, 'clear' to forget the conversation, or 'quit' to exit.")

scanner := bufio.NewScanner(os.Stdin)

Expand All @@ -370,12 +358,20 @@ func (c *MCPClient) ChatLoop(ctx context.Context) error {
if strings.EqualFold(query, "quit") {
break
}
if strings.EqualFold(query, "clear") {
c.messages = nil
fmt.Println("\nConversation cleared.")
continue
}
if query == "" {
continue
}

historyLength := len(c.messages)
response, err := c.ProcessQuery(ctx, query)
if err != nil {
// Drop the failed query so a follow-up doesn't send it again.
c.messages = c.messages[:historyLength]
fmt.Printf("\nError: %v\n", err)
continue
}
Expand Down
4 changes: 3 additions & 1 deletion mcp-client-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ or, against the TypeScript server (build it first — see its README):
uv run client.py ../weather-server-typescript/build/index.js
```

Type a question (for example, "What's the weather in Sacramento?") and Claude answers using the server's tools. Type `quit` to exit.
Type a question (for example, "What's the weather in Sacramento?") and Claude answers using the server's tools. The client keeps the conversation for the whole session, so a follow-up such as "And the alerts for that state?" works. Type `clear` to start a new conversation, or `quit` to exit.

Without an `ANTHROPIC_API_KEY`, the client still connects, prints the server's tools, and exits — useful for verifying the MCP wiring without credentials.

Expand All @@ -45,4 +45,6 @@ The two channels go to different readers: `content` is forwarded to the model, w

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.

Every request sends the same short system prompt. When a reply stops early, the client says so: `[Reply cut off at the max_tokens limit]` when it ran out of tokens, and `[The model declined to answer; the query is not kept in history]` when the model refused. A reply that was cut off, refused, empty or stopped at the tool-turn limit is not added to the history, and a query that fails with an error is dropped from it, so the next request stays valid.

`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/).
74 changes: 44 additions & 30 deletions mcp-client-python/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# plus the reply, so leave room for both.
MAX_TOKENS = 10000
MAX_TOOL_TURNS = 10
SYSTEM_PROMPT = "You are a helpful assistant. Use the available tools when they help answer the question."
# Image types the Messages API accepts.
SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}

Expand Down Expand Up @@ -57,6 +58,7 @@ def __init__(self):
self.client: Client | None = None
self.exit_stack = AsyncExitStack()
self._anthropic: Anthropic | None = None
self.messages: list[dict] = []

@property
def anthropic(self) -> Anthropic:
Expand Down Expand Up @@ -97,7 +99,8 @@ async def connect_to_server(self, server_script_path: str):

async def process_query(self, query: str) -> str:
"""Process a query using Claude and available tools"""
messages = [{"role": "user", "content": query}]
history_length = len(self.messages)
self.messages.append({"role": "user", "content": query})
Comment thread
Copilot marked this conversation as resolved.
Resolved

tools_response = await self.client.list_tools()
available_tools = [
Expand All @@ -107,23 +110,37 @@ async def process_query(self, query: str) -> str:

final_text = []

response = self.anthropic.messages.create(
model=ANTHROPIC_MODEL, max_tokens=MAX_TOKENS, messages=messages, tools=available_tools
)
for turn in range(MAX_TOOL_TURNS + 1):
response = self.anthropic.messages.create(
model=ANTHROPIC_MODEL,
max_tokens=MAX_TOKENS,
system=SYSTEM_PROMPT,
messages=self.messages,
tools=available_tools,
)

for _ in range(MAX_TOOL_TURNS):
tool_uses = []
for content in response.content:
if content.type == "text":
final_text.append(content.text)
elif content.type == "tool_use":
tool_uses.append(content)
if response.stop_reason == "refusal":
# Drop the refused query so a follow-up doesn't send it again.
del self.messages[history_length:]
final_text.append("[The model declined to answer; the query is not kept in history]")
break

if not tool_uses:
return "\n".join(final_text)
final_text.extend(content.text for content in response.content if content.type == "text")

if response.stop_reason == "max_tokens":
final_text.append("[Reply cut off at the max_tokens limit]")
break
if response.stop_reason != "tool_use":
# The API rejects an empty assistant message, so an empty reply isn't kept.
if response.content:
self.messages.append({"role": "assistant", "content": response.content})
break
if turn == MAX_TOOL_TURNS:
final_text.append(f"[Stopped after {MAX_TOOL_TURNS} tool-use turns]")
break

tool_results = []
for tool_use in tool_uses:
for tool_use in (content for content in response.content if content.type == "tool_use"):
final_text.append(f"[Calling tool {tool_use.name} with args {tool_use.input}]")
try:
# call_tool validates the result against the declared schema.
Expand Down Expand Up @@ -156,27 +173,15 @@ async def process_query(self, query: str) -> str:
}
)

messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})

response = self.anthropic.messages.create(
model=ANTHROPIC_MODEL,
max_tokens=MAX_TOKENS,
messages=messages,
tools=available_tools,
)
self.messages.append({"role": "assistant", "content": response.content})
self.messages.append({"role": "user", "content": tool_results})

# 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):
"""Run an interactive chat loop"""
print("\nMCP Client Started!")
print("Type your queries or 'quit' to exit.")
print("Type your queries, 'clear' to forget the conversation, or 'quit' to exit.")

while True:
# input() blocks, so keep it off the event loop.
Expand All @@ -187,11 +192,20 @@ async def chat_loop(self):

if query.lower() == "quit":
break

if not query:
continue
if query.lower() == "clear":
self.messages.clear()
print("\nConversation cleared.")
continue

history_length = len(self.messages)
try:
response = await self.process_query(query)
print("\n" + response)
except Exception as e:
# Drop the failed query so a follow-up doesn't send it again.
del self.messages[history_length:]
print(f"\nError: {str(e)}")

async def cleanup(self):
Expand Down
4 changes: 3 additions & 1 deletion mcp-client-ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Pass the path to a server script. `.rb` servers are launched with `ruby`, `.py`
bundle exec ruby client.rb ../weather-server-ruby/weather.rb
```

Type a question (for example, "What's the weather in Sacramento?") and Claude answers using the server's tools. Type `quit` to exit.
Type a question (for example, "What's the weather in Sacramento?") and Claude answers using the server's tools. The client keeps the conversation for the whole session, so a follow-up such as "And the alerts for that state?" works. Type `clear` to start a new conversation, or `quit` to exit.

Without an `ANTHROPIC_API_KEY`, the client still connects, prints the server's tools, and exits — useful for verifying the MCP wiring without credentials.

Expand All @@ -40,4 +40,6 @@ The two channels go to different readers: `content` is forwarded to the model, w

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.

Every request sends the same short system prompt. When a reply stops early, the client says so: `[Reply cut off at the max_tokens limit]` when it ran out of tokens, and `[The model declined to answer; the query is not kept in history]` when the model refused. A reply that was cut off, refused, empty or stopped at the tool-turn limit is not added to the history, and a query that fails with an error is dropped from it, so the next request stays valid.

`MCP::Client::Stdio` offers `2026-07-28` by default, so no negotiation setup is needed here.
Loading
Loading