Python 实战开发 MCP Client:完整 Tool Calling Loop
MCP 从入门到工程实践系列,第 7 篇,共 9 篇。
本文以 MCP2026-07-28文档版本中的 Python Client 为基线。
上一文构建了 Weather Server。现在的问题是:用户输入一句自然语言后,AI Application 怎样发现它的 Tool、让模型决定是否调用、真正执行,再把结果交回模型?
官方 Client 教程给出的是一个最小 AI Host:
连接一个 MCP Server
↓
取得 Tool Definitions
↓
把问题和 Tool Definitions 交给 Claude
↓
Claude 返回 text 或 tool_use
↓
Client 执行 MCP Tool
↓
把 tool_result 交回 Claude
↓
Claude 生成最终回答
难点在于代码里同时存在三套对象:
- Python MCP SDK;
- Anthropic Messages API;
- MCP Server 返回的 Content Block。
看到一个 content、id 或 client 时,必须先判断它来自哪一层。
一、环境、API Key 与 Import
创建环境并安装 MCP SDK、Anthropic SDK 和 python-dotenv。核心代码:
import asyncio
import sys
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp_types import TextContent
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv()
MODEL = "claude-opus-5"
anthropic = Anthropic()
这里有两个 Client,不要混淆:
| 对象 | 连接谁 | 作用 |
|---|---|---|
mcp.Client | MCP Server | list_tools、call_tool 等协议操作 |
Anthropic | Anthropic API | 调用 Claude 模型 |
在项目根目录创建 .env:
ANTHROPIC_API_KEY=你的密钥
并把它加入 .gitignore。load_dotenv() 只是把变量加载进进程环境;Anthropic() 再从环境变量读取 Credential。
二、用 server_params 描述怎样启动 Server
def server_params(server_script_path: str) -> StdioServerParameters:
"""Describe the subprocess that runs an MCP server."""
if server_script_path.endswith(".py"):
command = "python"
elif server_script_path.endswith(".js"):
command = "node"
else:
raise ValueError("Server script must be a .py or .js file")
return StdioServerParameters(
command=command,
args=[server_script_path],
)
server_params 是教程自定义的普通 Helper,不是 MCP Protocol Method。它负责产生一份“怎样启动本地子进程”的配置:
command:用 Python 还是 Node;args:传给 Runtime 的 Script Path;- 还可以根据需要提供
env。
如果用户运行:
uv run client.py /absolute/path/weather.py
Client 会根据这份参数启动 weather.py,然后通过 stdin/stdout 管道通信,不需要先手工启动一个监听端口的 Server。
三、教程里的 Client 到底是什么
async with Client(
stdio_client(server_params(sys.argv[1]))
) as client:
从内到外读:
server_params(...)描述 Server Process;stdio_client(...)创建本地 stdio Transport;Client(...)创建 MCP Client;async with建立连接,并在退出时清理 Transport 与子进程;- 小写
client是本次连接的实例变量。
教程只接收一个 Server Script,因此这个实例只连接一台 Server:
client.py
↓
一个 MCP Client instance
↓
weather.py
“每连接一个 MCP Server 有一个逻辑 Client”不等于“每加一个 Server 都重新编写一套 Client 程序”。同一个 Client 类可以在运行时创建多个实例,后文会给出多 Server 的结构。
四、process_query 从用户消息开始
async def process_query(client: Client, query: str) -> str:
messages = [
{
"role": "user",
"content": query,
}
]
这里的 messages 是 Anthropic Messages API 的消息历史,不是 MCP JSON-RPC Message。
这个最小示例在每次 process_query 开始时重新创建列表,因此多个 CLI Query 之间没有自动保留 Conversation Memory。
五、client.list_tools() 是谁定义的
tool_list = await client.list_tools()
list_tools():
- 不是 Python Built-in;
- 不是教程遗漏的自定义函数;
- 是 MCP SDK
Client对象提供的方法; - 概念上对应对当前 Server 执行
tools/list。
返回值包含当前 Server 暴露的 Tool Definitions。
六、把 MCP Tool 转成模型 Provider 格式
available_tools = [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
}
for tool in tool_list.tools
]
这是 List Comprehension。它遍历 MCP Tool Definition,取出 Name、Description 和 Input Schema,再转换为 Anthropic API 的 tools 参数格式:
MCP tools/list Result
↓ Application 做格式适配
Anthropic tools 参数
↓
Claude
这就是“AI Application 把 Tool Schema 提供给模型”的具体代码位置。
Server 定义 Schema,模型读取 Schema 后生成本次 Tool Name 与 Arguments,Application 负责校验和执行。模型不负责创建 Server 的 Tool Schema。
七、第一次调用 Claude
response = anthropic.messages.create(
model=MODEL,
max_tokens=1000,
messages=messages,
tools=available_tools,
)
模型可能返回:
- Text Content Block;
tool_useContent Block;- 多个 Content Block。
因此需要遍历:
final_text = []
tool_results = []
for content in response.content:
if content.type == "text":
final_text.append(content.text)
elif content.type == "tool_use":
tool_name = content.name
tool_args = content.input
Tool Use 是 Anthropic 独有吗
Tool Use 是通用概念:模型用结构化数据表达“希望 Application 调用某项外部能力”。不同 Provider 的具体 Wire Format 不同。
| 层 | 名称 | 作用 |
|---|---|---|
| 通用概念 | Tool Use / Tool Calling / Function Calling | 模型提出结构化调用意图 |
| Anthropic Response | type: "tool_use" | 携带 id、name、input |
| Anthropic Result | type: "tool_result" | 用 tool_use_id 返还结果 |
| MCP Protocol | tools/call | Client 真正请求 Server 执行 Tool |
所以 Tool Use 并非 Anthropic 独有,但 tool_use 这个 Content Block 名称属于 Anthropic API。
如果换成其他 Provider,抽象流程仍然一样:
声明 Tool Schema
↓
模型返回结构化 Tool Call 意图
↓
Application 执行真实 Tool
↓
把 Tool Result 按该 Provider 的格式返还给模型
差别在于 Wire Format。下面是截至 2026-08-09 常见 API 的对照,实际字段以各 Provider 官方文档为准:
| Provider / API | 模型返回的 Tool Call | Application 返还的 Tool Result |
|---|---|---|
| Anthropic Messages API | Assistant content[] 中出现 { "type": "tool_use", "id": "...", "name": "...", "input": {...} } | User content[] 中放 { "type": "tool_result", "tool_use_id": "...", "content": "..." } |
| OpenAI Responses API | response.output[] 中出现 { "type": "function_call", "call_id": "...", "name": "...", "arguments": "{...}" } | 下一轮 input[] 中追加 { "type": "function_call_output", "call_id": "...", "output": "..." } |
| OpenAI Chat Completions / OpenAI-compatible API | Assistant Message 中出现 tool_calls[],每项类似 { "id": "...", "type": "function", "function": { "name": "...", "arguments": "{...}" } } | 追加一条 { "role": "tool", "tool_call_id": "...", "content": "..." } Message |
| Google Gemini Interactions API | interaction.steps[] 中出现 { "type": "function_call", "id": "...", "name": "...", "arguments": {...} } | 下一轮 input 中传 { "type": "function_result", "name": "...", "call_id": "...", "result": [...] },并带上 previous_interaction_id |
| Google Gemini GenerateContent API | Model Content 的 parts[] 中出现 functionCall,包含 name 与 args | User Content 的 parts[] 中返回 functionResponse,包含 name 与 response |
| Mistral Chat API | Assistant Message 中出现 tool_calls[],每项包含 id、type: "function"、function.name、function.arguments | 追加 { "role": "tool", "name": "...", "content": "...", "tool_call_id": "..." } Message |
| Amazon Bedrock Converse API | Content Block 中出现 toolUse / ToolUseBlock,包含 toolUseId、name、input | Content Block 中返回 toolResult / ToolResultBlock,用 toolUseId 对应之前请求 |
| MCP Protocol | Client 发起 tools/call,参数里有 name 与 arguments | Server 返回 tools/call Result,通常包含 content[] 与 isError |
可以把它们统一理解成:
Anthropic: tool_use → tool_result
OpenAI Responses: function_call → function_call_output
OpenAI Chat-style: tool_calls[] → role: "tool"
Gemini: function_call → function_result
Mistral: tool_calls[] → role: "tool"
Bedrock Converse: toolUse → toolResult
MCP: tools/call → tools/call result
因此 MCP Client 的关键不是“把 Anthropic 的 tool_use 传给 MCP Server”,而是做一层 Provider Adapter:
MCP tools/list Result
↓
转换为 Provider-specific Tool Schema
↓
Provider 模型返回 Tool Call
↓
转换为 MCP tools/call
↓
MCP Server 返回 Tool Result
↓
转换为 Provider-specific Tool Result
↓
Provider 模型生成最终回答
换句话说,MCP 统一的是 Application 与 Tool Server 之间的协议;Anthropic、OpenAI、Gemini、Mistral、Bedrock 等模型 API 的消息格式仍然各自不同。
参考文档:
- Anthropic Tool use with Claude
- OpenAI Function calling
- Google Gemini Function calling
- Mistral Function Calling
- Amazon Bedrock ToolUseBlock
- Amazon Bedrock ToolResultBlock
怎么知道 content.name、input 和 id
程序员不是猜的,而是通过:
- Anthropic API Documentation;
- SDK 类型定义;
- IDE 自动补全;
- 静态类型检查。
抽象后的 tool_use Block 类似:
{
"type": "tool_use",
"id": "toolu_01...",
"name": "get_forecast",
"input": {
"latitude": 38.58,
"longitude": -121.49
}
}
name 和提供给模型的 Tool Definition 对应;input 是模型按 Input Schema 生成的 Arguments;id 标识这一次 Provider Tool Use。
这个 id 不等于 MCP JSON-RPC Request ID,它们属于不同系统、不同 ID Namespace。
八、从 tool_use 到 MCP tools/call
result = await client.call_tool(
tool_name,
tool_args,
)
client.call_tool() 是 MCP SDK 方法。内部链路是:
Claude tool_use
name + input
↓
Python MCP SDK client.call_tool
↓
SDK 构造 MCP JSON-RPC tools/call
↓
Weather Server 执行对应 Python Tool
↓
返回 MCP Tool Result
教程还加入状态文本:
final_text.append(
f"[Calling tool {tool_name} with args {tool_args}]"
)
它只用于 CLI 展示,并不执行 Tool;真正调用发生在 await client.call_tool(...)。生产 UI 不应随意展示包含敏感参数的完整 Arguments。
九、Anthropic Content 与 MCP TextContent
这两种判断来自不同系统:
content.type == "text"
检查 Anthropic Response Content Block。
isinstance(block, TextContent)
检查 MCP Tool Result Block 是否是 Python MCP SDK 的 TextContent 类。
Anthropic Response
→ content.type == "text"
MCP Tool Result
→ isinstance(block, TextContent)
它们都可能承载文本,但不能把两套类型系统混为一谈。
十、构造 Provider 所需的 tool_result
tool_results.append({
"type": "tool_result",
"tool_use_id": content.id,
"content": "\n".join(
block.text
for block in result.content
if isinstance(block, TextContent)
),
"is_error": result.is_error,
})
逐项解释:
type:告诉 Anthropic 这是工具执行结果;tool_use_id:与之前的某个tool_use对应;content:把 MCP Result 中的文本块取出并连接;is_error:告诉模型 Tool 是否以业务错误结束。
括号内:
block.text
for block in result.content
if isinstance(block, TextContent)
是 Generator Expression,可以理解为:
texts = []
for block in result.content:
if isinstance(block, TextContent):
texts.append(block.text)
content_text = "\n".join(texts)
for 和 if 看起来没有像普通语句那样额外缩进,是因为它们仍在 join(...) 的括号表达式内,不是新建了 Statement Block。
这个最小示例只收集 TextContent。若 Tool Result 还包含 Image、Audio、Embedded Resource 或 Resource Link,它们会被忽略,生产 Client 需要分别处理。
final_text 与 tool_results 的区别
final_text:最后打印给 CLI 用户的文本;tool_results:按 Anthropic API 格式返回给模型的结构化结果。
二者用途不同,不能合并成同一个列表。
十一、为什么必须第二次调用模型
Tool Result 通常只是数据,不是完整回答。程序要把模型先前的 tool_use 和实际执行得到的 tool_result 都放进消息:
if tool_results:
messages.append({
"role": "assistant",
"content": response.content,
})
messages.append({
"role": "user",
"content": tool_results,
})
response = anthropic.messages.create(
model=MODEL,
max_tokens=1000,
messages=messages,
tools=available_tools,
)
for content in response.content:
if content.type == "text":
final_text.append(content.text)
为什么要把 response.content 也保存?因为模型需要看到完整事件链:
用户原问题
+ Assistant 曾请求哪个 Tool
+ 这个 Tool 对应的 Result
↓
模型理解并组织最终自然语言回答
tool_use_id 能在一次响应包含多个 Tool Use 时,把每个 Result 和原调用正确关联。
必须记住的完整主流程
用户输入 query
↓
messages 先只包含用户问题
↓
MCP client.list_tools()
↓
取得 MCP Server 的 Tool Definitions
↓
转换成 Claude Provider 接受的 Tools 格式
↓
第一次调用 Claude
↓
┌───┴────────────┐
│ │
直接返回 Text 返回 tool_use
│ │
│ MCP client.call_tool()
│ ↓
│ MCP Server 执行 Tool
│ ↓
│ 获得 MCP Tool Result
│ ↓
│ 转成 Provider tool_result
│ ↓
│ Tool Use + Tool Result 加入 messages
│ ↓
│ 第二次调用 Claude
│ ↓
└────────→ 最终自然语言回答
第一次模型调用负责决定“是否需要 Tool、选择哪个 Tool、传哪些 Arguments”;第二次模型调用负责理解执行结果并形成回答。
如果第一次只有 Text,就不需要 MCP Tool Call。生产 Client 还应允许模型在第二次之后继续请求 Tool,循环到模型不再请求,而不是写死最多两轮。
十二、完整的 process_query
async def process_query(client: Client, query: str) -> str:
"""Process a query using Claude and available tools."""
messages = [
{
"role": "user",
"content": query,
}
]
tool_list = await client.list_tools()
available_tools = [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
}
for tool in tool_list.tools
]
response = anthropic.messages.create(
model=MODEL,
max_tokens=1000,
messages=messages,
tools=available_tools,
)
final_text = []
tool_results = []
for content in response.content:
if content.type == "text":
final_text.append(content.text)
elif content.type == "tool_use":
tool_name = content.name
tool_args = content.input
result = await client.call_tool(
tool_name,
tool_args,
)
final_text.append(
f"[Calling tool {tool_name} with args {tool_args}]"
)
tool_results.append({
"type": "tool_result",
"tool_use_id": content.id,
"content": "\n".join(
block.text
for block in result.content
if isinstance(block, TextContent)
),
"is_error": result.is_error,
})
if tool_results:
messages.append({
"role": "assistant",
"content": response.content,
})
messages.append({
"role": "user",
"content": tool_results,
})
response = anthropic.messages.create(
model=MODEL,
max_tokens=1000,
messages=messages,
tools=available_tools,
)
for content in response.content:
if content.type == "text":
final_text.append(content.text)
return "\n".join(final_text)
十三、chat_loop:持续接收用户问题
async def chat_loop(client: Client) -> None:
"""Run an interactive chat loop."""
print("\nMCP Client Started!")
print("Type your queries or 'quit' to exit.")
while True:
try:
query = (
await asyncio.to_thread(input, "\nQuery: ")
).strip()
except EOFError:
break
if query.lower() == "quit":
break
try:
response = await process_query(client, query)
print("\n" + response)
except Exception as e:
print(f"\nError: {e}")
逐项理解:
while True:不断接收问题;input():本身是阻塞函数;asyncio.to_thread(...):把阻塞输入放到工作线程,避免卡住 Event Loop;strip():去掉首尾空白;query.lower() == "quit":退出;await process_query(...):等待本轮模型调用和 Tool 执行;EOFError:输入流结束,例如终端按 Ctrl-D;- 外层
try/except:一轮失败后仍可继续下一轮。
生产代码不应把认证失败、连接断开、Validation Error、Timeout 和 Tool Business Error 全部只显示成一行字符串,而应分类处理。
十四、main 与 Python 程序入口
async def main() -> None:
if len(sys.argv) < 2:
print("Usage: python client.py <path_to_server_script>")
sys.exit(1)
async with Client(
stdio_client(server_params(sys.argv[1]))
) as client:
tool_list = await client.list_tools()
tool_names = [
tool.name
for tool in tool_list.tools
]
print("\nConnected to server with tools:", tool_names)
await chat_loop(client)
if __name__ == "__main__":
asyncio.run(main())
sys.argv[1]:命令行传入的 Server Script Path;async with:建立并最终关闭 MCP 连接;- 首次
list_tools():确认连接并打印 Tool Name; await chat_loop(client):把同一个连接交给聊天循环;asyncio.run(main()):建立 Event Loop 并运行顶层协程;if __name__ == "__main__":只有直接执行此文件时才启动。
有时网页把双下划线渲染成 Markdown 加粗,出现 if **name** == "**main**":。那不是合法 Python,正确写法必须是 __name__ 和 __main__。
十五、运行 Client
假设目录中有 client.py 和上一文的 weather.py:
uv run client.py /absolute/path/to/weather.py
启动时会发生:
client.py 启动
↓
根据扩展名选择 python
↓
创建 weather.py 子进程
↓
建立 stdio Transport
↓
初始化 MCP Client
↓
列出 Tools
↓
进入 Query 循环
首轮可能较慢,因为可能同时包含 Server 启动、Tool Discovery、Provider 推理、外部 API 请求和第二次模型调用。
十六、多个 MCP Server 怎样管理
真实 Host 往往连接 Filesystem、GitHub、Database 和 Weather 等多台 Server:
Host
├─ client_filesystem → Filesystem Server
├─ client_github → GitHub Server
└─ client_weather → Weather Server
只需要一套管理代码,但运行时为每条连接创建一个 Client Instance。一个简化的 Manager:
from contextlib import AsyncExitStack
class ClientManager:
def __init__(self):
self.stack = AsyncExitStack()
self.clients = {}
self.tool_registry = {}
async def connect(self, name, params):
transport = stdio_client(params)
client = await self.stack.enter_async_context(
Client(transport)
)
self.clients[name] = client
listed = await client.list_tools()
for tool in listed.tools:
public_name = f"{name}__{tool.name}"
self.tool_registry[public_name] = (
client,
tool.name,
tool,
)
async def close(self):
await self.stack.aclose()
Tool Registry 不只保存定义,还要保存 Tool 来自哪个 Client:
"weather__get_forecast"
→ weather_client
→ 原始 Tool Name: get_forecast
模型生成 Public Tool Name 和 Arguments 后,Host 查 Registry,再用对应 Client 调用对应 Server。加前缀还能减少多个 Server 出现同名 Tool 时的冲突。
十七、这个最小 Loop 的边界
官方示例用于教学,不是完整 Agent Runtime:
- 每个 Query 都重新
list_tools(),工具多时成本较高; - 主要演示一轮 Tool Use,复杂任务需要持续循环;
- 不保留多个 Query 之间的 Conversation Memory;
- 只处理 MCP
TextContent; - 多 Server 需要 Registry、命名和路由;
- 需要 Tool Permission、User Confirmation、Audit 和 Timeout;
is_error=true是 Tool Business Error,不一定表现为 Python Exception;- 状态提示可能泄露 Arguments,需要脱敏;
- 同步的
anthropic.messages.create()写在异步函数中,会占住当前 Event Loop Thread;生产实现应使用 Async Provider Client,或把同步调用显式隔离; - 需要限制消息、Tool Result 和循环次数,避免 Context 与成本失控。
当前官方页面还提供 TypeScript、Java、Kotlin、C#、Ruby、Rust 等语言路径。类名、Resource Cleanup 和 Provider Adapter 会变化,但核心闭环不会变化。
十八、常见误区
误区 1:list_tools() 是教程作者自己定义的
不是,它是 MCP SDK Client Method,概念上对应 tools/list。
误区 2:模型直接发送 MCP JSON-RPC
通常不是。模型产生 Tool Name 与 Arguments;Application 校验;MCP SDK 构造 tools/call。
误区 3:Tool Use 只存在于 Anthropic
通用概念不是 Anthropic 独有,但 tool_use、tool_result 和相应字段是 Anthropic 的具体 API 表达。
误区 4:所有 content 都是一种类型
不是。模型 Provider Content 和 MCP Result Content 属于不同类型系统。
误区 5:调用 Tool 后可以直接把结果显示给用户
有时可以,但多数问答场景还需要模型结合原问题解释、汇总或继续推理,因此要把结果交回模型。
误区 6:一个 Server 一个 Client 意味着重复写程序
不是。只写一套 Client Manager;运行时按连接创建多个实例,并用 Registry 路由。
十九、总结
一个完整 MCP Tool Calling Loop,本质上是 Application 在两个协议世界之间做编排:
MCP 世界
tools/list、tools/call、Tool Result
↕ Application 转换与校验
模型 Provider 世界
tools、tool_use、tool_result、Messages
最重要的三个结论是:
- Server 定义 Tool Schema,模型只生成本次调用;
- MCP Client 真正执行 Tool,SDK 负责协议消息;
- Application 负责连接、格式转换、权限、结果关联和循环控制。
当 Tool 数量从几个增长到数百、数千时,不能再把所有 Schema 每轮原样塞给模型。下一篇将进入 Progressive Discovery、Prompt Cache 和 Code Mode。

315

被折叠的 条评论
为什么被折叠?



