Skip to content

Fix tool use in TypeScript client, mirroring #141#147

Open
olaservo wants to merge 2 commits into
modelcontextprotocol:mainfrom
olaservo:fix-ts-client-tool-use
Open

Fix tool use in TypeScript client, mirroring #141#147
olaservo wants to merge 2 commits into
modelcontextprotocol:mainfrom
olaservo:fix-ts-client-tool-use

Conversation

@olaservo
Copy link
Copy Markdown
Member

@olaservo olaservo commented May 31, 2026

Motivation and Context

PR #141 fixed the Anthropic Messages API tool-use handling in the Python client (mcp-client-python/client.py). The TypeScript client (mcp-client-typescript/index.ts) carried the same bugs, but they stayed latent because the smoke test's mock MCP server returns an empty tool list, so the tool-use branch never executes.

The original TS processQuery is not spec-compliant: it sends each tool result back as a plain-text user message (no tool_result block, no tool_use_id), never appends the assistant turn carrying the tool_use block, drops tools from follow-up calls, and has no loop for chained tool calls.

The observable symptom is not a hard API error — the Messages API tolerates the malformed shape (it accepts the result as plain user text). Instead the bug shows up as silently incorrect answers and broken tool chaining. Verified against the real Messages API on the pristine main build with a query that needs two sequential tool calls ("Compare the weather in NYC and San Francisco"): the model called get-forecast for NYC, then — because tools was dropped on the follow-up — could not call it again for SF, so SF was never fetched and the model fabricated the San Francisco half of the comparison. Separately, chatLoop crashes with ERR_USE_AFTER_CLOSE on stdin EOF (Ctrl-D) / SIGINT (Ctrl-C).

This PR mirrors #141 for TypeScript:

  • Reply to each tool_use with a proper tool_result block carrying tool_use_id, and append the assistant's full content (preserving the tool_use block) instead of sending the result as a plain user string.
  • Restructure processQuery into a turn-level loop so parallel tool calls in a single response and chained tool calls across turns both work; pass tools on every follow-up request so the model can keep using them.
  • Add a MAX_TOOL_TURNS = 10 cap with a [Stopped after N tool-use turns] notice to prevent unbounded tool-use loops.
  • Fix chatLoop to exit cleanly on EOF (Ctrl-D) and SIGINT (Ctrl-C). Node's readline/promises question() does not reject on stdin EOF on its own (it just hangs / then throws ERR_USE_AFTER_CLOSE), so an AbortController wired to the readline close event unblocks it.

How Has This Been Tested?

Tested side-by-side against a pristine main build to confirm the fix changes real behavior:

  • tests/smoke-test.sh passes locally (5/5 green).
  • Structural verification via a fake /v1/messages endpoint that records requests: confirmed the follow-up call sends [user, assistant(tool_use), user(tool_result)] with tool_use_id, keeps the tools array, and makes a third call when the model chains another tool_use.
  • Manual, real ANTHROPIC_API_KEY against weather-server-typescript:
    • Single tool call ("What's the weather in NYC?") — model receives the tool result and produces a final answer.
    • Chained tool calls ("Compare the weather in NYC and San Francisco.") — both get-forecast calls execute and the final answer is built from real fetched data for both cities. On the pristine main build the same query fetched NYC only and fabricated SF.
  • MAX_TOOL_TURNS cap path verified by lowering the constant locally and confirming the [Stopped after N tool-use turns] notice appears.
  • EOF (Ctrl-D) at the Query: prompt exits cleanly (exit 0) instead of crashing with ERR_USE_AFTER_CLOSE as main does.

Breaking Changes

N/a

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

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

Additional context

Mirrors #141 (which fixed the same issue, originally #28, for the Python client).

🦉 Implementation and verification assisted by Claude Code.

🤖 Generated with Claude Code

olaservo and others added 2 commits May 30, 2026 11:47
Apply the same Messages API tool-use fixes that modelcontextprotocol#141 made to the Python
client (mcp-client-python/client.py) to mcp-client-typescript/index.ts:

- Reply to each tool_use with a proper tool_result block carrying
  tool_use_id, and append the assistant's full content (preserving the
  tool_use block) instead of sending the result as a plain user string.
- Restructure processQuery into a turn-level loop so parallel tool calls
  in one response and chained tool calls across turns both work; pass
  tools on every follow-up request.
- Add MAX_TOOL_TURNS = 10 cap with a "[Stopped after N tool-use turns]"
  notice to prevent unbounded tool-use loops.
- Fix chatLoop to exit cleanly on EOF (Ctrl-D) and SIGINT (Ctrl-C) via
  an AbortController wired to readline close, instead of hanging or
  throwing on stdin EOF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Mirrors the Python client’s fixed tool-use handling (#141) in the TypeScript MCP client so Anthropic Messages API tool-use turns are formed correctly, and the CLI exits cleanly on EOF/SIGINT.

Changes:

  • Reworked processQuery into a turn-level loop that preserves assistant tool_use blocks, replies with tool_result blocks (including tool_use_id), and passes tools on follow-up requests.
  • Added a MAX_TOOL_TURNS cap with a stop notice to prevent unbounded tool-use loops.
  • Updated chatLoop to unblock readline/promises.question() on EOF (Ctrl-D) and SIGINT (Ctrl-C) via an AbortController.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +176 to 181
let message: string;
try {
message = await rl.question("\nQuery: ", { signal: ac.signal });
} catch {
break;
}
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