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-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..f318e33b 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,9 +427,17 @@ 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 { + // 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/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..884fc118 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 anthropic import AsyncAnthropic 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 @@ -57,66 +58,61 @@ 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_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): - response = self.anthropic.messages.create( + response = await self.anthropic.messages.create( model=ANTHROPIC_MODEL, 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,52 @@ 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 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() + if not data: + raise EOFError + result = data.decode(sys.stdin.encoding, sys.stdin.errors) + except Exception as e: + 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) + + 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 +268,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..00e52fec 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 { @@ -133,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; @@ -151,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. @@ -193,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 @@ -244,7 +232,12 @@ class MCPClient { 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 { @@ -269,9 +262,10 @@ class MCPClient { const historyLength = this.messages.length; try { - const response = await this.processQuery(message); + const response = await this.processQuery(message, interrupt.signal); console.log("\n" + response); } catch (e) { + 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)}`); @@ -294,12 +288,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..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. @@ -130,7 +136,23 @@ 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 +``` + +### 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 diff --git a/tests/helpers/interrupt-test.ts b/tests/helpers/interrupt-test.ts new file mode 100644 index 00000000..e5234e4b --- /dev/null +++ b/tests/helpers/interrupt-test.ts @@ -0,0 +1,190 @@ +#!/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"], + // 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; + 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(killAll, START_TIMEOUT_MS); + const exitTimer = setInterval(() => { + if (sentAt && Date.now() - sentAt > EXIT_TIMEOUT_MS) killAll(); + }, 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]); + }); + // "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); + 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 1ffb57a5..060928d0 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,8 @@ * exactly what is in this file. */ -import type { Tool } from "@modelcontextprotocol/server"; +import { writeFileSync } from "node:fs"; +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 +34,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 +106,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 }; }); @@ -169,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 51c2e1ea..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=() @@ -97,12 +98,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 +129,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 +139,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 +155,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 +172,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 } @@ -162,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" "${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" "${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 "${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 @@ -200,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 @@ -216,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 @@ -232,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 "" 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 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