Skip to content

Commit 53461ea

Browse files
Add quickstart-client example for the "Building MCP clients" tutorial
Import the quickstart MCP client from `modelcontextprotocol/quickstart-resources` into `examples/clients/quickstart-client/`. This is the companion code for the "Building MCP clients" tutorial at modelcontextprotocol.io. The client connects to any MCP server via stdio, discovers its tools, and runs an interactive chat loop where user queries are sent to Claude with the server's tools available for use. Changes from the upstream source: - Moved `import sys` to top-level imports - Added return type annotations (`-> None`) to all methods - Added `assert self.session is not None` guards for type narrowing - Changed `tool.inputSchema` to `tool.input_schema` (SDK Python name) - Added proper Anthropic type annotations (`MessageParam`, `ToolParam`, `TextBlock`, `ToolUseBlock`, `ToolResultBlockParam`, `TextBlockParam`) - Fixed tool result handling to use proper `ToolResultBlockParam` with `tool_use_id` linkage and explicit MCP `TextContent` to Anthropic `TextBlockParam` conversion, replacing the original code which passed raw MCP content objects to the Anthropic API Root `pyproject.toml` adds `mcp-quickstart-client` as a dev dependency with a workspace source mapping. This is necessary because `anthropic` is a new external dependency not already in the root package's transitive dependency tree, and without this, `uv sync --all-extras` (used by CI) would not install it, causing `pyright` to fail.
1 parent d5a1d08 commit 53461ea

File tree

7 files changed

+353
-0
lines changed

7 files changed

+353
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ANTHROPIC_API_KEY=
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# An LLM-Powered Chatbot MCP Client written in Python
2+
3+
See the [Building MCP clients](https://modelcontextprotocol.io/tutorials/building-a-client) tutorial for more information.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import asyncio
2+
import os
3+
import sys
4+
from contextlib import AsyncExitStack
5+
from pathlib import Path
6+
7+
from anthropic import Anthropic
8+
from anthropic.types import MessageParam, TextBlock, TextBlockParam, ToolParam, ToolResultBlockParam, ToolUseBlock
9+
from dotenv import load_dotenv
10+
from mcp import ClientSession, StdioServerParameters
11+
from mcp.client.stdio import stdio_client
12+
from mcp.types import TextContent
13+
14+
load_dotenv() # load environment variables from .env
15+
16+
# Claude model constant
17+
ANTHROPIC_MODEL = "claude-sonnet-4-5"
18+
19+
20+
class MCPClient:
21+
def __init__(self) -> None:
22+
# Initialize session and client objects
23+
self.session: ClientSession | None = None
24+
self.exit_stack = AsyncExitStack()
25+
self._anthropic: Anthropic | None = None
26+
27+
@property
28+
def anthropic(self) -> Anthropic:
29+
"""Lazy-initialize Anthropic client when needed"""
30+
if self._anthropic is None:
31+
self._anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
32+
return self._anthropic
33+
34+
async def connect_to_server(self, server_script_path: str) -> None:
35+
"""Connect to an MCP server
36+
37+
Args:
38+
server_script_path: Path to the server script (.py or .js)
39+
"""
40+
is_python = server_script_path.endswith(".py")
41+
is_js = server_script_path.endswith(".js")
42+
if not (is_python or is_js):
43+
raise ValueError("Server script must be a .py or .js file")
44+
45+
if is_python:
46+
path = Path(server_script_path).resolve()
47+
server_params = StdioServerParameters(
48+
command="uv",
49+
args=["--directory", str(path.parent), "run", path.name],
50+
env=None,
51+
)
52+
else:
53+
server_params = StdioServerParameters(command="node", args=[server_script_path], env=None)
54+
55+
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
56+
self.stdio, self.write = stdio_transport
57+
self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
58+
59+
await self.session.initialize()
60+
61+
# List available tools
62+
response = await self.session.list_tools()
63+
tools = response.tools
64+
print("\nConnected to server with tools:", [tool.name for tool in tools])
65+
66+
async def process_query(self, query: str) -> str:
67+
"""Process a query using Claude and available tools"""
68+
assert self.session is not None
69+
messages: list[MessageParam] = [{"role": "user", "content": query}]
70+
71+
response = await self.session.list_tools()
72+
available_tools: list[ToolParam] = [
73+
{"name": tool.name, "description": tool.description or "", "input_schema": tool.input_schema or {}}
74+
for tool in response.tools
75+
]
76+
77+
# Initial Claude API call
78+
response = self.anthropic.messages.create(
79+
model=ANTHROPIC_MODEL, max_tokens=1000, messages=messages, tools=available_tools
80+
)
81+
82+
# Process response and handle tool calls
83+
final_text: list[str] = []
84+
85+
for content in response.content:
86+
if isinstance(content, TextBlock):
87+
final_text.append(content.text)
88+
elif isinstance(content, ToolUseBlock):
89+
tool_name = content.name
90+
tool_args = content.input
91+
92+
# Execute tool call
93+
assert self.session is not None
94+
result = await self.session.call_tool(tool_name, tool_args)
95+
final_text.append(f"[Calling tool {tool_name} with args {tool_args}]")
96+
97+
# Continue conversation with tool results
98+
messages.append({"role": "assistant", "content": response.content})
99+
tool_result_content: list[TextBlockParam] = [
100+
{"type": "text", "text": block.text} for block in result.content if isinstance(block, TextContent)
101+
]
102+
tool_result: ToolResultBlockParam = {
103+
"type": "tool_result",
104+
"tool_use_id": content.id,
105+
"content": tool_result_content,
106+
}
107+
messages.append({"role": "user", "content": [tool_result]})
108+
109+
# Get next response from Claude
110+
response = self.anthropic.messages.create(
111+
model=ANTHROPIC_MODEL,
112+
max_tokens=1000,
113+
messages=messages,
114+
)
115+
116+
response_text = response.content[0]
117+
if isinstance(response_text, TextBlock):
118+
final_text.append(response_text.text)
119+
120+
return "\n".join(final_text)
121+
122+
async def chat_loop(self) -> None:
123+
"""Run an interactive chat loop"""
124+
print("\nMCP Client Started!")
125+
print("Type your queries or 'quit' to exit.")
126+
127+
while True:
128+
try:
129+
query = input("\nQuery: ").strip()
130+
131+
if query.lower() == "quit":
132+
break
133+
134+
response = await self.process_query(query)
135+
print("\n" + response)
136+
137+
except Exception as e:
138+
print(f"\nError: {str(e)}")
139+
140+
async def cleanup(self) -> None:
141+
"""Clean up resources"""
142+
await self.exit_stack.aclose()
143+
144+
145+
async def main() -> None:
146+
if len(sys.argv) < 2:
147+
print("Usage: python client.py <path_to_server_script>")
148+
sys.exit(1)
149+
150+
client = MCPClient()
151+
try:
152+
await client.connect_to_server(sys.argv[1])
153+
154+
# Check if we have a valid API key to continue
155+
api_key = os.getenv("ANTHROPIC_API_KEY")
156+
if not api_key:
157+
print("\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:")
158+
print(" export ANTHROPIC_API_KEY=your-api-key-here")
159+
return
160+
161+
await client.chat_loop()
162+
finally:
163+
await client.cleanup()
164+
165+
166+
if __name__ == "__main__":
167+
asyncio.run(main())

examples/clients/quickstart-client/mcp_quickstart_client/__init__.py

Whitespace-only changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[project]
2+
name = "mcp-quickstart-client"
3+
version = "0.1.0"
4+
description = "Tutorial companion: an LLM-powered chatbot MCP client"
5+
requires-python = ">=3.10"
6+
dependencies = [
7+
"anthropic>=0.72.0",
8+
"mcp",
9+
"python-dotenv>=1.0.0",
10+
]
11+
12+
[build-system]
13+
requires = ["hatchling"]
14+
build-backend = "hatchling.build"
15+
16+
[tool.hatch.build.targets.wheel]
17+
packages = ["mcp_quickstart_client"]
18+
19+
[tool.ruff]
20+
line-length = 120
21+
target-version = "py310"
22+
23+
[tool.ruff.lint]
24+
select = ["E", "F", "I"]
25+
ignore = []

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ required-version = ">=0.9.5"
5858
dev = [
5959
# We add mcp[cli,ws] so `uv sync` considers the extras.
6060
"mcp[cli,ws]",
61+
# Pull in workspace members whose deps aren't already covered by the root package.
62+
"mcp-quickstart-client",
6163
"pyright>=1.1.400",
6264
"pytest>=8.3.4",
6365
"ruff>=0.8.5",
@@ -167,6 +169,7 @@ members = ["examples/clients/*", "examples/servers/*", "examples/snippets"]
167169

168170
[tool.uv.sources]
169171
mcp = { workspace = true }
172+
mcp-quickstart-client = { workspace = true }
170173
strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" }
171174

172175
[tool.pytest.ini_options]

0 commit comments

Comments
 (0)