From 6ab7d8587a82ea065bba96b853cb899ee5b8a367 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Fri, 6 Nov 2026 19:01:04 +0100 Subject: [PATCH 1/5] Make the five clients consistent (#269) All clients now take the server as a command plus its arguments, identify themselves the same way, report usage errors the same way and quit cleanly on Ctrl-C. - Launching: pass the command that starts the server and its arguments, as Go and Rust already did. TypeScript and Ruby no longer start .py servers with bare python3, which failed for the uv-managed Python server - Client info: every client sends mcp-client- / 1.0.0 - Usage errors: printed to stderr, exit status 1 - Ctrl-C: quits like `quit` and shuts the server down. Go and Rust read input on a separate thread; Python reads stdin's raw stream on a daemon thread, so exiting doesn't wait for a pending read - Tools: listed once at connect time in every client (Python listed them again on each query) - Python: import sys at the top, drop the unused pathlib import - .env.example: add the missing Rust one, empty Go placeholder - Tests: the mock server rejects clients that don't send the expected client info, the smoke test launches it as a command and checks the usage error - READMEs: run commands updated for the command-plus-args form Closes #269 --- mcp-client-go/.env.example | 2 +- mcp-client-go/README.md | 4 +- mcp-client-go/main.go | 44 ++++++++++--- mcp-client-python/README.md | 6 +- mcp-client-python/client.py | 95 +++++++++++++++++------------ mcp-client-ruby/README.md | 4 +- mcp-client-ruby/client.rb | 36 +++++------ mcp-client-rust/.env.example | 1 + mcp-client-rust/src/main.rs | 36 +++++++---- mcp-client-typescript/README.md | 4 +- mcp-client-typescript/index.ts | 30 +++------ tests/README.md | 2 +- tests/helpers/mock-mcp-server.ts | 21 ++++++- tests/smoke-test.sh | 33 ++++++++-- weather-server-go/README.md | 2 +- weather-server-python/README.md | 2 +- weather-server-ruby/README.md | 2 +- weather-server-typescript/README.md | 2 +- 18 files changed, 202 insertions(+), 124 deletions(-) create mode 100644 mcp-client-rust/.env.example diff --git a/mcp-client-go/.env.example b/mcp-client-go/.env.example index 7437ef33..8b3e1258 100644 --- a/mcp-client-go/.env.example +++ b/mcp-client-go/.env.example @@ -1 +1 @@ -ANTHROPIC_API_KEY=your_api_key_here +ANTHROPIC_API_KEY= diff --git a/mcp-client-go/README.md b/mcp-client-go/README.md index 641dcdce..9a28db4a 100644 --- a/mcp-client-go/README.md +++ b/mcp-client-go/README.md @@ -23,13 +23,13 @@ cp .env.example .env Pass the command that starts the server — a binary or an interpreter plus its arguments: ```bash -go run main.go ../weather-server-go/weather +go run . ../weather-server-go/weather ``` (Build the server first with `go build -o weather` in its directory.) Any server command works, for example the Python server: ```bash -go run main.go uv --directory ../weather-server-python run weather.py +go run . 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. 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. diff --git a/mcp-client-go/main.go b/mcp-client-go/main.go index 45249efb..1f6e7330 100644 --- a/mcp-client-go/main.go +++ b/mcp-client-go/main.go @@ -9,6 +9,7 @@ import ( "log" "os" "os/exec" + "os/signal" "strings" "github.com/anthropics/anthropic-sdk-go/v2" @@ -125,14 +126,15 @@ func (c *MCPClient) ConnectToServer(ctx context.Context, serverArgs []string) er return fmt.Errorf("no server command provided") } - // Create command to spawn server process - cmd := exec.CommandContext(ctx, serverArgs[0], serverArgs[1:]...) + // Not CommandContext: on Ctrl-C, Cleanup closes the server's stdin and + // lets it exit, rather than the context killing it. + cmd := exec.Command(serverArgs[0], serverArgs[1:]...) // Create MCP client client := mcp.NewClient( &mcp.Implementation{ Name: "mcp-client-go", - Version: "0.1.0", + Version: "1.0.0", }, nil, ) @@ -149,7 +151,7 @@ func (c *MCPClient) ConnectToServer(ctx context.Context, serverArgs []string) er c.session = session - // List available tools + // The tools are listed once, here; this client doesn't follow later changes. toolsResult, err := session.ListTools(ctx, &mcp.ListToolsParams{}) if err != nil { return fmt.Errorf("failed to list tools: %w", err) @@ -346,15 +348,31 @@ func (c *MCPClient) ChatLoop(ctx context.Context) error { fmt.Println("\nMCP Client Started!") fmt.Println("Type your queries, 'clear' to forget the conversation, or 'quit' to exit.") + // Read lines in the background, so Ctrl-C can end the loop while it waits. scanner := bufio.NewScanner(os.Stdin) + lines := make(chan string) + go func() { + defer close(lines) + for scanner.Scan() { + lines <- scanner.Text() + } + }() for { fmt.Print("\nQuery: ") - if !scanner.Scan() { - break // EOF + var line string + select { + case <-ctx.Done(): + fmt.Println() + return nil + case l, ok := <-lines: + if !ok { + return scanner.Err() // EOF + } + line = l } - query := strings.TrimSpace(scanner.Text()) + query := strings.TrimSpace(line) if strings.EqualFold(query, "quit") { break } @@ -369,6 +387,10 @@ func (c *MCPClient) ChatLoop(ctx context.Context) error { historyLength := len(c.messages) response, err := c.ProcessQuery(ctx, query) + if ctx.Err() != nil { + fmt.Println() + return nil // Ctrl-C during a query + } if err != nil { // Drop the failed query so a follow-up doesn't send it again. c.messages = c.messages[:historyLength] @@ -379,7 +401,7 @@ func (c *MCPClient) ChatLoop(ctx context.Context) error { fmt.Printf("\n%s\n", response) } - return scanner.Err() + return nil } func (c *MCPClient) Cleanup() error { @@ -394,7 +416,7 @@ func (c *MCPClient) Cleanup() error { func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "Usage: go run main.go [args...]") + fmt.Fprintln(os.Stderr, "Usage: go run . [args...]") os.Exit(1) } @@ -405,7 +427,9 @@ func main() { log.Fatalf("Failed to create MCP client: %v", err) } - ctx := context.Background() + // Ctrl-C cancels ctx, which ends the chat loop so cleanup still runs. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() if err := client.ConnectToServer(ctx, serverArgs); err != nil { log.Fatalf("Failed to connect to MCP server: %v", err) diff --git a/mcp-client-python/README.md b/mcp-client-python/README.md index 3e52e1c3..024aef78 100644 --- a/mcp-client-python/README.md +++ b/mcp-client-python/README.md @@ -21,16 +21,16 @@ cp .env.example .env ## Run the client -Pass the path to a server script. `.py` servers are launched with `uv`, `.js` servers with `node`: +Pass the command that starts the server, and its arguments: ```bash -uv run client.py ../weather-server-python/weather.py +uv run client.py uv --directory ../weather-server-python run weather.py ``` or, against the TypeScript server (build it first — see its README): ```bash -uv run client.py ../weather-server-typescript/build/index.js +uv run client.py node ../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. 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. diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index 563c6d35..6aaede4b 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -1,13 +1,14 @@ import asyncio import json import os +import sys +import threading from contextlib import AsyncExitStack -from pathlib import Path from anthropic import Anthropic from dotenv import load_dotenv from mcp import Client, MCPError, StdioServerParameters -from mcp_types import CallToolResult, EmbeddedResource, ImageContent, TextContent, TextResourceContents +from mcp_types import CallToolResult, EmbeddedResource, ImageContent, Implementation, TextContent, TextResourceContents load_dotenv() # load environment variables from .env @@ -58,6 +59,7 @@ def __init__(self): self.client: Client | None = None self.exit_stack = AsyncExitStack() self._anthropic: Anthropic | None = None + self.tools: list[dict] = [] self.messages: list[dict] = [] @property @@ -67,47 +69,41 @@ def anthropic(self) -> Anthropic: self._anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) return self._anthropic - async def connect_to_server(self, server_script_path: str): + async def connect_to_server(self, server_command: list[str]): """Connect to an MCP server Args: - server_script_path: Path to the server script (.py or .js) + server_command: The command that starts the server, and its arguments """ - is_python = server_script_path.endswith(".py") - is_js = server_script_path.endswith(".js") - if not (is_python or is_js): - raise ValueError("Server script must be a .py or .js file") - - if is_python: - path = Path(server_script_path).resolve() - server_params = StdioServerParameters( - command="uv", - args=["--directory", str(path.parent), "run", path.name], - env=None, - ) - else: - server_params = StdioServerParameters(command="node", args=[server_script_path], env=None) + server_params = StdioServerParameters(command=server_command[0], args=server_command[1:]) # Client launches the command itself when given StdioServerParameters. - # "auto" probes server/discover, falling back to the 2025-11-25 handshake. - self.client = await self.exit_stack.enter_async_context(Client(server_params, mode="auto")) + # "auto", the SDK's default, probes server/discover, falling back to the + # 2025-11-25 handshake. + self.client = await self.exit_stack.enter_async_context( + Client( + server_params, + mode="auto", + client_info=Implementation(name="mcp-client-python", version="1.0.0"), + ) + ) - # List available tools + # The tools are listed once, here; this client doesn't follow later changes. response = await self.client.list_tools() - tools = response.tools - print(f"\nConnected over protocol {self.client.protocol_version} with tools:", [tool.name for tool in tools]) + self.tools = [ + {"name": tool.name, "description": tool.description, "input_schema": tool.input_schema} + for tool in response.tools + ] + print( + f"\nConnected over protocol {self.client.protocol_version} with tools:", + [tool.name for tool in response.tools], + ) async def process_query(self, query: str) -> str: """Process a query using Claude and available tools""" history_length = len(self.messages) self.messages.append({"role": "user", "content": query}) - tools_response = await self.client.list_tools() - available_tools = [ - {"name": tool.name, "description": tool.description, "input_schema": tool.input_schema} - for tool in tools_response.tools - ] - final_text = [] for turn in range(MAX_TOOL_TURNS + 1): @@ -116,7 +112,7 @@ async def process_query(self, query: str) -> str: max_tokens=MAX_TOKENS, system=SYSTEM_PROMPT, messages=self.messages, - tools=available_tools, + tools=self.tools, ) if response.stop_reason == "refusal": @@ -184,10 +180,9 @@ async def chat_loop(self): print("Type your queries, 'clear' to forget the conversation, or 'quit' to exit.") while True: - # input() blocks, so keep it off the event loop. try: - query = (await asyncio.to_thread(input, "\nQuery: ")).strip() - except (EOFError, KeyboardInterrupt): + query = (await read_line("\nQuery: ")).strip() + except EOFError: break if query.lower() == "quit": @@ -213,14 +208,37 @@ async def cleanup(self): await self.exit_stack.aclose() +async def read_line(prompt: str) -> str: + """input() without blocking the event loop. + + It runs on a daemon thread, so Ctrl-C at the prompt doesn't have to wait + for a line that never comes, as it would with asyncio.to_thread. + """ + loop = asyncio.get_running_loop() + line = loop.create_future() + + def read(): + # The raw stream takes no lock, so exiting mid-read is safe. + data = sys.stdin.buffer.raw.readline() + if data: + loop.call_soon_threadsafe(line.set_result, data.decode()) + else: + loop.call_soon_threadsafe(line.set_exception, EOFError()) + + print(prompt, end="", flush=True) + + threading.Thread(target=read, daemon=True).start() + return await line + + async def main(): if len(sys.argv) < 2: - print("Usage: python client.py ") + print("Usage: uv run client.py [args...]", file=sys.stderr) sys.exit(1) client = MCPClient() try: - await client.connect_to_server(sys.argv[1]) + await client.connect_to_server(sys.argv[1:]) # Check if we have a valid API key to continue api_key = os.getenv("ANTHROPIC_API_KEY") @@ -235,6 +253,7 @@ async def main(): if __name__ == "__main__": - import sys - - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + print() # Ctrl-C quits like `quit`; cleanup has already run. diff --git a/mcp-client-ruby/README.md b/mcp-client-ruby/README.md index 126e236e..dea936f0 100644 --- a/mcp-client-ruby/README.md +++ b/mcp-client-ruby/README.md @@ -22,10 +22,10 @@ cp .env.example .env ## Run the client -Pass the path to a server script. `.rb` servers are launched with `ruby`, `.py` with `python3`, and `.js` with `node`: +Pass the command that starts the server, and its arguments: ```bash -bundle exec ruby client.rb ../weather-server-ruby/weather.rb +bundle exec ruby client.rb ruby ../weather-server-ruby/weather.rb ``` 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. diff --git a/mcp-client-ruby/client.rb b/mcp-client-ruby/client.rb index 2331fff3..4e4979cb 100644 --- a/mcp-client-ruby/client.rb +++ b/mcp-client-ruby/client.rb @@ -19,33 +19,26 @@ def initialize @mcp_client = nil @transport = nil @anthropic_client = nil + @tools = [] @messages = [] end - def connect_to_server(server_script_path) - command = case File.extname(server_script_path) - when ".rb" - "ruby" - when ".py" - "python3" - when ".js" - "node" - else - raise ArgumentError, "Server script must be a .rb, .py, or .js file." - end - - @transport = MCP::Client::Stdio.new(command: command, args: [server_script_path]) + def connect_to_server(server_command) + @transport = MCP::Client::Stdio.new(command: server_command.first, args: server_command.drop(1)) @mcp_client = MCP::Client.new(transport: @transport) - @mcp_client.connect + @mcp_client.connect(client_info: { name: "mcp-client-ruby", version: "1.0.0" }) + + # The tools are listed once, here; this client doesn't follow later changes. + @tools = @mcp_client.tools # The spec says clients SHOULD validate structured results against the # schema the tool declares. This SDK's client does not do it, so compile # each declared schema once here and check results as they arrive. - @output_schemas = @mcp_client.tools.each_with_object({}) do |tool, schemas| + @output_schemas = @tools.each_with_object({}) do |tool, schemas| schemas[tool.name] = MCP::Tool::OutputSchema.new(tool.output_schema) if tool.output_schema end - tool_names = @mcp_client.tools.map(&:name) + tool_names = @tools.map(&:name) puts "\nConnected to server with tools: #{tool_names}" end @@ -122,7 +115,7 @@ def process_query(query) history_length = @messages.length @messages << { role: "user", content: query } - available_tools = @mcp_client.tools.map do |tool| + available_tools = @tools.map do |tool| { name: tool.name, description: tool.description, input_schema: tool.input_schema } end @@ -229,14 +222,14 @@ def anthropic_client end if ARGV.empty? - puts "Usage: ruby client.rb " + warn "Usage: bundle exec ruby client.rb [args...]" exit 1 end client = MCPClient.new begin - client.connect_to_server(ARGV[0]) + client.connect_to_server(ARGV) api_key = ENV["ANTHROPIC_API_KEY"] if api_key.nil? || api_key.empty? @@ -248,8 +241,11 @@ def anthropic_client end client.chat_loop +rescue Interrupt + # Ctrl-C quits like `quit`. Interrupt isn't a StandardError, so it needs its own rescue. + puts rescue => e - puts "Error: #{e.message}" + warn "Error: #{e.message}" exit 1 ensure client.cleanup diff --git a/mcp-client-rust/.env.example b/mcp-client-rust/.env.example new file mode 100644 index 00000000..8b3e1258 --- /dev/null +++ b/mcp-client-rust/.env.example @@ -0,0 +1 @@ +ANTHROPIC_API_KEY= diff --git a/mcp-client-rust/src/main.rs b/mcp-client-rust/src/main.rs index 0426a393..af28c9bd 100644 --- a/mcp-client-rust/src/main.rs +++ b/mcp-client-rust/src/main.rs @@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::HashMap; use std::time::Duration; -use tokio::io::{self, AsyncBufReadExt, BufReader}; use tokio::process::Command; const MODEL_ANTHROPIC: &str = "claude-sonnet-5"; @@ -214,6 +213,7 @@ impl MCPClient { ) .await?; + // The tools are listed once, here; this client doesn't follow later changes. let rmcp_tools = session .list_all_tools() .await @@ -405,18 +405,25 @@ impl MCPClient { println!("\nMCP Client Started!"); 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(); + // Read lines on a plain thread. tokio's stdin waits for a pending read + // when the program exits, so Ctrl-C at the prompt would hang. + let (tx, mut lines) = tokio::sync::mpsc::unbounded_channel(); + std::thread::spawn(move || { + for line in std::io::stdin().lines() { + if tx.send(line).is_err() { + break; + } + } + }); loop { print!("\nQuery: "); std::io::Write::flush(&mut std::io::stdout())?; - input.clear(); - if stdin.read_line(&mut input).await? == 0 { + let Some(input) = lines.recv().await else { break; // EOF - } - + }; + let input = input?; let query = input.trim(); if query.eq_ignore_ascii_case("quit") { break; @@ -462,13 +469,13 @@ async fn main() -> Result<()> { let server_args: Vec = args.collect(); if server_args.is_empty() { - eprintln!("Usage: cargo run -- [args...]"); + eprintln!("Usage: cargo run -- [args...]"); std::process::exit(1); } let mut client = MCPClient::new()?; - let result = async { + let run = async { client.connect_to_server(&server_args).await?; // Connecting and listing tools needs no credentials; querying them @@ -486,8 +493,15 @@ async fn main() -> Result<()> { } client.chat_loop().await - } - .await; + }; + // Ctrl-C quits like `quit`, so cleanup still runs. + let result = tokio::select! { + result = run => result, + _ = tokio::signal::ctrl_c() => { + println!(); + Ok(()) + } + }; let cleanup_result = client.cleanup().await; diff --git a/mcp-client-typescript/README.md b/mcp-client-typescript/README.md index 5b972933..796b1dec 100644 --- a/mcp-client-typescript/README.md +++ b/mcp-client-typescript/README.md @@ -23,10 +23,10 @@ cp .env.example .env ## Run the client -Pass the path to a server script — `.js` servers are launched with `node`, `.py` servers with `python` (or `python3`, depending on the platform): +Pass the command that starts the server, and its arguments: ```bash -node build/index.js ../weather-server-typescript/build/index.js +node build/index.js node ../weather-server-typescript/build/index.js ``` (Build the server first — see its README.) diff --git a/mcp-client-typescript/index.ts b/mcp-client-typescript/index.ts index 8668a5c7..6a41ae23 100644 --- a/mcp-client-typescript/index.ts +++ b/mcp-client-typescript/index.ts @@ -78,7 +78,7 @@ class MCPClient { // 'auto' probes server/discover, falling back to the 2025-11-25 handshake. // The SDK's default is 'legacy'. this.mcp = new Client( - { name: "mcp-client-cli", version: "1.0.0" }, + { name: "mcp-client-typescript", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } }, ); } @@ -88,33 +88,20 @@ class MCPClient { return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); } - async connectToServer(serverScriptPath: string) { + async connectToServer(serverCommand: string[]) { /** * Connect to an MCP server * - * @param serverScriptPath - Path to the server script (.py or .js) + * @param serverCommand - The command that starts the server, and its arguments */ try { - // Determine script type and appropriate command - const isJs = serverScriptPath.endsWith(".js"); - const isPy = serverScriptPath.endsWith(".py"); - if (!isJs && !isPy) { - throw new Error("Server script must be a .js or .py file"); - } - const command = isPy - ? process.platform === "win32" - ? "python" - : "python3" - : process.execPath; - - // Initialize transport and connect to server this.transport = new StdioClientTransport({ - command, - args: [serverScriptPath], + command: serverCommand[0], + args: serverCommand.slice(1), }); await this.mcp.connect(this.transport); - // List available tools + // The tools are listed once, here; this client doesn't follow later changes. const toolsResult = await this.mcp.listTools(); this.tools = toolsResult.tools.map((tool) => { return { @@ -294,12 +281,13 @@ class MCPClient { async function main() { if (process.argv.length < 3) { - console.log("Usage: node build/index.js "); + console.error("Usage: node build/index.js [args...]"); + process.exitCode = 1; return; } const mcpClient = new MCPClient(); try { - await mcpClient.connectToServer(process.argv[2]); + await mcpClient.connectToServer(process.argv.slice(2)); // Check if we have a valid API key to continue const apiKey = process.env.ANTHROPIC_API_KEY; diff --git a/tests/README.md b/tests/README.md index 9748b02b..d3916a9e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -130,7 +130,7 @@ node tests/helpers/build/tool-loop-test.js [args...] **Example**: ```bash -node tests/helpers/build/tool-loop-test.js node mcp-client-typescript/build/index.js tests/helpers/build/mock-mcp-server.js +node tests/helpers/build/tool-loop-test.js node mcp-client-typescript/build/index.js node tests/helpers/build/mock-mcp-server.js ``` ## CI/CD Integration diff --git a/tests/helpers/mock-mcp-server.ts b/tests/helpers/mock-mcp-server.ts index 1ffb57a5..7bf72b68 100644 --- a/tests/helpers/mock-mcp-server.ts +++ b/tests/helpers/mock-mcp-server.ts @@ -2,7 +2,8 @@ /** * Mock MCP Server for testing clients * - * Verifies that clients call `tools/list`, and advertises tools whose output + * Verifies that clients call `tools/list` and identify themselves as + * `mcp-client-` version 1.0.0, and advertises tools whose output * schemas cover both shapes a structured result can take: an object root, and * an array root (allowed as of protocol revision 2026-07-28). * @@ -24,7 +25,7 @@ * exactly what is in this file. */ -import type { Tool } from "@modelcontextprotocol/server"; +import type { Implementation, Tool } from "@modelcontextprotocol/server"; import { Server } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { IMAGE_DATA, RESOURCE_TEXT, STRUCTURED_ONLY_VALUE } from "./mock-values.js"; @@ -32,6 +33,10 @@ import { IMAGE_DATA, RESOURCE_TEXT, STRUCTURED_ONLY_VALUE } from "./mock-values. // Track whether tools/list was called let toolsListCalled = false; +/** The client info every quickstart client sends. */ +const CLIENT_NAME = /^mcp-client-(python|typescript|go|rust|ruby)$/; +const CLIENT_VERSION = "1.0.0"; + const TOOLS: Tool[] = [ { name: "get_alerts", @@ -100,7 +105,17 @@ function buildServer(): Server { { capabilities: { tools: {} } }, ); - server.setRequestHandler("tools/list", async () => { + server.setRequestHandler("tools/list", async (_request, ctx) => { + // 2026-07-28 clients send their info with every request; 2025-11-25 + // clients send it once, in initialize. + const envelope = ctx.mcpReq.envelope as Record | undefined; + const info = (envelope?.["io.modelcontextprotocol/clientInfo"] ?? + server.getClientVersion()) as Implementation | undefined; + if (!info || !CLIENT_NAME.test(info.name) || info.version !== CLIENT_VERSION) { + throw new Error( + `Expected client info mcp-client- ${CLIENT_VERSION}, got ${JSON.stringify(info)}`, + ); + } toolsListCalled = true; return { tools: TOOLS }; }); diff --git a/tests/smoke-test.sh b/tests/smoke-test.sh index 51c2e1ea..1860e911 100755 --- a/tests/smoke-test.sh +++ b/tests/smoke-test.sh @@ -97,12 +97,29 @@ test_weather_server_ruby() { # The client tests drive the no-API-key path, where each client prints a notice # and exits. Empty rather than unset: dotenv skips a name already present in # ENV, so a key in the environment or a local .env would start the chat loop. +# Each first runs the client with no server command, which must print usage to +# stderr, nothing to stdout, and exit 1. + +# Usage: check_usage [args...] +check_usage() { + local stdout stderr_file status + stderr_file=$(mktemp) + stdout=$("$@" 2>"${stderr_file}") && status=0 || status=$? + local stderr + stderr=$(cat "${stderr_file}") + rm -f "${stderr_file}" + if [ "${status}" -ne 1 ] || [ -n "${stdout}" ] || [[ "${stderr}" != *Usage* ]]; then + print_error "with no server command, expected usage on stderr and exit 1 (got exit ${status}, stdout '${stdout}', stderr '${stderr}')" + return 1 + fi +} # Test: Python MCP client test_mcp_client_python() { check_dependency uv || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-python" - ANTHROPIC_API_KEY= uv --directory "${client_dir}" run python "${client_dir}/client.py" "${MOCK_SERVER}" >/dev/null 2>&1 + check_usage uv --directory "${client_dir}" run python "${client_dir}/client.py" || return 1 + ANTHROPIC_API_KEY= uv --directory "${client_dir}" run python "${client_dir}/client.py" node "${MOCK_SERVER}" >/dev/null 2>&1 } # Test: TypeScript MCP client @@ -111,7 +128,8 @@ test_mcp_client_typescript() { check_dependency npm || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-typescript" ensure_built "${client_dir}" "" || return 1 - ANTHROPIC_API_KEY= node "${client_dir}/build/index.js" "${MOCK_SERVER}" >/dev/null 2>&1 + check_usage node "${client_dir}/build/index.js" || return 1 + ANTHROPIC_API_KEY= node "${client_dir}/build/index.js" node "${MOCK_SERVER}" >/dev/null 2>&1 } # Test: Ruby MCP client @@ -120,7 +138,8 @@ test_mcp_client_ruby() { check_dependency bundle || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-ruby" ensure_bundled "${client_dir}" || return 1 - (cd "${client_dir}" && ANTHROPIC_API_KEY= bundle exec ruby client.rb "${MOCK_SERVER}") >/dev/null 2>&1 + (cd "${client_dir}" && check_usage bundle exec ruby client.rb) || return 1 + (cd "${client_dir}" && ANTHROPIC_API_KEY= bundle exec ruby client.rb node "${MOCK_SERVER}") >/dev/null 2>&1 } # Test: Go MCP client @@ -135,6 +154,7 @@ test_mcp_client_go() { return 1 } + check_usage "${client_bin}" || return 1 ANTHROPIC_API_KEY= "${client_bin}" node "${MOCK_SERVER}" >/dev/null 2>&1 } @@ -151,6 +171,7 @@ test_mcp_client_rust() { return 1 } + check_usage "${client_bin}" || return 1 ANTHROPIC_API_KEY= "${client_bin}" node "${MOCK_SERVER}" >/dev/null 2>&1 } @@ -167,7 +188,7 @@ test_mcp_client_rust() { test_tool_loop_python() { check_dependency uv || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-python" - node "${TOOL_LOOP_TEST}" uv --directory "${client_dir}" run python "${client_dir}/client.py" "${MOCK_SERVER}" + node "${TOOL_LOOP_TEST}" uv --directory "${client_dir}" run python "${client_dir}/client.py" node "${MOCK_SERVER}" } # Test: TypeScript MCP client tool loop @@ -176,7 +197,7 @@ test_tool_loop_typescript() { check_dependency npm || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-typescript" ensure_built "${client_dir}" "" || return 1 - node "${TOOL_LOOP_TEST}" node "${client_dir}/build/index.js" "${MOCK_SERVER}" + node "${TOOL_LOOP_TEST}" node "${client_dir}/build/index.js" node "${MOCK_SERVER}" } # Test: Ruby MCP client tool loop @@ -185,7 +206,7 @@ test_tool_loop_ruby() { check_dependency bundle || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-ruby" ensure_bundled "${client_dir}" || return 1 - (cd "${client_dir}" && node "${TOOL_LOOP_TEST}" bundle exec ruby client.rb "${MOCK_SERVER}") + (cd "${client_dir}" && node "${TOOL_LOOP_TEST}" bundle exec ruby client.rb node "${MOCK_SERVER}") } # Test: Go MCP client tool loop diff --git a/weather-server-go/README.md b/weather-server-go/README.md index bc2f8c3b..0130e52b 100644 --- a/weather-server-go/README.md +++ b/weather-server-go/README.md @@ -22,7 +22,7 @@ The server communicates over stdio, so running it directly just waits for a clie ```bash cd ../mcp-client-go -go run main.go ../weather-server-go/weather +go run . ../weather-server-go/weather ``` ## Tool annotations diff --git a/weather-server-python/README.md b/weather-server-python/README.md index b0685e69..f07dcea6 100644 --- a/weather-server-python/README.md +++ b/weather-server-python/README.md @@ -24,7 +24,7 @@ The server communicates over stdio, so running it directly just waits for a clie ```bash cd ../mcp-client-python -uv run client.py ../weather-server-python/weather.py +uv run client.py uv --directory ../weather-server-python run weather.py ``` ## Tool annotations diff --git a/weather-server-ruby/README.md b/weather-server-ruby/README.md index 25d40e44..6464f9a2 100644 --- a/weather-server-ruby/README.md +++ b/weather-server-ruby/README.md @@ -24,7 +24,7 @@ The server communicates over stdio, so running it directly just waits for a clie ```bash cd ../mcp-client-ruby bundle install -bundle exec ruby client.rb ../weather-server-ruby/weather.rb +bundle exec ruby client.rb ruby ../weather-server-ruby/weather.rb ``` ## Tool annotations diff --git a/weather-server-typescript/README.md b/weather-server-typescript/README.md index 51b8f970..a5f1ae97 100644 --- a/weather-server-typescript/README.md +++ b/weather-server-typescript/README.md @@ -25,7 +25,7 @@ The server communicates over stdio, so running it directly just waits for a clie ```bash cd ../mcp-client-typescript npm install && npm run build -node build/index.js ../weather-server-typescript/build/index.js +node build/index.js node ../weather-server-typescript/build/index.js ``` ## Tool annotations From 6ab505254805186a2c640beb801e1d9cfd6c6df6 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Fri, 6 Nov 2026 22:44:51 +0100 Subject: [PATCH 2/5] Address comments --- mcp-client-go/main.go | 6 ++++++ mcp-client-python/client.py | 14 +++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/mcp-client-go/main.go b/mcp-client-go/main.go index 1f6e7330..f318e33b 100644 --- a/mcp-client-go/main.go +++ b/mcp-client-go/main.go @@ -432,6 +432,12 @@ func main() { defer stop() if err := client.ConnectToServer(ctx, serverArgs); err != nil { + // The session may already be open, for example if listing tools failed. + _ = client.Cleanup() + if ctx.Err() != nil { + fmt.Println() + return // Ctrl-C while connecting + } log.Fatalf("Failed to connect to MCP server: %v", err) } diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index 6aaede4b..7206b061 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -218,12 +218,16 @@ async def read_line(prompt: str) -> str: line = loop.create_future() def read(): - # The raw stream takes no lock, so exiting mid-read is safe. - data = sys.stdin.buffer.raw.readline() - if data: - loop.call_soon_threadsafe(line.set_result, data.decode()) + try: + # The raw stream takes no lock, so exiting mid-read is safe. + data = sys.stdin.buffer.raw.readline() + if not data: + raise EOFError + result = data.decode(sys.stdin.encoding, sys.stdin.errors) + except Exception as e: + loop.call_soon_threadsafe(line.set_exception, e) else: - loop.call_soon_threadsafe(line.set_exception, EOFError()) + loop.call_soon_threadsafe(line.set_result, result) print(prompt, end="", flush=True) From 03ee2a4e6676eef23375183d3368c9b001ae3210 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Sat, 7 Nov 2026 00:10:25 +0100 Subject: [PATCH 3/5] Address comments --- mcp-client-python/client.py | 17 ++- mcp-client-typescript/index.ts | 11 +- tests/README.md | 24 ++++- tests/helpers/interrupt-test.ts | 178 +++++++++++++++++++++++++++++++ tests/helpers/mock-mcp-server.ts | 4 + tests/smoke-test.sh | 57 ++++++---- 6 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 tests/helpers/interrupt-test.ts diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index 7206b061..bb7b1361 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -217,7 +217,16 @@ async def read_line(prompt: str) -> str: loop = asyncio.get_running_loop() line = loop.create_future() + def finish(result, error): + if line.done(): + return # Ctrl-C cancelled the read + if error: + line.set_exception(error) + else: + line.set_result(result) + def read(): + result, error = None, None try: # The raw stream takes no lock, so exiting mid-read is safe. data = sys.stdin.buffer.raw.readline() @@ -225,9 +234,11 @@ def read(): raise EOFError result = data.decode(sys.stdin.encoding, sys.stdin.errors) except Exception as e: - loop.call_soon_threadsafe(line.set_exception, e) - else: - loop.call_soon_threadsafe(line.set_result, result) + error = e + try: + loop.call_soon_threadsafe(finish, result, error) + except RuntimeError: + pass # the loop has closed: the client is exiting print(prompt, end="", flush=True) diff --git a/mcp-client-typescript/index.ts b/mcp-client-typescript/index.ts index 6a41ae23..a141d008 100644 --- a/mcp-client-typescript/index.ts +++ b/mcp-client-typescript/index.ts @@ -120,11 +120,12 @@ class MCPClient { } } - async processQuery(query: string) { + async processQuery(query: string, signal?: AbortSignal) { /** * Process a query using Claude and available tools * * @param query - The user's input query + * @param signal - Aborts the query's API and tool calls * @returns Processed response as a string */ const historyLength = this.messages.length; @@ -138,7 +139,7 @@ class MCPClient { system: SYSTEM_PROMPT, messages: this.messages, tools: this.tools, - }); + }, { signal }); if (response.stop_reason === "refusal") { // Drop the refused query so a follow-up doesn't send it again. @@ -180,7 +181,7 @@ class MCPClient { result = await this.mcp.callTool({ name: toolUse.name, arguments: toolArgs, - }); + }, { signal }); } catch (error) { if (!(error instanceof ProtocolError)) throw error; // A rejected call is the model's mistake to correct, so it goes back @@ -228,6 +229,7 @@ class MCPClient { }); // rl.question() doesn't reject on stdin EOF on its own; wire its close // event to an AbortSignal so EOF (Ctrl-D) and SIGINT both unblock it. + // The signal also aborts a query in progress. const ac = new AbortController(); const onClose = () => ac.abort(); rl.on("close", onClose); @@ -256,9 +258,10 @@ class MCPClient { const historyLength = this.messages.length; try { - const response = await this.processQuery(message); + const response = await this.processQuery(message, ac.signal); console.log("\n" + response); } catch (e) { + if (ac.signal.aborted) break; // Ctrl-C during the query // 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 d3916a9e..272942cc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -41,6 +41,10 @@ Finally the client must exit 0 on `quit`. A client that does one tool round and All five clients are covered, including Rust: it calls the Messages API directly, so it honours `ANTHROPIC_BASE_URL` like the official SDKs do. +## Ctrl-C + +Every client must treat Ctrl-C like `quit`. `interrupt-test.ts` sends SIGINT to the client twice, in two separate runs: once at its `Query:` prompt, and once while a query waits on a fake API that never answers. Stdin stays open both times, as in a terminal, so a client stuck on a pending read when it tries to exit fails here. The client must exit with status 0 within 10 seconds, and the mock MCP server must be gone afterwards: only the client gets the signal, so it must shut the server down itself. + ## Running Tests ```bash @@ -114,9 +118,11 @@ Three more tools serve the tool-loop test's **tool result contents** query: `rej **Usage**: ```bash -node tests/helpers/build/mock-mcp-server.js +node tests/helpers/build/mock-mcp-server.js [pid file] ``` +Given a path, it writes its PID there, so the Ctrl-C test can check it has exited. + ### 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. @@ -133,6 +139,22 @@ node tests/helpers/build/tool-loop-test.js [args...] node tests/helpers/build/tool-loop-test.js node mcp-client-typescript/build/index.js node tests/helpers/build/mock-mcp-server.js ``` +### interrupt-test.ts + +Runs the Ctrl-C test described above. The client command must end with the mock server's path; the helper adds a PID file argument after it. + +**Usage**: + +```bash +node tests/helpers/build/interrupt-test.js [args...] +``` + +**Example**: + +```bash +node tests/helpers/build/interrupt-test.js node mcp-client-typescript/build/index.js node tests/helpers/build/mock-mcp-server.js +``` + ## CI/CD Integration Tests run automatically on pull requests via GitHub Actions. See `.github/workflows/ci.yml` for the CI configuration. diff --git a/tests/helpers/interrupt-test.ts b/tests/helpers/interrupt-test.ts new file mode 100644 index 00000000..7d35051f --- /dev/null +++ b/tests/helpers/interrupt-test.ts @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Ctrl-C test for clients + * + * Every client must treat Ctrl-C like `quit`: stop, shut the MCP server down + * and exit with status 0. This sends SIGINT to the client twice, in two + * separate runs: + * + * - "at the prompt": once the client is waiting for a query; + * - "during a request": once a query has reached a fake Anthropic API that + * never answers. + * + * Stdin stays open in both, as in a terminal: a client blocked on a pending + * read when it tries to exit hangs here, where closing stdin would hide it. + * + * Only the client gets the signal. A terminal sends Ctrl-C to the server as + * well, but the client must not depend on that. The mock server writes its + * PID to a file, so the test can check that the server is gone afterwards. + * + * Usage: node interrupt-test.js [args...] + * The client command must end with the mock server's path; the test adds the + * PID file argument after it. + */ + +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +/** Every client prints this before reading a query. */ +const PROMPT = "Query: "; +/** How long a client may take to exit after SIGINT. */ +const EXIT_TIMEOUT_MS = 10_000; +/** How long a client may take to start and show its prompt. */ +const START_TIMEOUT_MS = 120_000; +/** How long the mock server may outlive the client. */ +const SERVER_EXIT_MS = 2_000; + +type Scenario = "at the prompt" | "during a request"; + +const failures: string[] = []; + +/** A fake Messages API that takes every request and never answers it. */ +function startHangingApi(onRequest: () => void): Promise { + const server = http.createServer((req) => { + req.resume(); + req.on("end", onRequest); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))); +} + +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function serverExited(pidFile: string): Promise { + let pid: number; + try { + pid = Number(readFileSync(pidFile, "utf8")); + } catch { + return "the mock server never wrote its PID"; + } + const deadline = Date.now() + SERVER_EXIT_MS; + while (isRunning(pid)) { + if (Date.now() > deadline) { + process.kill(pid, "SIGKILL"); + return `the mock server (PID ${pid}) was still running ${SERVER_EXIT_MS / 1000}s after the client exited`; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return undefined; +} + +async function run(scenario: Scenario, command: string, args: string[]) { + const dir = mkdtempSync(path.join(os.tmpdir(), "interrupt-test-")); + const pidFile = path.join(dir, "server.pid"); + + let interrupt = () => {}; + const api = await startHangingApi(() => { + if (scenario === "during a request") interrupt(); + }); + const address = api.address(); + if (!address || typeof address === "string") throw new Error("no port"); + + const child = spawn(command, [...args, pidFile], { + env: { + ...process.env, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, + ANTHROPIC_API_KEY: "test-key", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let sentAt = 0; + interrupt = () => { + if (sentAt) return; + sentAt = Date.now(); + child.kill("SIGINT"); + }; + + child.stdout.on("data", (chunk) => { + const first = !stdout.includes(PROMPT); + stdout += chunk; + if (!first || !stdout.includes(PROMPT)) return; + if (scenario === "at the prompt") { + // Give the client a moment to start reading. + setTimeout(interrupt, 300); + } else { + child.stdin.write("what's the weather?\n"); + } + }); + child.stderr.on("data", (chunk) => (stderr += chunk)); + + const timer = setTimeout(() => child.kill("SIGKILL"), START_TIMEOUT_MS); + const exitTimer = setInterval(() => { + if (sentAt && Date.now() - sentAt > EXIT_TIMEOUT_MS) child.kill("SIGKILL"); + }, 200); + + const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>((resolve) => { + child.on("error", (error) => { + failures.push(`${scenario}: could not start client: ${error.message}`); + resolve([null, null]); + }); + child.on("close", (code, signal) => resolve([code, signal])); + }); + clearTimeout(timer); + clearInterval(exitTimer); + api.closeAllConnections(); + api.close(); + + const before = failures.length; + if (!sentAt) { + failures.push(`${scenario}: the client exited before the test could send SIGINT`); + } else if (signal === "SIGKILL") { + failures.push(`${scenario}: the client was still running ${EXIT_TIMEOUT_MS / 1000}s after SIGINT`); + } else if (code !== 0) { + failures.push(`${scenario}: the client exited with ${signal ? `signal ${signal}` : `status ${code}`}, not 0`); + } + const serverProblem = await serverExited(pidFile); + if (serverProblem) failures.push(`${scenario}: ${serverProblem}`); + rmSync(dir, { recursive: true, force: true }); + + if (failures.length > before) { + console.error(`--- ${scenario}: client stdout ---\n${stdout.trim()}`); + console.error(`--- ${scenario}: client stderr ---\n${stderr.trim()}`); + } else { + console.error(`✓ ${scenario}: exited 0 in ${Date.now() - sentAt}ms, server shut down`); + } +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + if (!command || args.length === 0) { + console.error("Usage: node interrupt-test.js [args...] "); + process.exit(1); + } + console.error(`Testing Ctrl-C: ${command} ${args.join(" ")}`); + + await run("at the prompt", command, args); + await run("during a request", command, args); + + if (failures.length === 0) return; + console.error(`✗ Ctrl-C test failed:`); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} + +main().catch((error) => { + console.error("✗ Ctrl-C test crashed:", error); + process.exit(1); +}); diff --git a/tests/helpers/mock-mcp-server.ts b/tests/helpers/mock-mcp-server.ts index 7bf72b68..060928d0 100644 --- a/tests/helpers/mock-mcp-server.ts +++ b/tests/helpers/mock-mcp-server.ts @@ -25,6 +25,7 @@ * exactly what is in this file. */ +import { writeFileSync } from "node:fs"; import type { Implementation, Tool } from "@modelcontextprotocol/server"; import { Server } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; @@ -184,6 +185,9 @@ function buildServer(): Server { serveStdio(buildServer); console.error("Mock MCP Server running on stdio"); +// The Ctrl-C test passes a file to write the PID to, to check the server exits. +if (process.argv[2]) writeFileSync(process.argv[2], String(process.pid)); + // Verify that tools/list was called when the connection closes process.stdin.on("end", () => { if (!toolsListCalled) { diff --git a/tests/smoke-test.sh b/tests/smoke-test.sh index 1860e911..d5145770 100755 --- a/tests/smoke-test.sh +++ b/tests/smoke-test.sh @@ -14,6 +14,7 @@ TESTS_DIR="${PROJECT_ROOT}/tests" TEST_CLIENT="${PROJECT_ROOT}/tests/helpers/build/mcp-test-client.js" MOCK_SERVER="${PROJECT_ROOT}/tests/helpers/build/mock-mcp-server.js" TOOL_LOOP_TEST="${PROJECT_ROOT}/tests/helpers/build/tool-loop-test.js" +INTERRUPT_TEST="${PROJECT_ROOT}/tests/helpers/build/interrupt-test.js" # Track test results FAILED_TESTS=() @@ -183,34 +184,42 @@ test_mcp_client_rust() { # 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 -test_tool_loop_python() { +# +# The Ctrl-C tests send SIGINT to the client at its prompt and again while a +# query waits on a fake API that never answers, with stdin kept open both +# times. The client must exit with status 0 and shut the mock server down. + +# Run the Python client under a test helper (tool loop or Ctrl-C) +drive_client_python() { + local helper=$1 check_dependency uv || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-python" - node "${TOOL_LOOP_TEST}" uv --directory "${client_dir}" run python "${client_dir}/client.py" node "${MOCK_SERVER}" + node "${helper}" uv --directory "${client_dir}" run python "${client_dir}/client.py" node "${MOCK_SERVER}" } -# Test: TypeScript MCP client tool loop -test_tool_loop_typescript() { +# Run the TypeScript client under a test helper (tool loop or Ctrl-C) +drive_client_typescript() { + local helper=$1 check_dependency node || return 1 check_dependency npm || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-typescript" ensure_built "${client_dir}" "" || return 1 - node "${TOOL_LOOP_TEST}" node "${client_dir}/build/index.js" node "${MOCK_SERVER}" + node "${helper}" node "${client_dir}/build/index.js" node "${MOCK_SERVER}" } -# Test: Ruby MCP client tool loop -test_tool_loop_ruby() { +# Run the Ruby client under a test helper (tool loop or Ctrl-C) +drive_client_ruby() { + local helper=$1 check_dependency ruby || return 1 check_dependency bundle || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-ruby" ensure_bundled "${client_dir}" || return 1 - (cd "${client_dir}" && node "${TOOL_LOOP_TEST}" bundle exec ruby client.rb node "${MOCK_SERVER}") + (cd "${client_dir}" && node "${helper}" bundle exec ruby client.rb node "${MOCK_SERVER}") } -# Test: Go MCP client tool loop -test_tool_loop_go() { +# Run the Go client under a test helper (tool loop or Ctrl-C) +drive_client_go() { + local helper=$1 check_dependency go || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-go" ensure_built "${client_dir}" mcp-client-go || return 1 @@ -221,11 +230,12 @@ test_tool_loop_go() { return 1 } - node "${TOOL_LOOP_TEST}" "${client_bin}" node "${MOCK_SERVER}" + node "${helper}" "${client_bin}" node "${MOCK_SERVER}" } -# Test: Rust MCP client tool loop -test_tool_loop_rust() { +# Run the Rust client under a test helper (tool loop or Ctrl-C) +drive_client_rust() { + local helper=$1 check_dependency cargo || return 1 local client_dir="${PROJECT_ROOT}/mcp-client-rust" ensure_built "${client_dir}" mcp-client-rust || return 1 @@ -237,7 +247,7 @@ test_tool_loop_rust() { return 1 } - node "${TOOL_LOOP_TEST}" "${client_bin}" node "${MOCK_SERVER}" + node "${helper}" "${client_bin}" node "${MOCK_SERVER}" } # Run all tests @@ -253,11 +263,16 @@ run_test "mcp-client-typescript" test_mcp_client_typescript run_test "mcp-client-ruby" test_mcp_client_ruby run_test "mcp-client-go" test_mcp_client_go run_test "mcp-client-rust" test_mcp_client_rust -run_test "mcp-client-python tool loop" test_tool_loop_python -run_test "mcp-client-typescript tool loop" test_tool_loop_typescript -run_test "mcp-client-ruby tool loop" test_tool_loop_ruby -run_test "mcp-client-go tool loop" test_tool_loop_go -run_test "mcp-client-rust tool loop" test_tool_loop_rust +run_test "mcp-client-python tool loop" drive_client_python "${TOOL_LOOP_TEST}" +run_test "mcp-client-typescript tool loop" drive_client_typescript "${TOOL_LOOP_TEST}" +run_test "mcp-client-ruby tool loop" drive_client_ruby "${TOOL_LOOP_TEST}" +run_test "mcp-client-go tool loop" drive_client_go "${TOOL_LOOP_TEST}" +run_test "mcp-client-rust tool loop" drive_client_rust "${TOOL_LOOP_TEST}" +run_test "mcp-client-python Ctrl-C" drive_client_python "${INTERRUPT_TEST}" +run_test "mcp-client-typescript Ctrl-C" drive_client_typescript "${INTERRUPT_TEST}" +run_test "mcp-client-ruby Ctrl-C" drive_client_ruby "${INTERRUPT_TEST}" +run_test "mcp-client-go Ctrl-C" drive_client_go "${INTERRUPT_TEST}" +run_test "mcp-client-rust Ctrl-C" drive_client_rust "${INTERRUPT_TEST}" # Print summary echo "" From 97c685a543b2a10b0b2a2f4ade69cbb309963e67 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Sat, 7 Nov 2026 00:36:27 +0100 Subject: [PATCH 4/5] Address comments --- mcp-client-python/client.py | 10 +++++----- tests/helpers/interrupt-test.ts | 18 +++++++++++++++--- tests/utils.sh | 4 +++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index bb7b1361..884fc118 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -5,7 +5,7 @@ import threading from contextlib import AsyncExitStack -from anthropic import Anthropic +from anthropic import AsyncAnthropic from dotenv import load_dotenv from mcp import Client, MCPError, StdioServerParameters from mcp_types import CallToolResult, EmbeddedResource, ImageContent, Implementation, TextContent, TextResourceContents @@ -58,15 +58,15 @@ def __init__(self): # Initialize session and client objects self.client: Client | None = None self.exit_stack = AsyncExitStack() - self._anthropic: Anthropic | None = None + self._anthropic: AsyncAnthropic | None = None self.tools: list[dict] = [] self.messages: list[dict] = [] @property - def anthropic(self) -> Anthropic: + def anthropic(self) -> AsyncAnthropic: """Lazy-initialize Anthropic client when needed""" if self._anthropic is None: - self._anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) + self._anthropic = AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) return self._anthropic async def connect_to_server(self, server_command: list[str]): @@ -107,7 +107,7 @@ async def process_query(self, query: str) -> str: final_text = [] for turn in range(MAX_TOOL_TURNS + 1): - response = self.anthropic.messages.create( + response = await self.anthropic.messages.create( model=ANTHROPIC_MODEL, max_tokens=MAX_TOKENS, system=SYSTEM_PROMPT, diff --git a/tests/helpers/interrupt-test.ts b/tests/helpers/interrupt-test.ts index 7d35051f..e5234e4b 100644 --- a/tests/helpers/interrupt-test.ts +++ b/tests/helpers/interrupt-test.ts @@ -95,7 +95,17 @@ async function run(scenario: Scenario, command: string, args: string[]) { ANTHROPIC_API_KEY: "test-key", }, stdio: ["pipe", "pipe", "pipe"], + // Its own process group, so a client that hangs can be killed together + // with everything it started. SIGINT still goes to the client alone. + detached: true, }); + const killAll = () => { + try { + process.kill(-child.pid!, "SIGKILL"); + } catch { + // already gone + } + }; let stdout = ""; let stderr = ""; let sentAt = 0; @@ -118,9 +128,9 @@ async function run(scenario: Scenario, command: string, args: string[]) { }); child.stderr.on("data", (chunk) => (stderr += chunk)); - const timer = setTimeout(() => child.kill("SIGKILL"), START_TIMEOUT_MS); + const timer = setTimeout(killAll, START_TIMEOUT_MS); const exitTimer = setInterval(() => { - if (sentAt && Date.now() - sentAt > EXIT_TIMEOUT_MS) child.kill("SIGKILL"); + if (sentAt && Date.now() - sentAt > EXIT_TIMEOUT_MS) killAll(); }, 200); const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>((resolve) => { @@ -128,7 +138,9 @@ async function run(scenario: Scenario, command: string, args: string[]) { failures.push(`${scenario}: could not start client: ${error.message}`); resolve([null, null]); }); - child.on("close", (code, signal) => resolve([code, signal])); + // "exit", not "close": a server left running can hold the client's pipes + // open, and the server check below must still run. + child.on("exit", (code, signal) => resolve([code, signal])); }); clearTimeout(timer); clearInterval(exitTimer); diff --git a/tests/utils.sh b/tests/utils.sh index 82b2b375..5e09642c 100644 --- a/tests/utils.sh +++ b/tests/utils.sh @@ -77,11 +77,13 @@ setup_test() { TEST_CLIENT="${PROJECT_ROOT}/tests/helpers/build/mcp-test-client.js" MOCK_SERVER="${PROJECT_ROOT}/tests/helpers/build/mock-mcp-server.js" TOOL_LOOP_TEST="${PROJECT_ROOT}/tests/helpers/build/tool-loop-test.js" + INTERRUPT_TEST="${PROJECT_ROOT}/tests/helpers/build/interrupt-test.js" } # Ensure test helpers are built ensure_helpers_built() { - if [ ! -f "${TEST_CLIENT}" ] || [ ! -f "${MOCK_SERVER}" ] || [ ! -f "${TOOL_LOOP_TEST}" ]; then + if [ ! -f "${TEST_CLIENT}" ] || [ ! -f "${MOCK_SERVER}" ] || [ ! -f "${TOOL_LOOP_TEST}" ] \ + || [ ! -f "${INTERRUPT_TEST}" ]; then print_error "Test helpers not built" print_header "Building test helpers..." cd "${PROJECT_ROOT}/tests/helpers" || return 1 From 264cfa77f7e979c575531c94622679fd172a1fe6 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Sat, 7 Nov 2026 00:47:05 +0100 Subject: [PATCH 5/5] Address comments --- README.md | 4 ++-- mcp-client-typescript/index.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e684a366..d5f18ba8 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ 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 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. +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`. Each client takes the command that starts the server, 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. -You can mix and match across languages: for example, the Python client can drive the TypeScript server. Each client's README lists which server launch forms it supports. +You can mix and match across languages: for example, the Python client can drive the TypeScript server with `uv run client.py node ../weather-server-typescript/build/index.js`. Each client's README shows how to launch its matching server. The [`tests`](./tests) directory contains the smoke tests that run in CI against these examples. See [`tests/README.md`](./tests/README.md) for how they work. diff --git a/mcp-client-typescript/index.ts b/mcp-client-typescript/index.ts index a141d008..00e52fec 100644 --- a/mcp-client-typescript/index.ts +++ b/mcp-client-typescript/index.ts @@ -229,11 +229,15 @@ class MCPClient { }); // rl.question() doesn't reject on stdin EOF on its own; wire its close // event to an AbortSignal so EOF (Ctrl-D) and SIGINT both unblock it. - // The signal also aborts a query in progress. const ac = new AbortController(); const onClose = () => ac.abort(); rl.on("close", onClose); - const onSigint = () => rl.close(); + // Only Ctrl-C aborts a query in progress: on EOF, the query finishes first. + const interrupt = new AbortController(); + const onSigint = () => { + interrupt.abort(); + rl.close(); + }; process.once("SIGINT", onSigint); try { @@ -258,10 +262,10 @@ class MCPClient { const historyLength = this.messages.length; try { - const response = await this.processQuery(message, ac.signal); + const response = await this.processQuery(message, interrupt.signal); console.log("\n" + response); } catch (e) { - if (ac.signal.aborted) break; // Ctrl-C during the query + if (interrupt.signal.aborted) break; // Ctrl-C during the query // 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)}`);