Skip to content

Fix the tool-use loop in the Go, Rust, and Ruby clients and add a tool-loop smoke test - #200

Open
a-akimov wants to merge 3 commits into
modelcontextprotocol:mainfrom
a-akimov:fix/client-agent-loop
Open

a-akimov wants to merge 3 commits into
modelcontextprotocol:mainfrom
a-akimov:fix/client-agent-loop

Conversation

@a-akimov

Copy link
Copy Markdown
Contributor

Motivation and Context

Only the Python and TypeScript clients implemented a correct tool-use loop. The other three had bugs that break ordinary multi-step requests:

  • Go, Rust, Ruby did a single tool round and then called Claude once more without tools, so any request that needs a second tool call stalled. Go silently dropped tool_use blocks from that final response.
  • Ruby called Claude once per tool_use block, each time with a single tool_result. Parallel tool calls produced a malformed message sequence and an API 400.
  • max_tokens was 1000 in TypeScript and Ruby, 1024 in Go, and unset in Rust. Sonnet 5 thinks adaptively, so 1000 tokens is regularly exhausted mid-thought. Python already used 10000 with a comment explaining why.
  • Ruby never forwarded MCP isError as is_error, so the model could not tell a failed tool call from a successful one. Rust cannot set the flag either (see below).
  • TypeScript forwarded raw MCP content blocks to Anthropic. The two block shapes differ, so a non-text result would have failed.

The existing smoke test never noticed because it only checks that each client calls tools/list. None of these bugs was reachable without an API key.

What changed

All five clients now run the same loop: call tools until Claude answers without one, send every tool_result of a turn in one user message, pass tools on every call, and stop after MAX_TOOL_TURNS = 10. max_tokens is 10000 everywhere, with the same comment. Python and TypeScript also keep the last response's text when the turn cap is hit; it used to be dropped.

Rust keeps the genai crate. Its tool-response type has no is_error field, so an error result is marked with an Error: prefix in the text and the limitation is commented.

New smoke test

tests/helpers/tool-loop-test.ts starts a fake Anthropic Messages API on a loopback port, points a client at it through ANTHROPIC_BASE_URL, pipes in one query, and checks every request the client sends. It scripts two parallel tool calls, then a call to a tool the mock server lacks (an isError result), then a final answer, and asserts that tools are passed each time, max_tokens is 10000, all tool_results arrive in one message with matching ids, and the error is forwarded. smoke-test.sh runs it for Python, TypeScript, Go, and Ruby.

The Rust client is excluded for now: genai reads no endpoint variable, and the client negotiates protocol 2025-11-25, under which the mock's array-rooted tool is refused at call time. Both will be addressed in a follow-up that brings all examples to current SDK versions.

How Has This Been Tested?

  • ./tests/smoke-test.sh passes for every example.
  • The new tool-loop test passes all 19 checks for Python, TypeScript, Go, and Ruby.
  • The Rust client was driven through the same scripted conversation with a temporary endpoint resolver (not committed) and passes.
  • As a negative check, the Ruby client from main was run through the new test. It fails 10 of 19 checks, on exactly the bugs listed above.
  • Go and Ruby were built and run in golang:1.25 and ruby:3.4 containers.

Breaking Changes

None. The clients' command lines, output format, and no-key behavior are unchanged.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Two pre-existing issues surfaced while testing and are left for a follow-up:

  • The Rust client (rmcp 3.1.2) negotiates 2025-11-25. The TypeScript mock server and the Python weather server both reject its calls to array-rooted tools under that revision. Even current rmcp defaults to 2025-11-25, so the client must request 2026-07-28 explicitly.
  • The Go SDK's tool-result helper sends content as a list of text blocks rather than a string. The API accepts both, so it is unchanged.

AI assistance: the analysis, code changes, test helper, and this description were prepared partially manually, partially with Claude Code and reviewed by me.

…l-loop smoke test

## Motivation and Context

Only the Python and TypeScript clients implemented a correct tool-use loop. The other three
had bugs that break ordinary multi-step requests:

- **Go, Rust, Ruby** did a single tool round and then called Claude once more *without*
  `tools`, so any request that needs a second tool call stalled. Go silently dropped
  `tool_use` blocks from that final response.
- **Ruby** called Claude once per `tool_use` block, each time with a single `tool_result`.
  Parallel tool calls produced a malformed message sequence and an API 400.
- **max_tokens** was 1000 in TypeScript and Ruby, 1024 in Go, and unset in Rust. Sonnet 5
  thinks adaptively, so 1000 tokens is regularly exhausted mid-thought. Python already used
  10000 with a comment explaining why.
- **Ruby** never forwarded MCP `isError` as `is_error`, so the model could not tell a failed
  tool call from a successful one. Rust cannot set the flag either (see below).
- **TypeScript** forwarded raw MCP content blocks to Anthropic. The two block shapes differ,
  so a non-text result would have failed.

The existing smoke test never noticed because it only checks that each client calls
`tools/list`. None of these bugs was reachable without an API key.

### What changed

All five clients now run the same loop: call tools until Claude answers without one, send
every `tool_result` of a turn in one user message, pass `tools` on every call, and stop
after `MAX_TOOL_TURNS = 10`. `max_tokens` is 10000 everywhere, with the same comment.
Python and TypeScript also keep the last response's text when the turn cap is hit; it used
to be dropped.

Rust keeps the `genai` crate. Its tool-response type has no `is_error` field, so an error
result is marked with an `Error:` prefix in the text and the limitation is commented.

### New smoke test

`tests/helpers/tool-loop-test.ts` starts a fake Anthropic Messages API on a loopback port,
points a client at it through `ANTHROPIC_BASE_URL`, pipes in one query, and checks every
request the client sends. It scripts two parallel tool calls, then a call to a tool the mock
server lacks (an `isError` result), then a final answer, and asserts that tools are passed
each time, `max_tokens` is 10000, all `tool_result`s arrive in one message with matching
ids, and the error is forwarded. `smoke-test.sh` runs it for Python, TypeScript, Go, and
Ruby.

The Rust client is excluded for now: `genai` reads no endpoint variable, and the client
negotiates protocol `2025-11-25`, under which the mock's array-rooted tool is refused at call
time. Both will be addressed in a follow-up that brings all examples to current SDK versions.

## How Has This Been Tested?

- `./tests/smoke-test.sh` passes for every example.
- The new tool-loop test passes all 19 checks for Python, TypeScript, Go, and Ruby.
- The Rust client was driven through the same scripted conversation with a temporary
  endpoint resolver (not committed) and passes.
- As a negative check, the Ruby client from `main` was run through the new test. It fails 10
  of 19 checks, on exactly the bugs listed above.
- Go and Ruby were built and run in `golang:1.25` and `ruby:3.4` containers.

## Breaking Changes

None. The clients' command lines, output format, and no-key behavior are unchanged.

## Types of changes

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [x] Documentation update

## Checklist

- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

## Additional context

Two pre-existing issues surfaced while testing and are left for a follow-up:

- The Rust client (rmcp 3.1.2) negotiates `2025-11-25`. The TypeScript mock server and the
  Python weather server both reject its calls to array-rooted tools under that revision.
  Even current rmcp defaults to `2025-11-25`, so the client must request `2026-07-28`
  explicitly.
- The Go SDK's tool-result helper sends content as a list of text blocks rather than a
  string. The API accepts both, so it is unchanged.

AI assistance: the analysis, code changes, test helper, and this description were prepared
partially manually, partially with Claude Code and reviewed by me.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Ruby drops required thinking blocks, and the smoke test can incorrectly accept missing is_error flags.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Standardizes multi-turn tool use across MCP clients and adds smoke-test coverage for tool loops.

Changes:

  • Adds capped, repeated tool-call handling with consistent token limits.
  • Preserves final response text and forwards tool errors.
  • Adds scripted smoke tests for Python, TypeScript, Ruby, and Go.
File summaries
File Description
tests/utils.sh Includes the new test helper in build checks.
tests/smoke-test.sh Runs tool-loop tests for four clients.
tests/README.md Documents tool-loop scenarios and usage.
tests/helpers/tool-loop-test.ts Implements the fake API and assertions.
mcp-client-typescript/index.ts Updates token limits and result handling.
mcp-client-rust/src/main.rs Adds a capped multi-turn tool loop.
mcp-client-ruby/client.rb Adds multi-turn execution and error forwarding.
mcp-client-python/client.py Preserves the final capped response.
mcp-client-go/main.go Adds a capped multi-turn tool loop.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread mcp-client-ruby/client.rb
Comment thread tests/helpers/tool-loop-test.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The Rust client still loses signed adaptive-thinking blocks before tool-result follow-ups, causing API rejection.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tests/smoke-test.sh:161

  • This comment is stale: the helper now sends three queries, not one, and two scenarios specifically exercise the exact-limit and over-limit behavior. Describing only the parallel-tool scenario obscures why the additional API calls are expected.
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread mcp-client-rust/src/main.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The TypeScript client silently discards valid non-text MCP tool results.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread mcp-client-typescript/index.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants