From 971a9194eb8a2c9029a9b69605a850d01a9580b0 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Tue, 3 Nov 2026 23:20:57 +0100 Subject: [PATCH 1/4] Closes #274 and #275 Keep conversation history and handle stop_reason in the clients All five clients now keep the message list for the whole session, tool_use and tool_result pairs included, so a follow-up like "and the alerts for that state?" has the earlier turn to work from. A new `clear` command starts a fresh conversation. Every request sends the same short system prompt, and the loop checks stop_reason after each call: - max_tokens: print the partial text and a notice. - refusal: print a notice and drop the query from history, so a follow-up doesn't send it again. A reply that was cut off, refused or stopped at the tool-turn limit is not added to history, so the next request stays valid. The loop now calls the API in one place instead of two. The tool-loop test now types seven queries plus `clear`. It checks the system prompt and the history on every request, and adds a refusal, a max_tokens cut-off and a query after `clear`. --- README.md | 2 +- mcp-client-go/README.md | 4 +- mcp-client-go/main.go | 108 +++++++++++------------- mcp-client-python/README.md | 4 +- mcp-client-python/client.py | 65 ++++++++------- mcp-client-ruby/README.md | 4 +- mcp-client-ruby/client.rb | 84 ++++++++++--------- mcp-client-rust/README.md | 4 +- mcp-client-rust/src/main.rs | 111 +++++++++++++------------ mcp-client-typescript/README.md | 4 +- mcp-client-typescript/index.ts | 92 ++++++++++---------- tests/README.md | 13 +-- tests/helpers/tool-loop-test.ts | 143 +++++++++++++++++++++++++++++--- tests/smoke-test.sh | 10 +-- 14 files changed, 390 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index 7a26f1c1..e684a366 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/mcp-client-go/README.md b/mcp-client-go/README.md index 8c9f2360..9d749a4a 100644 --- a/mcp-client-go/README.md +++ b/mcp-client-go/README.md @@ -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. @@ -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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. diff --git a/mcp-client-go/main.go b/mcp-client-go/main.go index 6482c3fe..03cf8d1c 100644 --- a/mcp-client-go/main.go +++ b/mcp-client-go/main.go @@ -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{ @@ -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 } @@ -240,37 +242,56 @@ 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 { + 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))) @@ -310,53 +331,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) @@ -370,6 +355,11 @@ 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 } diff --git a/mcp-client-python/README.md b/mcp-client-python/README.md index dd5a0719..be731590 100644 --- a/mcp-client-python/README.md +++ b/mcp-client-python/README.md @@ -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. @@ -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 or stopped at the tool-turn limit is not added to the history, 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/). diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index f4fa476d..21d35544 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -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"} @@ -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: @@ -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}) tools_response = await self.client.list_tools() available_tools = [ @@ -107,23 +110,35 @@ 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, + ) + + 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 - 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) + final_text.extend(content.text for content in response.content if content.type == "text") - if not tool_uses: - return "\n".join(final_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": + 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. @@ -156,27 +171,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. @@ -187,6 +190,10 @@ async def chat_loop(self): if query.lower() == "quit": break + if query.lower() == "clear": + self.messages.clear() + print("\nConversation cleared.") + continue try: response = await self.process_query(query) diff --git a/mcp-client-ruby/README.md b/mcp-client-ruby/README.md index cb74633f..ba5eb131 100644 --- a/mcp-client-ruby/README.md +++ b/mcp-client-ruby/README.md @@ -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. @@ -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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. + `MCP::Client::Stdio` offers `2026-07-28` by default, so no negotiation setup is needed here. diff --git a/mcp-client-ruby/client.rb b/mcp-client-ruby/client.rb index 32696c63..bfc26f79 100644 --- a/mcp-client-ruby/client.rb +++ b/mcp-client-ruby/client.rb @@ -11,6 +11,7 @@ class MCPClient # 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"].freeze @@ -18,6 +19,7 @@ def initialize @mcp_client = nil @transport = nil @anthropic_client = nil + @messages = [] end def connect_to_server(server_script_path) @@ -50,7 +52,7 @@ def connect_to_server(server_script_path) def chat_loop puts <<~MESSAGE MCP Client Started! - Type your queries or 'quit' to exit. + Type your queries, 'clear' to forget the conversation, or 'quit' to exit. MESSAGE loop do @@ -63,6 +65,12 @@ def chat_loop break if query.downcase == "quit" next if query.empty? + if query.downcase == "clear" + @messages = [] + puts "\nConversation cleared." + next + end + begin response = process_query(query) puts "\n#{response}" @@ -108,7 +116,8 @@ def tool_result_content(result) end def process_query(query) - messages = [{ role: "user", content: query }] + history_length = @messages.length + @messages << { role: "user", content: query } available_tools = @mcp_client.tools.map do |tool| { name: tool.name, description: tool.description, input_schema: tool.input_schema } @@ -116,24 +125,40 @@ def process_query(query) final_text = [] - # 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 - final_text << block.text - when Anthropic::Models::ToolUseBlock - tool_uses << block - end + (0..MAX_TOOL_TURNS).each do |turn| + # Tools are passed on every call so Claude can keep calling them. + response = anthropic_client.messages.create( + model: ANTHROPIC_MODEL, + max_tokens: MAX_TOKENS, + system: SYSTEM_PROMPT, + messages: @messages, + tools: available_tools + ) + + if response.stop_reason == :refusal + # Drop the refused query so a follow-up doesn't send it again. + @messages.slice!(history_length..) + final_text << "[The model declined to answer; the query is not kept in history]" + break end - return final_text.join("\n") if tool_uses.empty? + response.content.grep(Anthropic::Models::TextBlock) { |block| final_text << block.text } + + if response.stop_reason == :max_tokens + final_text << "[Reply cut off at the max_tokens limit]" + break + end + if response.stop_reason != :tool_use + @messages << { role: "assistant", content: assistant_content(response) } + break + end + if turn == MAX_TOOL_TURNS + final_text << "[Stopped after #{MAX_TOOL_TURNS} tool-use turns]" + break + end # Execute every tool call in this response and collect the results. + tool_uses = response.content.grep(Anthropic::Models::ToolUseBlock) tool_results = tool_uses.map do |tool_use| final_text << "[Calling tool #{tool_use.name} with args #{tool_use.input.to_json}]" begin @@ -168,22 +193,11 @@ def process_query(query) } end - # 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 = chat(messages, available_tools) + # Every tool_use needs a matching tool_result, all in one user message. + @messages << { role: "assistant", content: assistant_content(response) } + @messages << { role: "user", content: tool_results } 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 @@ -205,16 +219,6 @@ def assistant_content(response) end end - # 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 @anthropic_client ||= Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"]) end diff --git a/mcp-client-rust/README.md b/mcp-client-rust/README.md index a835f416..295d6b89 100644 --- a/mcp-client-rust/README.md +++ b/mcp-client-rust/README.md @@ -27,7 +27,7 @@ cargo run -- ../weather-server-rust/target/release/weather (Build the server first with `cargo build --release` in its directory.) -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. @@ -48,3 +48,5 @@ rmcp does not validate tool output, so this client compiles each declared `outpu 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. + +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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. diff --git a/mcp-client-rust/src/main.rs b/mcp-client-rust/src/main.rs index 77e83d04..b17c8ae3 100644 --- a/mcp-client-rust/src/main.rs +++ b/mcp-client-rust/src/main.rs @@ -18,6 +18,8 @@ const MODEL_ANTHROPIC: &str = "claude-sonnet-5"; /// plus the reply, so leave room for both. const MAX_TOKENS: u32 = 10000; const MAX_TOOL_TURNS: usize = 10; +const SYSTEM_PROMPT: &str = + "You are a helpful assistant. Use the available tools when they help answer the question."; const ANTHROPIC_API_BASE: &str = "https://api.anthropic.com"; const ANTHROPIC_VERSION: &str = "2023-06-01"; @@ -118,6 +120,7 @@ struct ToolDefinition { struct MessagesRequest<'a> { model: &'a str, max_tokens: u32, + system: &'a str, messages: &'a [Message], tools: &'a [ToolDefinition], } @@ -125,6 +128,7 @@ struct MessagesRequest<'a> { #[derive(Deserialize)] struct MessagesResponse { content: Vec, + stop_reason: Option, } /// The `type` of a content block, when it has one. @@ -139,6 +143,7 @@ struct MCPClient { http: reqwest::Client, session: Option>, tools: Vec, + messages: Vec, /// Compiled `outputSchema` per tool name. rmcp does not validate results, /// so this client does it with the `jsonschema` crate. output_schemas: HashMap, @@ -153,6 +158,7 @@ impl MCPClient { .context("Failed to build HTTP client")?, session: None, tools: Vec::new(), + messages: Vec::new(), output_schemas: HashMap::new(), }) } @@ -247,34 +253,52 @@ impl MCPClient { .as_ref() .context("Client is not connected to any server")?; - let mut messages = vec![Message::user(vec![json!({"type": "text", "text": query})])]; + let history_length = self.messages.len(); + self.messages + .push(Message::user(vec![json!({"type": "text", "text": query})])); let mut final_text = Vec::new(); - // Initial Claude API call with tools - let mut content = self.request_model(&messages).await?; - - // Keep calling tools until Claude answers without one, up to a cap. - for _ in 0..MAX_TOOL_TURNS { - let mut tool_uses = Vec::new(); - for block in &content { - match block_type(block) { - "text" => { - if let Some(text) = block.get("text").and_then(Value::as_str) { - final_text.push(text.to_string()); - } - } - "tool_use" => tool_uses.push(block.clone()), - _ => {} + for turn in 0..=MAX_TOOL_TURNS { + let response = self.request_model(&self.messages).await?; + let stop_reason = response.stop_reason.as_deref().unwrap_or_default(); + + if stop_reason == "refusal" { + // Drop the refused query so a follow-up doesn't send it again. + self.messages.truncate(history_length); + final_text.push( + "[The model declined to answer; the query is not kept in history]".to_string(), + ); + break; + } + + for block in &response.content { + if block_type(block) == "text" + && let Some(text) = block.get("text").and_then(Value::as_str) + { + final_text.push(text.to_string()); } } - if tool_uses.is_empty() { - return Ok(final_text.join("\n")); + if stop_reason == "max_tokens" { + final_text.push("[Reply cut off at the max_tokens limit]".to_string()); + break; + } + if stop_reason != "tool_use" { + self.messages.push(Message::assistant(response.content)); + break; + } + if turn == MAX_TOOL_TURNS { + final_text.push(format!("[Stopped after {MAX_TOOL_TURNS} tool-use turns]")); + break; } // Execute every tool call in this response and collect the results let mut tool_results = Vec::new(); - for tool_use in &tool_uses { + for tool_use in response + .content + .iter() + .filter(|block| block_type(block) == "tool_use") + { let name = tool_use .get("name") .and_then(Value::as_str) @@ -325,40 +349,19 @@ impl MCPClient { })); } - // Append the assistant's response and all tool results, in one user - // message, to the history. Every tool_use needs a matching - // tool_result, and the assistant's blocks go back as they arrived. - messages.push(Message::assistant(content)); - messages.push(Message::user(tool_results)); - - content = self.request_model(&messages).await?; - } - - // The turn cap was hit. Keep the last response's text. If it asked for - // more tools, say they were not run. - let mut wants_tools = false; - for block in &content { - match block_type(block) { - "text" => { - if let Some(text) = block.get("text").and_then(Value::as_str) { - final_text.push(text.to_string()); - } - } - "tool_use" => wants_tools = true, - _ => {} - } - } - if wants_tools { - final_text.push(format!("[Stopped after {MAX_TOOL_TURNS} tool-use turns]")); + // Every tool_use needs a matching tool_result, all in one user + // message, and the assistant's blocks go back as they arrived. + self.messages.push(Message::assistant(response.content)); + self.messages.push(Message::user(tool_results)); } Ok(final_text.join("\n")) } - /// Call Claude with the current history and return the content blocks of - /// its reply, untouched. Tools are passed on every call so Claude can keep - /// calling them across turns. - async fn request_model(&self, messages: &[Message]) -> Result> { + /// Call Claude with the current history and return its reply, with the + /// content blocks untouched. Tools are passed on every call so Claude can + /// keep calling them across turns. + async fn request_model(&self, messages: &[Message]) -> Result { let api_key = std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY is not set")?; // The base URL is configurable for the same reason the official SDKs // make it configurable: to point the client at a local stand-in. @@ -368,6 +371,7 @@ impl MCPClient { let request = MessagesRequest { model: MODEL_ANTHROPIC, max_tokens: MAX_TOKENS, + system: SYSTEM_PROMPT, messages, tools: &self.tools, }; @@ -391,14 +395,12 @@ impl MCPClient { bail!("Anthropic request failed with {status}: {body}"); } - let parsed: MessagesResponse = - serde_json::from_str(&body).context("Unable to parse the Anthropic response")?; - Ok(parsed.content) + serde_json::from_str(&body).context("Unable to parse the Anthropic response") } async fn chat_loop(&mut self) -> Result<()> { println!("\nMCP Client Started!"); - println!("Type your queries or 'quit' to exit."); + println!("Type your queries, 'clear' to forget the conversation, or 'quit' to exit."); let mut stdin = BufReader::new(io::stdin()); let mut input = String::new(); @@ -419,6 +421,11 @@ impl MCPClient { if query.is_empty() { continue; } + if query.eq_ignore_ascii_case("clear") { + self.messages.clear(); + println!("\nConversation cleared."); + continue; + } match self.process_query(query).await { Ok(response) => println!("\n{}", response), diff --git a/mcp-client-typescript/README.md b/mcp-client-typescript/README.md index ea0f0435..6e9a64ac 100644 --- a/mcp-client-typescript/README.md +++ b/mcp-client-typescript/README.md @@ -31,7 +31,7 @@ node build/index.js ../weather-server-typescript/build/index.js (Build the server first — see its README.) -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. @@ -43,4 +43,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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. + `versionNegotiation: { mode: 'auto' }` probes `server/discover` and falls back to the `2025-11-25` handshake; the SDK's default is `'legacy'`. See the [SDK documentation](https://ts.sdk.modelcontextprotocol.io/v2/). diff --git a/mcp-client-typescript/index.ts b/mcp-client-typescript/index.ts index 0292e182..488e56d1 100644 --- a/mcp-client-typescript/index.ts +++ b/mcp-client-typescript/index.ts @@ -14,6 +14,8 @@ const ANTHROPIC_MODEL = "claude-sonnet-5"; // plus the reply, so leave room for both. const MAX_TOKENS = 10000; const MAX_TOOL_TURNS = 10; +const SYSTEM_PROMPT = + "You are a helpful assistant. Use the available tools when they help answer the question."; type ImageMediaType = Anthropic.Base64ImageSource["media_type"]; // Image types the Messages API accepts. @@ -70,6 +72,7 @@ class MCPClient { private _anthropic: Anthropic | null = null; private transport: StdioClientTransport | null = null; private tools: Anthropic.Tool[] = []; + private messages: Anthropic.MessageParam[] = []; constructor() { // 'auto' probes server/discover, falling back to the 2025-11-25 handshake. @@ -137,39 +140,46 @@ class MCPClient { * @param query - The user's input query * @returns Processed response as a string */ - const messages: Anthropic.MessageParam[] = [ - { - role: "user", - content: query, - }, - ]; - - let response = await this.anthropic.messages.create({ - model: ANTHROPIC_MODEL, - max_tokens: MAX_TOKENS, - messages, - tools: this.tools, - }); - + const historyLength = this.messages.length; + this.messages.push({ role: "user", content: query }); const finalText: string[] = []; - for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) { - const toolUses: Anthropic.ToolUseBlock[] = []; + for (let turn = 0; turn <= MAX_TOOL_TURNS; turn++) { + const response = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: MAX_TOKENS, + system: SYSTEM_PROMPT, + messages: this.messages, + tools: this.tools, + }); + + if (response.stop_reason === "refusal") { + // Drop the refused query so a follow-up doesn't send it again. + this.messages.length = historyLength; + finalText.push("[The model declined to answer; the query is not kept in history]"); + break; + } for (const block of response.content) { - if (block.type === "text") { - finalText.push(block.text); - } else if (block.type === "tool_use") { - toolUses.push(block); - } + if (block.type === "text") finalText.push(block.text); } - if (toolUses.length === 0) { - return finalText.join("\n"); + if (response.stop_reason === "max_tokens") { + finalText.push("[Reply cut off at the max_tokens limit]"); + break; + } + if (response.stop_reason !== "tool_use") { + this.messages.push({ role: "assistant", content: response.content }); + break; + } + if (turn === MAX_TOOL_TURNS) { + finalText.push(`[Stopped after ${MAX_TOOL_TURNS} tool-use turns]`); + break; } const toolResults: Anthropic.ToolResultBlockParam[] = []; - for (const toolUse of toolUses) { + for (const toolUse of response.content) { + if (toolUse.type !== "tool_use") continue; const toolArgs = toolUse.input as { [x: string]: unknown } | undefined; finalText.push( `[Calling tool ${toolUse.name} with args ${JSON.stringify(toolArgs)}]`, @@ -211,33 +221,10 @@ class MCPClient { }); } - messages.push({ - role: "assistant", - content: response.content as unknown as Anthropic.ContentBlockParam[], - }); - messages.push({ role: "user", content: toolResults }); - - response = await this.anthropic.messages.create({ - model: ANTHROPIC_MODEL, - max_tokens: MAX_TOKENS, - messages, - tools: this.tools, - }); + this.messages.push({ role: "assistant", content: response.content }); + this.messages.push({ role: "user", content: toolResults }); } - // The turn cap was hit. Keep the last response's text. If it asked for - // more tools, say they were not run. - let wantsTools = false; - for (const block of response.content) { - if (block.type === "text") { - finalText.push(block.text); - } else if (block.type === "tool_use") { - wantsTools = true; - } - } - if (wantsTools) { - finalText.push(`[Stopped after ${MAX_TOOL_TURNS} tool-use turns]`); - } return finalText.join("\n"); } @@ -259,7 +246,7 @@ class MCPClient { try { console.log("\nMCP Client Started!"); - console.log("Type your queries or 'quit' to exit."); + console.log("Type your queries, 'clear' to forget the conversation, or 'quit' to exit."); while (true) { let message: string; @@ -270,6 +257,11 @@ class MCPClient { } if (message.toLowerCase() === "quit") break; + if (message.toLowerCase() === "clear") { + this.messages = []; + console.log("\nConversation cleared."); + continue; + } try { const response = await this.processQuery(message); diff --git a/tests/README.md b/tests/README.md index 55f7e4a0..9acc9b77 100644 --- a/tests/README.md +++ b/tests/README.md @@ -23,16 +23,19 @@ Tool calls reach the live NWS API. When it is unreachable the tools return an er ## Tool loop -Connecting and listing tools does not exercise the chat loop, so each client is also driven through four scripted queries with `tool-loop-test.ts`. No real API is involved: the helper starts a fake Anthropic Messages API on a loopback port and hands it to the client through `ANTHROPIC_BASE_URL`, which every quickstart SDK reads. The client talks to the mock MCP server as in the no-key test. The helper types each query only after the client shows its `Query:` prompt, as a person would. +Connecting and listing tools does not exercise the chat loop, so each client is also driven through seven scripted queries with `tool-loop-test.ts`. No real API is involved: the helper starts a fake Anthropic Messages API on a loopback port and hands it to the client through `ANTHROPIC_BASE_URL`, which every quickstart SDK reads. The client talks to the mock MCP server as in the no-key test. The helper types each query only after the client shows its `Query:` prompt, as a person would. -The fake API picks a script from the query text and checks every request the client sends: +The fake API picks a script from the query text and checks every request the client sends. Every request must carry the same system prompt, and its history must be one the real API accepts: every `tool_use` answered by a `tool_result` in the next message. - **parallel tools** — a response with two `tool_use` blocks, then one for a tool the mock server lacks, then an answer. `tools` must be passed on every call, `max_tokens` must be 10000 (room for the model's adaptive thinking), every `tool_use` must get a `tool_result` in a single following user message with matching `tool_use_id`s, and the MCP `isError` result must be forwarded as `is_error`. - **ten tool turns** — exactly `MAX_TOOL_TURNS` tool calls, then an answer. The client must print the answer and no stop notice: nothing was cut short. - **endless tool turns** — a tool call on every response. The client must stop after `MAX_TOOL_TURNS` rounds, print `[Stopped after 10 tool-use turns]` once, and make no further call. -- **tool result contents** — one call to each of three tools whose results need translating before they reach the model. A call the server rejects with a JSON-RPC error must come back as a `tool_result` with `is_error` set, and the query must go on. A result with `structuredContent` and only an empty text block must arrive as its JSON. A result with an image, a text resource and an audio block must arrive as an Anthropic base64 image block, the resource's text, and the placeholder `[unsupported result type: audio]`. Empty text from a text block or a resource must be dropped: like the real API, the fake one fails any request that carries an empty text block. +- **tool result contents** — one call to each of three tools whose results need translating before they reach the model. A call the server rejects with a JSON-RPC error must come back as a `tool_result` with `is_error` set, and the query must go on. A result with `structuredContent` and only an empty text block must arrive as its JSON. A result with an image, a text resource and an audio block must arrive as an Anthropic base64 image block, the resource's text, and the placeholder `[unsupported result type: audio]`. Empty text from a text block or a resource must be dropped: like the real API, the fake one fails any request that carries an empty text block. The history must also still hold the first query and its answer: the client keeps the conversation. +- **refused query** — a response with `stop_reason: "refusal"` and no content. The client must print `[The model declined to answer; the query is not kept in history]`, and the next request must not carry the refused query. +- **cut off reply** — partial text with `stop_reason: "max_tokens"`. The client must print the text and `[Reply cut off at the max_tokens limit]`. +- **after clear** — typed after `clear`. The request must carry this query alone. -Finally the client must exit 0 on `quit`. A client that does one tool round and stops, drops `tools` on the follow-up call, sends one `tool_result` per message, gets the turn cap wrong, or drops what a tool returned passes the no-key test and fails this one. +Finally the client must exit 0 on `quit`. A client that does one tool round and stops, drops `tools` on the follow-up call, sends one `tool_result` per message, gets the turn cap wrong, drops what a tool returned, forgets the conversation, or ignores `stop_reason` passes the no-key test and fails this one. All five clients are covered, including Rust: it calls the Messages API directly, so it honours `ANTHROPIC_BASE_URL` like the official SDKs do. @@ -114,7 +117,7 @@ node tests/helpers/build/mock-mcp-server.js ### tool-loop-test.ts -Runs a client through the scripted tool loop described above. It starts the fake Anthropic API, spawns the client command with `ANTHROPIC_BASE_URL` and a dummy `ANTHROPIC_API_KEY` set, types the four queries and then `quit` at the client's prompts, and reports which checks failed along with the client's output. +Runs a client through the scripted tool loop described above. It starts the fake Anthropic API, spawns the client command with `ANTHROPIC_BASE_URL` and a dummy `ANTHROPIC_API_KEY` set, types the queries, `clear` before the last one, and then `quit` at the client's prompts, and reports which checks failed along with the client's output. **Usage**: diff --git a/tests/helpers/tool-loop-test.ts b/tests/helpers/tool-loop-test.ts index 68dfe121..f72b18b7 100644 --- a/tests/helpers/tool-loop-test.ts +++ b/tests/helpers/tool-loop-test.ts @@ -10,7 +10,7 @@ * - a fake Anthropic Messages API, started here on a loopback port and handed * to the client through ANTHROPIC_BASE_URL, which every quickstart SDK reads. * - * Four queries are piped into one client process, and the fake API picks the + * Seven queries are piped into one client process, and the fake API picks the * script from the query text: * * - "parallel tools": text plus two tool_use blocks in one response, then one @@ -29,6 +29,15 @@ * resource and audio blocks must arrive as an image block, the resource's * text, and a placeholder for the audio. Empty text must be dropped: like * the real API, this one rejects an empty text block in any request. + * - "refused query": a refusal. The client must print a notice and drop the + * query from its history. + * - "cut off reply": a reply stopped by max_tokens. The client must print the + * partial text and a notice. + * - "after clear", typed after `clear`: the history must be empty again. + * + * Every request must carry the same system prompt, every query after the first + * must carry the earlier conversation, and the history must be one the API + * accepts: every tool_use answered by a tool_result in the next message. * * A client that does one tool round and stops, drops `tools` on the second * call, sends one tool_result per message, or gets the turn cap wrong fails @@ -57,6 +66,9 @@ const QUERIES = { atLimit: "ten tool turns", overrun: "endless tool turns", contents: "tool result contents", + refused: "refused query", + cutOff: "cut off reply", + afterClear: "after clear", } as const; type Scenario = keyof typeof QUERIES; @@ -65,12 +77,35 @@ const FINAL_ANSWER: Record = { atLimit: "Answered at the limit.", overrun: "never sent", contents: "Contents handled.", + refused: "never sent", + cutOff: "Partial reply", + afterClear: "Fresh start.", }; +/** What the test types, in order: every query, with `clear` before the last. */ +const INPUT = [ + QUERIES.parallel, + QUERIES.atLimit, + QUERIES.overrun, + QUERIES.contents, + QUERIES.refused, + QUERIES.cutOff, + "clear", + QUERIES.afterClear, + "quit", +]; + +/** SYSTEM_PROMPT in every client. */ +const SYSTEM_PROMPT = + "You are a helpful assistant. Use the available tools when they help answer the question."; +const REFUSAL_NOTICE = "[The model declined to answer; the query is not kept in history]"; +const MAX_TOKENS_NOTICE = "[Reply cut off at the max_tokens limit]"; + type Block = Record & { type: string }; type Message = { role: string; content: string | Block[] }; type Request = { model?: string; + system?: unknown; max_tokens?: number; tools?: unknown[]; messages: Message[]; @@ -78,7 +113,15 @@ type Request = { const failures: string[] = []; let passed = 0; -const calls: Record = { parallel: 0, atLimit: 0, overrun: 0, contents: 0 }; +const calls: Record = { + parallel: 0, + atLimit: 0, + overrun: 0, + contents: 0, + refused: 0, + cutOff: 0, + afterClear: 0, +}; function check(condition: boolean, what: string) { if (condition) { @@ -114,6 +157,37 @@ function hasEmptyText(value: unknown): boolean { return Object.values(block).some(hasEmptyText); } +/** Whether a message with this role and this text is in the history. */ +function historyHas(messages: Message[], role: string, value: string): boolean { + return messages.some( + (message) => message.role === role && textOf(message.content).includes(value), + ); +} + +/** + * Why the API would reject this history, if it would. Consecutive user + * messages are fine (the API joins them); an unanswered tool_use is not. + */ +function historyProblem(messages: Message[]): string | undefined { + if (messages[0]?.role !== "user") return "history must start with a user message"; + if (messages[messages.length - 1]?.role !== "user") { + return "history must end with a user message"; + } + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (message.role !== "assistant") continue; + if (messages[i - 1]?.role === "assistant") return `two assistant messages in a row at ${i}`; + const blocks = Array.isArray(message.content) ? message.content : []; + const ids = blocks.filter((block) => block.type === "tool_use").map((block) => block.id); + if (ids.length === 0) continue; + const answered = toolResultsOf(messages[i + 1]).map((block) => block.tool_use_id); + if (ids.sort().join(",") !== answered.sort().join(",")) { + return `tool_use in message ${i} is not answered by the next message`; + } + } + return undefined; +} + /** The scenario a request belongs to: named by its most recent user query. */ function scenarioOf(messages: Message[]): Scenario | undefined { for (let i = messages.length - 1; i >= 0; i--) { @@ -215,6 +289,11 @@ function respondContents(n: number, request: Request): Block[] { const last = request.messages[request.messages.length - 1]; if (n === 1) { + check( + historyHas(request.messages, "user", QUERIES.parallel) && + historyHas(request.messages, "assistant", FINAL_ANSWER.parallel), + `${label}: earlier queries and answers are kept in history`, + ); return [ toolUse("tc_1", "rejected_call", {}), toolUse("tc_2", "structured_only", {}), @@ -302,13 +381,23 @@ function respondTurnLimit(scenario: "atLimit" | "overrun", n: number, request: R : [toolUse(`${prefix}_${n}`, "get_alerts", { state: "NY" })]; } -function respond(request: Request): Block[] { +type Reply = { content: Block[]; stopReason?: string }; + +function respond(request: Request): Reply { const scenario = scenarioOf(request.messages); if (!scenario) { check(false, "request belongs to a known query"); - return [text("unknown query")]; + return { content: [text("unknown query")] }; } const n = ++calls[scenario]; + const label = `${scenario} call ${n}`; + + check( + textOf(request.system) === SYSTEM_PROMPT, + `${label}: system prompt is sent (got ${JSON.stringify(request.system)})`, + ); + const problem = historyProblem(request.messages); + check(problem === undefined, `${label}: history is valid (${problem})`); check( Array.isArray(request.tools) && request.tools.length === MOCK_TOOL_COUNT, @@ -323,9 +412,32 @@ function respond(request: Request): Block[] { `${scenario} call ${n}: max_tokens is ${EXPECTED_MAX_TOKENS} (got ${request.max_tokens})`, ); - if (scenario === "parallel") return respondParallel(n, request); - if (scenario === "contents") return respondContents(n, request); - return respondTurnLimit(scenario, n, request); + if (scenario === "refused") { + // A refusal before any output comes back with no content. + return { content: [], stopReason: "refusal" }; + } + if (scenario === "cutOff") { + check( + !historyHas(request.messages, "user", QUERIES.refused), + `${label}: the refused query is not kept in history`, + ); + check( + historyHas(request.messages, "assistant", FINAL_ANSWER.contents), + `${label}: earlier answers are still kept in history`, + ); + return { content: [text(FINAL_ANSWER.cutOff)], stopReason: "max_tokens" }; + } + if (scenario === "afterClear") { + check( + request.messages.length === 1, + `${label}: history is empty after clear (got ${request.messages.length} messages)`, + ); + return { content: [text(FINAL_ANSWER.afterClear)] }; + } + + if (scenario === "parallel") return { content: respondParallel(n, request) }; + if (scenario === "contents") return { content: respondContents(n, request) }; + return { content: respondTurnLimit(scenario, n, request) }; } function startFakeApi(): Promise { @@ -341,10 +453,9 @@ function startFakeApi(): Promise { res.writeHead(400).end(); return; } - const content = respond(request); - const stopReason = content.some((block) => block.type === "tool_use") - ? "tool_use" - : "end_turn"; + const { content, stopReason: scripted } = respond(request); + const stopReason = + scripted ?? (content.some((block) => block.type === "tool_use") ? "tool_use" : "end_turn"); res.setHeader("content-type", "application/json"); res.end( JSON.stringify({ @@ -378,7 +489,7 @@ function runClient( // Type each line only once the client shows its prompt, as a user would. // Lines written ahead of time are dropped by readline-based clients, and // closing stdin early ends the session before the later queries. - const lines = [...Object.values(QUERIES), "quit"]; + const lines = [...INPUT]; let prompts = 0; child.stdout.on("data", (chunk) => { stdout += chunk; @@ -433,6 +544,14 @@ function checkOutput(code: number | null, stdout: string) { check(calls.contents === 2, `contents: API was called 2 times (got ${calls.contents})`); check(stdout.includes(FINAL_ANSWER.contents), "contents: client printed the final answer"); + + check(calls.refused === 1, `refused: API was called once (got ${calls.refused})`); + check(stdout.includes(REFUSAL_NOTICE), "refused: client printed the refusal notice"); + check(calls.cutOff === 1, `cut off: API was called once (got ${calls.cutOff})`); + check(stdout.includes(FINAL_ANSWER.cutOff), "cut off: client printed the partial reply"); + check(stdout.includes(MAX_TOKENS_NOTICE), "cut off: client printed the max_tokens notice"); + check(calls.afterClear === 1, `after clear: API was called once (got ${calls.afterClear})`); + check(stdout.includes(FINAL_ANSWER.afterClear), "after clear: client printed the answer"); } async function main() { diff --git a/tests/smoke-test.sh b/tests/smoke-test.sh index cfa890c7..d86af1cb 100755 --- a/tests/smoke-test.sh +++ b/tests/smoke-test.sh @@ -156,12 +156,12 @@ test_mcp_client_rust() { # The tool-loop tests drive the chat loop itself, which the no-key tests never # reach. The helper starts a fake Anthropic API on a loopback port, points the -# client at it through ANTHROPIC_BASE_URL, and types four queries at the +# client at it through ANTHROPIC_BASE_URL, and types seven queries at the # client's prompt: two parallel tool calls then a failing one, exactly ten -# tool turns then an answer, tool calls past the turn limit, and three tools -# whose results need translating (a rejected call, structured content with no -# text, and non-text blocks). It checks every request the client sends; see -# tests/README.md for what is checked. +# tool turns then an answer, tool calls past the turn limit, three tools whose +# results need translating, a refusal, a reply cut off by max_tokens, and one +# after `clear`. It checks every request the client sends, the conversation +# history included; see tests/README.md for what is checked. # Test: Python MCP client tool loop test_tool_loop_python() { From 6726b3f28337175501f7b974c73d2d6c74ebf527 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Wed, 4 Nov 2026 00:01:26 +0100 Subject: [PATCH 2/4] Address comments --- mcp-client-python/client.py | 2 ++ mcp-client-typescript/index.ts | 1 + tests/README.md | 6 +++--- tests/helpers/tool-loop-test.ts | 25 +++++++++++++++++++++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index 21d35544..05b7ceb2 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -190,6 +190,8 @@ async def chat_loop(self): if query.lower() == "quit": break + if not query: + continue if query.lower() == "clear": self.messages.clear() print("\nConversation cleared.") diff --git a/mcp-client-typescript/index.ts b/mcp-client-typescript/index.ts index 488e56d1..6c3520e2 100644 --- a/mcp-client-typescript/index.ts +++ b/mcp-client-typescript/index.ts @@ -257,6 +257,7 @@ class MCPClient { } if (message.toLowerCase() === "quit") break; + if (!message.trim()) continue; if (message.toLowerCase() === "clear") { this.messages = []; console.log("\nConversation cleared."); diff --git a/tests/README.md b/tests/README.md index 9acc9b77..1ce9f6ce 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,10 +30,10 @@ The fake API picks a script from the query text and checks every request the cli - **parallel tools** — a response with two `tool_use` blocks, then one for a tool the mock server lacks, then an answer. `tools` must be passed on every call, `max_tokens` must be 10000 (room for the model's adaptive thinking), every `tool_use` must get a `tool_result` in a single following user message with matching `tool_use_id`s, and the MCP `isError` result must be forwarded as `is_error`. - **ten tool turns** — exactly `MAX_TOOL_TURNS` tool calls, then an answer. The client must print the answer and no stop notice: nothing was cut short. - **endless tool turns** — a tool call on every response. The client must stop after `MAX_TOOL_TURNS` rounds, print `[Stopped after 10 tool-use turns]` once, and make no further call. -- **tool result contents** — one call to each of three tools whose results need translating before they reach the model. A call the server rejects with a JSON-RPC error must come back as a `tool_result` with `is_error` set, and the query must go on. A result with `structuredContent` and only an empty text block must arrive as its JSON. A result with an image, a text resource and an audio block must arrive as an Anthropic base64 image block, the resource's text, and the placeholder `[unsupported result type: audio]`. Empty text from a text block or a resource must be dropped: like the real API, the fake one fails any request that carries an empty text block. The history must also still hold the first query and its answer: the client keeps the conversation. +- **tool result contents** — one call to each of three tools whose results need translating before they reach the model. A call the server rejects with a JSON-RPC error must come back as a `tool_result` with `is_error` set, and the query must go on. A result with `structuredContent` and only an empty text block must arrive as its JSON. A result with an image, a text resource and an audio block must arrive as an Anthropic base64 image block, the resource's text, and the placeholder `[unsupported result type: audio]`. Empty text from a text block or a resource must be dropped: like the real API, the fake one fails any request that carries an empty text block. The history must also still hold the first query, its tool calls and results, and its answer: the client keeps the conversation. - **refused query** — a response with `stop_reason: "refusal"` and no content. The client must print `[The model declined to answer; the query is not kept in history]`, and the next request must not carry the refused query. - **cut off reply** — partial text with `stop_reason: "max_tokens"`. The client must print the text and `[Reply cut off at the max_tokens limit]`. -- **after clear** — typed after `clear`. The request must carry this query alone. +- **after clear** — typed after a blank line and `clear`. The blank line must not reach the API, and the request must carry this query alone. Finally the client must exit 0 on `quit`. A client that does one tool round and stops, drops `tools` on the follow-up call, sends one `tool_result` per message, gets the turn cap wrong, drops what a tool returned, forgets the conversation, or ignores `stop_reason` passes the no-key test and fails this one. @@ -117,7 +117,7 @@ node tests/helpers/build/mock-mcp-server.js ### tool-loop-test.ts -Runs a client through the scripted tool loop described above. It starts the fake Anthropic API, spawns the client command with `ANTHROPIC_BASE_URL` and a dummy `ANTHROPIC_API_KEY` set, types the queries, `clear` before the last one, and then `quit` at the client's prompts, and reports which checks failed along with the client's output. +Runs a client through the scripted tool loop described above. It starts the fake Anthropic API, spawns the client command with `ANTHROPIC_BASE_URL` and a dummy `ANTHROPIC_API_KEY` set, types the queries, a blank line and `clear` before the last one, and then `quit` at the client's prompts, and reports which checks failed along with the client's output. **Usage**: diff --git a/tests/helpers/tool-loop-test.ts b/tests/helpers/tool-loop-test.ts index f72b18b7..898d2425 100644 --- a/tests/helpers/tool-loop-test.ts +++ b/tests/helpers/tool-loop-test.ts @@ -33,7 +33,8 @@ * query from its history. * - "cut off reply": a reply stopped by max_tokens. The client must print the * partial text and a notice. - * - "after clear", typed after `clear`: the history must be empty again. + * - "after clear", typed after a blank line and `clear`: the history must be + * empty again, and the blank line must not have been sent. * * Every request must carry the same system prompt, every query after the first * must carry the earlier conversation, and the history must be one the API @@ -82,7 +83,10 @@ const FINAL_ANSWER: Record = { afterClear: "Fresh start.", }; -/** What the test types, in order: every query, with `clear` before the last. */ +/** + * What the test types, in order: every query, with a blank line (which must not + * reach the API) and `clear` before the last. + */ const INPUT = [ QUERIES.parallel, QUERIES.atLimit, @@ -90,6 +94,7 @@ const INPUT = [ QUERIES.contents, QUERIES.refused, QUERIES.cutOff, + "", "clear", QUERIES.afterClear, "quit", @@ -164,6 +169,16 @@ function historyHas(messages: Message[], role: string, value: string): boolean { ); } +/** Whether a tool_use with this id, and a non-empty result for it, are in the history. */ +function toolPairKept(messages: Message[], id: string): boolean { + const blocks = messages.flatMap((message) => + Array.isArray(message.content) ? message.content : [], + ); + const used = blocks.some((block) => block.type === "tool_use" && block.id === id); + const result = blocks.find((block) => block.type === "tool_result" && block.tool_use_id === id); + return used && textOf(result?.content).length > 0; +} + /** * Why the API would reject this history, if it would. Consecutive user * messages are fine (the API joins them); an unanswered tool_use is not. @@ -294,6 +309,12 @@ function respondContents(n: number, request: Request): Block[] { historyHas(request.messages, "assistant", FINAL_ANSWER.parallel), `${label}: earlier queries and answers are kept in history`, ); + for (const id of ["tu_1", "tu_2", "tu_3"]) { + check( + toolPairKept(request.messages, id), + `${label}: earlier tool call ${id} and its result are kept in history`, + ); + } return [ toolUse("tc_1", "rejected_call", {}), toolUse("tc_2", "structured_only", {}), From e7fac129c4829f60b607cd4c83e751cb3337009d Mon Sep 17 00:00:00 2001 From: a-akimov Date: Wed, 4 Nov 2026 19:35:54 +0100 Subject: [PATCH 3/4] Address comments --- mcp-client-go/README.md | 2 +- mcp-client-go/main.go | 8 +++++- mcp-client-python/README.md | 2 +- mcp-client-python/client.py | 7 ++++- mcp-client-ruby/README.md | 2 +- mcp-client-ruby/client.rb | 6 +++- mcp-client-rust/README.md | 2 +- mcp-client-rust/src/main.rs | 12 ++++++-- mcp-client-typescript/README.md | 2 +- mcp-client-typescript/index.ts | 8 +++++- tests/README.md | 10 ++++--- tests/helpers/tool-loop-test.ts | 50 +++++++++++++++++++++++++++++---- tests/smoke-test.sh | 6 ++-- 13 files changed, 94 insertions(+), 23 deletions(-) diff --git a/mcp-client-go/README.md b/mcp-client-go/README.md index 9d749a4a..641dcdce 100644 --- a/mcp-client-go/README.md +++ b/mcp-client-go/README.md @@ -44,4 +44,4 @@ 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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. +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. diff --git a/mcp-client-go/main.go b/mcp-client-go/main.go index 03cf8d1c..400e4d5f 100644 --- a/mcp-client-go/main.go +++ b/mcp-client-go/main.go @@ -277,7 +277,10 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, err break } if response.StopReason != anthropic.StopReasonToolUse { - c.messages = append(c.messages, response.ToParam()) + // 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 { @@ -364,8 +367,11 @@ func (c *MCPClient) ChatLoop(ctx context.Context) error { 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 } diff --git a/mcp-client-python/README.md b/mcp-client-python/README.md index be731590..3e52e1c3 100644 --- a/mcp-client-python/README.md +++ b/mcp-client-python/README.md @@ -45,6 +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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. +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/). diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index 05b7ceb2..563c6d35 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -131,7 +131,9 @@ async def process_query(self, query: str) -> str: final_text.append("[Reply cut off at the max_tokens limit]") break if response.stop_reason != "tool_use": - self.messages.append({"role": "assistant", "content": response.content}) + # 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]") @@ -197,10 +199,13 @@ async def chat_loop(self): 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): diff --git a/mcp-client-ruby/README.md b/mcp-client-ruby/README.md index ba5eb131..126e236e 100644 --- a/mcp-client-ruby/README.md +++ b/mcp-client-ruby/README.md @@ -40,6 +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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. +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. diff --git a/mcp-client-ruby/client.rb b/mcp-client-ruby/client.rb index bfc26f79..2331fff3 100644 --- a/mcp-client-ruby/client.rb +++ b/mcp-client-ruby/client.rb @@ -71,10 +71,13 @@ def chat_loop next end + history_length = @messages.length begin response = process_query(query) puts "\n#{response}" rescue => e + # Drop the failed query so a follow-up doesn't send it again. + @messages.slice!(history_length..) puts "\nError: #{e.message}" end end @@ -149,7 +152,8 @@ def process_query(query) break end if response.stop_reason != :tool_use - @messages << { role: "assistant", content: assistant_content(response) } + # The API rejects an empty assistant message, so an empty reply isn't kept. + @messages << { role: "assistant", content: assistant_content(response) } unless response.content.empty? break end if turn == MAX_TOOL_TURNS diff --git a/mcp-client-rust/README.md b/mcp-client-rust/README.md index 295d6b89..124c2101 100644 --- a/mcp-client-rust/README.md +++ b/mcp-client-rust/README.md @@ -49,4 +49,4 @@ 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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. +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. diff --git a/mcp-client-rust/src/main.rs b/mcp-client-rust/src/main.rs index b17c8ae3..6d07edef 100644 --- a/mcp-client-rust/src/main.rs +++ b/mcp-client-rust/src/main.rs @@ -284,7 +284,10 @@ impl MCPClient { break; } if stop_reason != "tool_use" { - self.messages.push(Message::assistant(response.content)); + // The API rejects an empty assistant message, so an empty reply isn't kept. + if !response.content.is_empty() { + self.messages.push(Message::assistant(response.content)); + } break; } if turn == MAX_TOOL_TURNS { @@ -427,9 +430,14 @@ impl MCPClient { continue; } + let history_length = self.messages.len(); match self.process_query(query).await { Ok(response) => println!("\n{}", response), - Err(err) => println!("\nError: {:#}", err), + Err(err) => { + // Drop the failed query so a follow-up doesn't send it again. + self.messages.truncate(history_length); + println!("\nError: {:#}", err); + } } } diff --git a/mcp-client-typescript/README.md b/mcp-client-typescript/README.md index 6e9a64ac..5b972933 100644 --- a/mcp-client-typescript/README.md +++ b/mcp-client-typescript/README.md @@ -43,6 +43,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 or stopped at the tool-turn limit is not added to the history, so the next request stays valid. +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. `versionNegotiation: { mode: 'auto' }` probes `server/discover` and falls back to the `2025-11-25` handshake; the SDK's default is `'legacy'`. See the [SDK documentation](https://ts.sdk.modelcontextprotocol.io/v2/). diff --git a/mcp-client-typescript/index.ts b/mcp-client-typescript/index.ts index 6c3520e2..1724674c 100644 --- a/mcp-client-typescript/index.ts +++ b/mcp-client-typescript/index.ts @@ -169,7 +169,10 @@ class MCPClient { break; } if (response.stop_reason !== "tool_use") { - this.messages.push({ role: "assistant", content: response.content }); + // The API rejects an empty assistant message, so an empty reply isn't kept. + if (response.content.length > 0) { + this.messages.push({ role: "assistant", content: response.content }); + } break; } if (turn === MAX_TOOL_TURNS) { @@ -264,10 +267,13 @@ class MCPClient { continue; } + const historyLength = this.messages.length; try { const response = await this.processQuery(message); console.log("\n" + response); } catch (e) { + // Drop the failed query so a follow-up doesn't send it again. + this.messages.length = historyLength; console.log(`\nError: ${e instanceof Error ? e.message : String(e)}`); } } diff --git a/tests/README.md b/tests/README.md index 1ce9f6ce..b6c8972d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -23,19 +23,21 @@ Tool calls reach the live NWS API. When it is unreachable the tools return an er ## Tool loop -Connecting and listing tools does not exercise the chat loop, so each client is also driven through seven scripted queries with `tool-loop-test.ts`. No real API is involved: the helper starts a fake Anthropic Messages API on a loopback port and hands it to the client through `ANTHROPIC_BASE_URL`, which every quickstart SDK reads. The client talks to the mock MCP server as in the no-key test. The helper types each query only after the client shows its `Query:` prompt, as a person would. +Connecting and listing tools does not exercise the chat loop, so each client is also driven through nine scripted queries with `tool-loop-test.ts`. No real API is involved: the helper starts a fake Anthropic Messages API on a loopback port and hands it to the client through `ANTHROPIC_BASE_URL`, which every quickstart SDK reads. The client talks to the mock MCP server as in the no-key test. The helper types each query only after the client shows its `Query:` prompt, as a person would. -The fake API picks a script from the query text and checks every request the client sends. Every request must carry the same system prompt, and its history must be one the real API accepts: every `tool_use` answered by a `tool_result` in the next message. +The fake API picks a script from the query text and checks every request the client sends. Every request must carry the same system prompt, and its history must be one the real API accepts: no message empty, and every `tool_use` answered by a `tool_result` in the next message. - **parallel tools** — a response with two `tool_use` blocks, then one for a tool the mock server lacks, then an answer. `tools` must be passed on every call, `max_tokens` must be 10000 (room for the model's adaptive thinking), every `tool_use` must get a `tool_result` in a single following user message with matching `tool_use_id`s, and the MCP `isError` result must be forwarded as `is_error`. - **ten tool turns** — exactly `MAX_TOOL_TURNS` tool calls, then an answer. The client must print the answer and no stop notice: nothing was cut short. - **endless tool turns** — a tool call on every response. The client must stop after `MAX_TOOL_TURNS` rounds, print `[Stopped after 10 tool-use turns]` once, and make no further call. - **tool result contents** — one call to each of three tools whose results need translating before they reach the model. A call the server rejects with a JSON-RPC error must come back as a `tool_result` with `is_error` set, and the query must go on. A result with `structuredContent` and only an empty text block must arrive as its JSON. A result with an image, a text resource and an audio block must arrive as an Anthropic base64 image block, the resource's text, and the placeholder `[unsupported result type: audio]`. Empty text from a text block or a resource must be dropped: like the real API, the fake one fails any request that carries an empty text block. The history must also still hold the first query, its tool calls and results, and its answer: the client keeps the conversation. - **refused query** — a response with `stop_reason: "refusal"` and no content. The client must print `[The model declined to answer; the query is not kept in history]`, and the next request must not carry the refused query. -- **cut off reply** — partial text with `stop_reason: "max_tokens"`. The client must print the text and `[Reply cut off at the max_tokens limit]`. +- **failed query** — a tool call, then an HTTP 400 error from the API. The client must print the error, and the next request must carry neither the query nor its tool call. +- **empty reply** — `stop_reason: "end_turn"` with no content. The API rejects an empty assistant message, so the client must not keep the reply. +- **cut off reply** — partial text with `stop_reason: "max_tokens"`. The client must print the text and `[Reply cut off at the max_tokens limit]`. Its request is where the refused and failed queries are checked. - **after clear** — typed after a blank line and `clear`. The blank line must not reach the API, and the request must carry this query alone. -Finally the client must exit 0 on `quit`. A client that does one tool round and stops, drops `tools` on the follow-up call, sends one `tool_result` per message, gets the turn cap wrong, drops what a tool returned, forgets the conversation, or ignores `stop_reason` passes the no-key test and fails this one. +Finally the client must exit 0 on `quit`. A client that does one tool round and stops, drops `tools` on the follow-up call, sends one `tool_result` per message, gets the turn cap wrong, drops what a tool returned, forgets the conversation, keeps a failed query or an empty reply, or ignores `stop_reason` passes the no-key test and fails this one. All five clients are covered, including Rust: it calls the Messages API directly, so it honours `ANTHROPIC_BASE_URL` like the official SDKs do. diff --git a/tests/helpers/tool-loop-test.ts b/tests/helpers/tool-loop-test.ts index 898d2425..fff14fa0 100644 --- a/tests/helpers/tool-loop-test.ts +++ b/tests/helpers/tool-loop-test.ts @@ -10,7 +10,7 @@ * - a fake Anthropic Messages API, started here on a loopback port and handed * to the client through ANTHROPIC_BASE_URL, which every quickstart SDK reads. * - * Seven queries are piped into one client process, and the fake API picks the + * Nine queries are piped into one client process, and the fake API picks the * script from the query text: * * - "parallel tools": text plus two tool_use blocks in one response, then one @@ -31,6 +31,10 @@ * the real API, this one rejects an empty text block in any request. * - "refused query": a refusal. The client must print a notice and drop the * query from its history. + * - "failed query": a tool call, then an API error. The client must print the + * error and drop the query, tool call included, from its history. + * - "empty reply": an end_turn reply with no content. The API rejects an + * empty assistant message, so the client must not keep it. * - "cut off reply": a reply stopped by max_tokens. The client must print the * partial text and a notice. * - "after clear", typed after a blank line and `clear`: the history must be @@ -38,7 +42,8 @@ * * Every request must carry the same system prompt, every query after the first * must carry the earlier conversation, and the history must be one the API - * accepts: every tool_use answered by a tool_result in the next message. + * accepts: no message empty, and every tool_use answered by a tool_result in + * the next message. * * A client that does one tool round and stops, drops `tools` on the second * call, sends one tool_result per message, or gets the turn cap wrong fails @@ -68,6 +73,8 @@ const QUERIES = { overrun: "endless tool turns", contents: "tool result contents", refused: "refused query", + failed: "failed query", + empty: "empty reply", cutOff: "cut off reply", afterClear: "after clear", } as const; @@ -79,6 +86,8 @@ const FINAL_ANSWER: Record = { overrun: "never sent", contents: "Contents handled.", refused: "never sent", + failed: "never sent", + empty: "never sent", cutOff: "Partial reply", afterClear: "Fresh start.", }; @@ -93,6 +102,8 @@ const INPUT = [ QUERIES.overrun, QUERIES.contents, QUERIES.refused, + QUERIES.failed, + QUERIES.empty, QUERIES.cutOff, "", "clear", @@ -105,6 +116,8 @@ const SYSTEM_PROMPT = "You are a helpful assistant. Use the available tools when they help answer the question."; const REFUSAL_NOTICE = "[The model declined to answer; the query is not kept in history]"; const MAX_TOKENS_NOTICE = "[Reply cut off at the max_tokens limit]"; +/** What the fake API says when it fails the failed query. */ +const API_ERROR = "prompt is too long"; type Block = Record & { type: string }; type Message = { role: string; content: string | Block[] }; @@ -124,6 +137,8 @@ const calls: Record = { overrun: 0, contents: 0, refused: 0, + failed: 0, + empty: 0, cutOff: 0, afterClear: 0, }; @@ -190,6 +205,7 @@ function historyProblem(messages: Message[]): string | undefined { } for (let i = 0; i < messages.length; i++) { const message = messages[i]; + if (message.content.length === 0) return `message ${i} is empty`; if (message.role !== "assistant") continue; if (messages[i - 1]?.role === "assistant") return `two assistant messages in a row at ${i}`; const blocks = Array.isArray(message.content) ? message.content : []; @@ -402,7 +418,8 @@ function respondTurnLimit(scenario: "atLimit" | "overrun", n: number, request: R : [toolUse(`${prefix}_${n}`, "get_alerts", { state: "NY" })]; } -type Reply = { content: Block[]; stopReason?: string }; +/** A scripted reply, or an error for the fake API to fail the request with. */ +type Reply = { content: Block[]; stopReason?: string; error?: string }; function respond(request: Request): Reply { const scenario = scenarioOf(request.messages); @@ -437,11 +454,24 @@ function respond(request: Request): Reply { // A refusal before any output comes back with no content. return { content: [], stopReason: "refusal" }; } + if (scenario === "failed") { + // A tool round first, so rolling back must drop more than the query. + if (n === 1) return { content: [toolUse("rj_1", "get_alerts", { state: "TX" })] }; + return { content: [], error: API_ERROR }; + } + if (scenario === "empty") { + return { content: [], stopReason: "end_turn" }; + } if (scenario === "cutOff") { check( !historyHas(request.messages, "user", QUERIES.refused), `${label}: the refused query is not kept in history`, ); + check( + !historyHas(request.messages, "user", QUERIES.failed) && + !toolPairKept(request.messages, "rj_1"), + `${label}: the failed query and its tool call are not kept in history`, + ); check( historyHas(request.messages, "assistant", FINAL_ANSWER.contents), `${label}: earlier answers are still kept in history`, @@ -474,10 +504,17 @@ function startFakeApi(): Promise { res.writeHead(400).end(); return; } - const { content, stopReason: scripted } = respond(request); + const { content, stopReason: scripted, error } = respond(request); + res.setHeader("content-type", "application/json"); + if (error) { + // 400 rather than 500: the SDKs retry a 500, not a 400. + res.writeHead(400).end( + JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: error } }), + ); + return; + } const stopReason = scripted ?? (content.some((block) => block.type === "tool_use") ? "tool_use" : "end_turn"); - res.setHeader("content-type", "application/json"); res.end( JSON.stringify({ id: `msg_${Object.values(calls).reduce((a, b) => a + b, 0)}`, @@ -568,6 +605,9 @@ function checkOutput(code: number | null, stdout: string) { check(calls.refused === 1, `refused: API was called once (got ${calls.refused})`); check(stdout.includes(REFUSAL_NOTICE), "refused: client printed the refusal notice"); + check(calls.failed === 2, `failed: API was called 2 times (got ${calls.failed})`); + check(stdout.includes(API_ERROR), "failed: client printed the API error"); + check(calls.empty === 1, `empty: API was called once (got ${calls.empty})`); check(calls.cutOff === 1, `cut off: API was called once (got ${calls.cutOff})`); check(stdout.includes(FINAL_ANSWER.cutOff), "cut off: client printed the partial reply"); check(stdout.includes(MAX_TOKENS_NOTICE), "cut off: client printed the max_tokens notice"); diff --git a/tests/smoke-test.sh b/tests/smoke-test.sh index d86af1cb..51c2e1ea 100755 --- a/tests/smoke-test.sh +++ b/tests/smoke-test.sh @@ -156,11 +156,11 @@ test_mcp_client_rust() { # The tool-loop tests drive the chat loop itself, which the no-key tests never # reach. The helper starts a fake Anthropic API on a loopback port, points the -# client at it through ANTHROPIC_BASE_URL, and types seven queries at the +# client at it through ANTHROPIC_BASE_URL, and types nine queries at the # client's prompt: two parallel tool calls then a failing one, exactly ten # tool turns then an answer, tool calls past the turn limit, three tools whose -# results need translating, a refusal, a reply cut off by max_tokens, and one -# after `clear`. It checks every request the client sends, the conversation +# results need translating, a refusal, a tool call then an API error, an empty +# reply, a reply cut off by max_tokens, and one after `clear`. It checks every request the client sends, the conversation # history included; see tests/README.md for what is checked. # Test: Python MCP client tool loop From 23eb321007fabbdc1fe62169f666378fb7801193 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Wed, 4 Nov 2026 19:47:56 +0100 Subject: [PATCH 4/4] Address comments --- mcp-client-typescript/index.ts | 4 ++-- tests/README.md | 4 ++-- tests/helpers/tool-loop-test.ts | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/mcp-client-typescript/index.ts b/mcp-client-typescript/index.ts index 1724674c..8668a5c7 100644 --- a/mcp-client-typescript/index.ts +++ b/mcp-client-typescript/index.ts @@ -254,13 +254,13 @@ class MCPClient { while (true) { let message: string; try { - message = await rl.question("\nQuery: ", { signal: ac.signal }); + message = (await rl.question("\nQuery: ", { signal: ac.signal })).trim(); } catch { break; } if (message.toLowerCase() === "quit") break; - if (!message.trim()) continue; + if (!message) continue; if (message.toLowerCase() === "clear") { this.messages = []; console.log("\nConversation cleared."); diff --git a/tests/README.md b/tests/README.md index b6c8972d..b7eb889a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,7 +35,7 @@ The fake API picks a script from the query text and checks every request the cli - **failed query** — a tool call, then an HTTP 400 error from the API. The client must print the error, and the next request must carry neither the query nor its tool call. - **empty reply** — `stop_reason: "end_turn"` with no content. The API rejects an empty assistant message, so the client must not keep the reply. - **cut off reply** — partial text with `stop_reason: "max_tokens"`. The client must print the text and `[Reply cut off at the max_tokens limit]`. Its request is where the refused and failed queries are checked. -- **after clear** — typed after a blank line and `clear`. The blank line must not reach the API, and the request must carry this query alone. +- **after clear** — typed after a blank line and ` clear ` (spaces around it, which every client must trim). The blank line must not reach the API, and the request must carry this query alone. Finally the client must exit 0 on `quit`. A client that does one tool round and stops, drops `tools` on the follow-up call, sends one `tool_result` per message, gets the turn cap wrong, drops what a tool returned, forgets the conversation, keeps a failed query or an empty reply, or ignores `stop_reason` passes the no-key test and fails this one. @@ -119,7 +119,7 @@ node tests/helpers/build/mock-mcp-server.js ### tool-loop-test.ts -Runs a client through the scripted tool loop described above. It starts the fake Anthropic API, spawns the client command with `ANTHROPIC_BASE_URL` and a dummy `ANTHROPIC_API_KEY` set, types the queries, a blank line and `clear` before the last one, and then `quit` at the client's prompts, and reports which checks failed along with the client's output. +Runs a client through the scripted tool loop described above. It starts the fake Anthropic API, spawns the client command with `ANTHROPIC_BASE_URL` and a dummy `ANTHROPIC_API_KEY` set, types the queries, a blank line and ` clear ` before the last one, and then `quit` at the client's prompts, and reports which checks failed along with the client's output. **Usage**: diff --git a/tests/helpers/tool-loop-test.ts b/tests/helpers/tool-loop-test.ts index fff14fa0..980e2c98 100644 --- a/tests/helpers/tool-loop-test.ts +++ b/tests/helpers/tool-loop-test.ts @@ -37,7 +37,7 @@ * empty assistant message, so the client must not keep it. * - "cut off reply": a reply stopped by max_tokens. The client must print the * partial text and a notice. - * - "after clear", typed after a blank line and `clear`: the history must be + * - "after clear", typed after a blank line and ` clear `: the history must be * empty again, and the blank line must not have been sent. * * Every request must carry the same system prompt, every query after the first @@ -94,7 +94,8 @@ const FINAL_ANSWER: Record = { /** * What the test types, in order: every query, with a blank line (which must not - * reach the API) and `clear` before the last. + * reach the API) and `clear` before the last. `clear` has spaces around it, as + * a command typed carelessly still counts. */ const INPUT = [ QUERIES.parallel, @@ -106,7 +107,7 @@ const INPUT = [ QUERIES.empty, QUERIES.cutOff, "", - "clear", + " clear ", QUERIES.afterClear, "quit", ];