Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion mcp-client-go/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ANTHROPIC_API_KEY=your_api_key_here
ANTHROPIC_API_KEY=
4 changes: 2 additions & 2 deletions mcp-client-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 40 additions & 10 deletions mcp-client-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"log"
"os"
"os/exec"
"os/signal"
"strings"

"github.com/anthropics/anthropic-sdk-go/v2"
Expand Down Expand Up @@ -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,
)
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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]
Expand All @@ -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 {
Expand All @@ -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 <server_script_or_binary> [args...]")
fmt.Fprintln(os.Stderr, "Usage: go run . <server_command> [args...]")
os.Exit(1)
}

Expand All @@ -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)
Comment thread
Copilot marked this conversation as resolved.
Resolved
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)
}

Expand Down
6 changes: 3 additions & 3 deletions mcp-client-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
120 changes: 77 additions & 43 deletions mcp-client-python/client.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand All @@ -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 <path_to_server_script>")
print("Usage: uv run client.py <server_command> [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")
Expand All @@ -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.
4 changes: 2 additions & 2 deletions mcp-client-ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading