Python 实战开发 MCP Client:完整 Tool Calling Loop

AI 时代程序员必备技能

Claude Code 完整实战,MCP 与子代理工程化落地,让 AI 接管脏活累活

Python 实战开发 MCP Client:完整 Tool Calling Loop

MCP 从入门到工程实践系列,第 7 篇,共 9 篇。
本文以 MCP 2026-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 生成最终回答

难点在于代码里同时存在三套对象:

  1. Python MCP SDK;
  2. Anthropic Messages API;
  3. MCP Server 返回的 Content Block。

看到一个 contentidclient 时,必须先判断它来自哪一层。

一、环境、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.ClientMCP Serverlist_toolscall_tool 等协议操作
AnthropicAnthropic API调用 Claude 模型

在项目根目录创建 .env

ANTHROPIC_API_KEY=你的密钥

并把它加入 .gitignoreload_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:

从内到外读:

  1. server_params(...) 描述 Server Process;
  2. stdio_client(...) 创建本地 stdio Transport;
  3. Client(...) 创建 MCP Client;
  4. async with 建立连接,并在退出时清理 Transport 与子进程;
  5. 小写 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_use Content 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 Responsetype: "tool_use"携带 idnameinput
Anthropic Resulttype: "tool_result"tool_use_id 返还结果
MCP Protocoltools/callClient 真正请求 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 CallApplication 返还的 Tool Result
Anthropic Messages APIAssistant content[] 中出现 { "type": "tool_use", "id": "...", "name": "...", "input": {...} }User content[] 中放 { "type": "tool_result", "tool_use_id": "...", "content": "..." }
OpenAI Responses APIresponse.output[] 中出现 { "type": "function_call", "call_id": "...", "name": "...", "arguments": "{...}" }下一轮 input[] 中追加 { "type": "function_call_output", "call_id": "...", "output": "..." }
OpenAI Chat Completions / OpenAI-compatible APIAssistant Message 中出现 tool_calls[],每项类似 { "id": "...", "type": "function", "function": { "name": "...", "arguments": "{...}" } }追加一条 { "role": "tool", "tool_call_id": "...", "content": "..." } Message
Google Gemini Interactions APIinteraction.steps[] 中出现 { "type": "function_call", "id": "...", "name": "...", "arguments": {...} }下一轮 input 中传 { "type": "function_result", "name": "...", "call_id": "...", "result": [...] },并带上 previous_interaction_id
Google Gemini GenerateContent APIModel Content 的 parts[] 中出现 functionCall,包含 nameargsUser Content 的 parts[] 中返回 functionResponse,包含 nameresponse
Mistral Chat APIAssistant Message 中出现 tool_calls[],每项包含 idtype: "function"function.namefunction.arguments追加 { "role": "tool", "name": "...", "content": "...", "tool_call_id": "..." } Message
Amazon Bedrock Converse APIContent Block 中出现 toolUse / ToolUseBlock,包含 toolUseIdnameinputContent Block 中返回 toolResult / ToolResultBlock,用 toolUseId 对应之前请求
MCP ProtocolClient 发起 tools/call,参数里有 nameargumentsServer 返回 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 的消息格式仍然各自不同。

参考文档:

怎么知道 content.nameinputid

程序员不是猜的,而是通过:

  • 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)

forif 看起来没有像普通语句那样额外缩进,是因为它们仍在 join(...) 的括号表达式内,不是新建了 Statement Block。

这个最小示例只收集 TextContent。若 Tool Result 还包含 Image、Audio、Embedded Resource 或 Resource Link,它们会被忽略,生产 Client 需要分别处理。

final_texttool_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_usetool_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

最重要的三个结论是:

  1. Server 定义 Tool Schema,模型只生成本次调用;
  2. MCP Client 真正执行 Tool,SDK 负责协议消息;
  3. Application 负责连接、格式转换、权限、结果关联和循环控制。

当 Tool 数量从几个增长到数百、数千时,不能再把所有 Schema 每轮原样塞给模型。下一篇将进入 Progressive Discovery、Prompt Cache 和 Code Mode。

参考资料

AI 时代程序员必备技能

Claude Code 完整实战,MCP 与子代理工程化落地,让 AI 接管脏活累活

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值