quickstart-resources/tests/helpers/tool-loop-test.ts
Alex Akimov 470d1ba328
Some checks failed
Smoke Tests / test (push) Has been cancelled
Fix the tool-use loop in the Go, Rust, and Ruby clients and add a tool-loop smoke test (#200)
* Fix the tool-use loop in the Go, Rust, and Ruby clients and add a tool-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.

* Improvements

* Address comments
2026-09-19 18:25:02 -07:00

378 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Tool-loop test for clients
*
* Runs a quickstart client through scripted queries and checks that its
* tool-use loop is right. Two stand-ins replace the real world:
*
* - the mock MCP server (mock-mcp-server.ts), passed as part of the client
* command exactly as in the no-key client test;
* - 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.
*
* Three 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
* tool_use for a tool the mock server lacks (an error result), then an
* answer. Checks that tools are passed on every call, max_tokens leaves
* room for thinking, every tool_use gets a tool_result in a single
* following user message with matching ids, and the error is forwarded.
* - "ten tool turns": exactly MAX_TOOL_TURNS tool calls, then an answer. The
* client must print the answer without a premature stop notice.
* - "endless tool turns": a tool call on every response. The client must stop
* after MAX_TOOL_TURNS rounds, say so, and make no further call.
*
* 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
* here and passes the no-key test.
*
* Usage: node tool-loop-test.js <client command> [args...]
*/
import { spawn } from "node:child_process";
import http from "node:http";
/** Sonnet 5 thinks adaptively; the clients reserve this much for it. */
const EXPECTED_MAX_TOKENS = 10000;
/** MAX_TOOL_TURNS in every client. */
const MAX_TOOL_TURNS = 10;
const STOP_NOTICE = `[Stopped after ${MAX_TOOL_TURNS} tool-use turns]`;
/** Every client prints this before reading a query. */
const PROMPT = "Query: ";
const CLIENT_TIMEOUT_MS = 180_000;
const QUERIES = {
parallel: "parallel tools",
atLimit: "ten tool turns",
overrun: "endless tool turns",
} as const;
type Scenario = keyof typeof QUERIES;
const FINAL_ANSWER: Record<Scenario, string> = {
parallel: "All done.",
atLimit: "Answered at the limit.",
overrun: "never sent",
};
type Block = Record<string, unknown> & { type: string };
type Message = { role: string; content: string | Block[] };
type Request = {
model?: string;
max_tokens?: number;
tools?: unknown[];
messages: Message[];
};
const failures: string[] = [];
let passed = 0;
const calls: Record<Scenario, number> = { parallel: 0, atLimit: 0, overrun: 0 };
function check(condition: boolean, what: string) {
if (condition) {
passed++;
} else {
failures.push(what);
}
}
function toolResultsOf(message: Message | undefined): Block[] {
if (!message || !Array.isArray(message.content)) return [];
return message.content.filter((block) => block.type === "tool_result");
}
/** Text content may be a string or a list of text blocks. */
function textOf(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((block) => block?.type === "text")
.map((block) => String(block.text))
.join("\n");
}
return "";
}
/** 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--) {
const message = messages[i];
if (message.role !== "user" || toolResultsOf(message).length > 0) continue;
const text = textOf(message.content).trim();
return (Object.keys(QUERIES) as Scenario[]).find((key) => QUERIES[key] === text);
}
return undefined;
}
const THINKING: Block = {
type: "thinking",
thinking: "Two lookups are needed.",
signature: "test-signature",
};
const REDACTED_THINKING: Block = { type: "redacted_thinking", data: "test-redacted-data" };
function toolUse(id: string, name: string, input: Record<string, unknown>): Block {
return { type: "tool_use", id, name, input };
}
function text(value: string): Block {
return { type: "text", text: value };
}
/** The scripted assistant turn for the parallel-tools query. */
function respondParallel(n: number, request: Request): Block[] {
const label = `parallel call ${n}`;
const { messages } = request;
const last = messages[messages.length - 1];
const results = toolResultsOf(last);
if (n === 1) {
check(messages.length === 1 && last.role === "user", `${label}: history is the user query`);
// Thinking comes before the tool calls, as it does from the real model.
return [
THINKING,
REDACTED_THINKING,
text("Checking two things."),
toolUse("tu_1", "get_alerts", { state: "CA" }),
toolUse("tu_2", "get_forecast", { latitude: 38.58, longitude: -121.49 }),
];
}
if (n === 2) {
check(messages.length === 3, `${label}: history has 3 messages (got ${messages.length})`);
check(messages[1]?.role === "assistant", `${label}: assistant turn is kept in history`);
// The API rejects a follow-up whose assistant turn lost its thinking
// blocks, so they must be echoed back unchanged.
const echoed = Array.isArray(messages[1]?.content) ? messages[1].content : [];
const thinking = echoed.find((block) => block.type === "thinking");
const redacted = echoed.find((block) => block.type === "redacted_thinking");
check(
thinking?.thinking === THINKING.thinking && thinking?.signature === THINKING.signature,
`${label}: thinking block is passed back with its signature`,
);
check(
redacted?.data === REDACTED_THINKING.data,
`${label}: redacted thinking block is passed back`,
);
check(
last.role === "user" && results.length === 2,
`${label}: both tool_results arrive in one user message (got ${results.length})`,
);
const ids = results.map((block) => block.tool_use_id).sort().join(",");
check(ids === "tu_1,tu_2", `${label}: tool_use_ids match (got ${ids})`);
check(
results.every((block) => textOf(block.content).length > 0),
`${label}: tool_result content carries the tool's text`,
);
check(
results.every((block) => block.is_error !== true),
`${label}: successful results are not flagged as errors`,
);
return [toolUse("tu_3", "no_such_tool", {})];
}
if (n === 3) {
check(messages.length === 5, `${label}: history has 5 messages (got ${messages.length})`);
check(
results.length === 1 && results[0].tool_use_id === "tu_3",
`${label}: second-step tool_result is present`,
);
check(results[0]?.is_error === true, `${label}: error result is forwarded as is_error`);
return [text(FINAL_ANSWER.parallel)];
}
check(false, `${label}: unexpected extra call`);
return [text("extra")];
}
/**
* The scripted turn for the two turn-limit queries. Each response asks for one
* more tool call; the previous call's result must be present. At the limit the
* script answers on call MAX_TOOL_TURNS + 1; past it, it never stops asking.
*/
function respondTurnLimit(scenario: "atLimit" | "overrun", n: number, request: Request): Block[] {
const label = `${scenario === "atLimit" ? "at-limit" : "overrun"} call ${n}`;
const prefix = scenario === "atLimit" ? "tl" : "ov";
const last = request.messages[request.messages.length - 1];
const results = toolResultsOf(last);
if (n > 1) {
check(
results.length === 1 && results[0].tool_use_id === `${prefix}_${n - 1}`,
`${label}: previous tool_result is present`,
);
}
// The initial call plus MAX_TOOL_TURNS follow-ups is the most a client makes.
if (n > MAX_TOOL_TURNS + 1) {
check(false, `${label}: client called past the turn limit`);
return [text("extra")];
}
if (scenario === "atLimit" && n === MAX_TOOL_TURNS + 1) {
return [text(FINAL_ANSWER.atLimit)];
}
// Distinct tools per scenario, so their "[Calling tool ...]" lines can be
// told apart in the client's output.
return scenario === "atLimit"
? [toolUse(`${prefix}_${n}`, "get_forecast", { latitude: 40.7, longitude: -74.0 })]
: [toolUse(`${prefix}_${n}`, "get_alerts", { state: "NY" })];
}
function respond(request: Request): Block[] {
const scenario = scenarioOf(request.messages);
if (!scenario) {
check(false, "request belongs to a known query");
return [text("unknown query")];
}
const n = ++calls[scenario];
check(
Array.isArray(request.tools) && request.tools.length === 2,
`${scenario} call ${n}: tools are passed (got ${request.tools?.length ?? "none"})`,
);
check(
request.max_tokens === EXPECTED_MAX_TOKENS,
`${scenario} call ${n}: max_tokens is ${EXPECTED_MAX_TOKENS} (got ${request.max_tokens})`,
);
return scenario === "parallel" ? respondParallel(n, request) : respondTurnLimit(scenario, n, request);
}
function startFakeApi(): Promise<http.Server> {
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
let request: Request;
try {
request = JSON.parse(body) as Request;
} catch {
check(false, "request body is JSON");
res.writeHead(400).end();
return;
}
const content = respond(request);
const stopReason = 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)}`,
type: "message",
role: "assistant",
model: request.model,
content,
stop_reason: stopReason,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
}),
);
});
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
}
function runClient(
command: string,
args: string[],
baseUrl: string,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(command, args, {
env: { ...process.env, ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_API_KEY: "test-key" },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
// 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"];
let prompts = 0;
child.stdout.on("data", (chunk) => {
stdout += chunk;
const seen = stdout.split(PROMPT).length - 1;
while (prompts < seen && lines.length > 0) {
prompts++;
child.stdin.write(`${lines.shift()}\n`);
}
});
child.stderr.on("data", (chunk) => (stderr += chunk));
const timer = setTimeout(() => {
failures.push(`client did not exit within ${CLIENT_TIMEOUT_MS / 1000}s`);
child.kill();
}, CLIENT_TIMEOUT_MS);
child.on("error", (error) => {
clearTimeout(timer);
failures.push(`could not start client: ${error.message}`);
resolve({ code: null, stdout, stderr });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
}
/** Checks on the client's output once it has exited. */
function checkOutput(code: number | null, stdout: string) {
check(code === 0, `client exited 0 (got ${code})`);
check(calls.parallel === 3, `parallel: API was called 3 times (got ${calls.parallel})`);
check(stdout.includes(FINAL_ANSWER.parallel), "parallel: client printed the final answer");
const expected = MAX_TOOL_TURNS + 1;
check(calls.atLimit === expected, `at-limit: API was called ${expected} times (got ${calls.atLimit})`);
check(calls.overrun === expected, `overrun: API was called ${expected} times (got ${calls.overrun})`);
// Output arrives in query order. The at-limit answer is followed by the
// overrun query's first tool call; a stop notice between the two is wrong,
// and exactly one must follow.
const answerAt = stdout.indexOf(FINAL_ANSWER.atLimit);
const overrunAt = answerAt < 0 ? -1 : stdout.indexOf("[Calling tool get_alerts", answerAt);
check(answerAt >= 0, "at-limit: client printed the final answer");
check(overrunAt > answerAt, "overrun: client called the tool");
if (answerAt >= 0 && overrunAt > answerAt) {
check(
!stdout.slice(answerAt, overrunAt).includes(STOP_NOTICE),
"at-limit: no stop notice after an answer at the limit",
);
const notices = stdout.slice(overrunAt).split(STOP_NOTICE).length - 1;
check(notices === 1, `overrun: stop notice printed once (got ${notices})`);
}
}
async function main() {
const [command, ...args] = process.argv.slice(2);
if (!command) {
console.error("Usage: node tool-loop-test.js <client command> [args...]");
process.exit(1);
}
const api = await startFakeApi();
const address = api.address();
if (!address || typeof address === "string") throw new Error("no port");
const baseUrl = `http://127.0.0.1:${address.port}`;
console.error(`Testing client tool loop: ${command} ${args.join(" ")}`);
const { code, stdout, stderr } = await runClient(command, args, baseUrl);
api.close();
checkOutput(code, stdout);
if (failures.length === 0) {
console.error(`✓ Tool loop correct (${passed} checks)`);
return;
}
console.error(`✗ Tool loop failed ${failures.length} of ${passed + failures.length} checks:`);
for (const failure of failures) console.error(` - ${failure}`);
console.error("--- client stdout ---");
console.error(stdout.trim());
console.error("--- client stderr ---");
console.error(stderr.trim());
process.exit(1);
}
main().catch((error) => {
console.error("✗ Tool loop test crashed:", error);
process.exit(1);
});