数据截至 (上游 commit 743ddffd0a07)
第 3 章:一次工具调用的完整链路
本章端到端追一次
tools/call:从客户端请求进网络,到你的函数返回。这是理解 FastMCP「运行期」的主线,也是依赖注入和错误处理的所在。
3.1 起点:低层 SDK 收到 JSON-RPC
FastMCP 不自己解析 JSON-RPC——它复用官方 MCP Python SDK 的 LowLevelServer(FastMCP 在 server/low_level.py:157 对它做了子类扩展)。启动时,_setup_handlers(server/mixins/mcp_operations.py:54)把 FastMCP 的处理函数用 SDK 装饰器接上去:
# server/mixins/mcp_operations.py:54 _setup_handlers —— 接线
self._mcp_server.list_tools()(self._list_tools_mcp)
self._mcp_server.call_tool(validate_input=self.strict_input_validation)(self._call_tool_mcp)
self._mcp_server.read_resource()(self._read_resource_mcp)
self._mcp_server.get_prompt()(self._get_prompt_mcp)
所以当 tools/call 到达时,SDK 会调 FastMCP 的 _call_tool_mcp,后者转调公开 API call_tool。
3.2 主干:call_tool
公开入口是 server/server.py:1200(async def call_tool)。它在一个 Context 上下文里跑完整条链。整体结构:
call_tool(name, arguments)
│
▼
① 建立 Context(async with Context(fastmcp=self))
│
▼
② 若 run_middleware=True → 进中间件洋葱链
│ 洋葱最内层再回调 call_tool(run_middleware=False)
▼
③ get_tool(name) ← 经 providers 聚合定位工具(找不到再试 hash 名分发)
│
▼
④ tool._run(arguments) ← 执行(下节)
│
▼
⑤ try/except 错误分类 + 遮蔽
中间件回调的巧思(server/server.py:1263): 中间件链的最内层 call_next 又指回 call_tool,但这次带 run_middleware=False,避免无限套娃。也就是「同一个方法,第一次带中间件、第二次不带」。
3.3 中间件洋葱怎么组装
中间件链在 server/server.py:507(_run_middleware)构建,是经典的洋葱包裹:
# server/server.py:507 _run_middleware —— 反向包裹成洋葱
chain = call_next
for mw in reversed(self.middleware): # 反着遍历
next_chain = chain
async def wrapped(context, mw=mw, call_next=next_chain):
return await mw(context, call_next) # 每层包住下一层
chain = wrapped
return await chain(context)
每个中间件是 Middleware(server/middleware/middleware.py:88),按 MCP 方法分派到 on_call_tool / on_list_tools / on_read_resource 等钩子(middleware.py:168 起)。内置的有鉴权、限流、缓存、计时、日志等(server/middleware/ 目录)。第 6 章细讲。
3.4 定位工具:providers 聚合
get_tool(name) 走的是 provider 聚合逻辑(第 4 章主题):FastMCP 是个 AggregateProvider,它按顺序问每个 provider「你有这个工具吗」,先返回非 None 的赢。装饰器注册的工具在 LocalProvider 里,静态组件总是优先于动态 provider。
如果常规名字找不到,还有第二条路:哈希名分发(server/server.py:1288 起)——某些后端工具用 <hash>_<局部名> 格式暴露,靠反查哈希表定位。这是给「挂载/代理」场景准备的路由后门。
3.5 执行:校验与调用合一
找到工具后调 tool._run(arguments)(tools/base.py:372)。它先看要不要走后台任务(check_background_task),否则调 run()。对 FunctionTool 而言,run() 在 tools/function_tool.py:385,核心在 _execute(:436):
# tools/function_tool.py:436 _execute —— 校验即执行
if exec_is_async:
result = type_adapter.validate_python(arguments) # 校验 + 得到协程
elif self.run_in_thread:
# 同步函数:丢线程池,避免阻塞事件循环
result = await call_sync_fn_in_threadpool(type_adapter.validate_python, arguments)
else:
result = type_adapter.validate_python(arguments)
...
if inspect.isawaitable(result):
result = await result # 等待异步结果
这里就是第 2 章埋的伏笔落地: type_adapter.validate_python(arguments) 一步同时做「校验入参」和「调用函数」——因为这个 adapter 是套在函数上的。校验失败抛 pydantic 错误,校验通过就等于函数已被调用。
同步函数默认丢线程池(run_in_thread=True),避免一个慢的同步工具卡死整个 async 事件循环——这是 async 框架的必修课。
3.6 依赖注入:Context 与 Depends
工具函数怎么拿到「与客户端对话的能力」?靠依赖注入。有两种写法,最终走同一条路径:
# 示意,非源码。 两种拿 Context 的写法
@mcp.tool
async def a(x: int, ctx: Context) -> str: # 写法一:类型注解
await ctx.info("hi")
return str(x)
@mcp.tool
async def b(x: int, ctx: Context = CurrentContext()) -> str: # 写法二:显式依赖
...
统一发生在两步:
- 注册期
transform_context_annotations(server/dependencies.py:180)扫描签名,把「类型是Context但没有依赖默认值」的参数,自动补上CurrentContext()默认值——于是写法一被改写成写法二。这段还小心地重排了参数(带默认值的要排在无默认值之后),处理各种参数种类(dependencies.py:229起)。 - 运行期
without_injected_parameters(server/dependencies.py:539)生成一个 wrapper:它的对外签名剥掉了注入参数(所以模型不用填),被调用时内部resolve_dependencies把Context、Depends()的值解析好再喂给真函数(dependencies.py:588的async def wrapper)。
为什么统一成
Depends: FastMCP 有两套 DI 传统——老的「ctx: Context类型注解」和新的「Depends()显 式声明」。把前者在注册期改写成后者,就只需要维护一条解析路径。transform_context_annotations的 docstring 明说了这个动机。
3.7 错误分类:不是所有异常都一样
call_tool 的 try/except(server/server.py:1310 起)对异常做了细致分类,这是生产级框架的讲究:
| 异常类型 | 处理 | 意图 |
|---|---|---|
ValidationError(参数校验失败) | 记 warning,原样抛 | 这是客户端的错(传了坏参数),不是服务器 bug |
FastMCPError | 按其 log_level 记录后抛 | 框架已知的领域错误 |
httpx 429 / 超时 | 转成友好 ToolError(「被限流,请重试」) | 让大模型看到可操作的提示 |
其它 Exception | 若 _mask_error_details 打开 → 遮蔽成通用消息 | 默认隐藏内部细节,防信息泄露 |
错误类型体系在 exceptions.py:FastMCPError 下有 ValidationError、ToolError、ResourceError、PromptError、AuthorizationError,另有 NotFoundError、DisabledError。
默认遮蔽错误详情(_mask_error_details)是重要的安全默认:工具内部抛的原始异常消息(可能含路径、密钥、SQL)默认不回传给客户端,只回一句通用错误——除非你显式关掉遮蔽。
代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 低层 SDK 服务器 | fastmcp_slim/fastmcp/server/low_level.py | LowLevelServer |
| 协议处理函数接线 | fastmcp_slim/fastmcp/server/mixins/mcp_operations.py | _setup_handlers、_call_tool_mcp |
| 调用主干 | fastmcp_slim/fastmcp/server/server.py | FastMCP.call_tool |
| 中间件洋葱 | fastmcp_slim/fastmcp/server/server.py | FastMCP._run_middleware |
| 执行入口 | fastmcp_slim/fastmcp/tools/base.py | Tool._run |
| 校验即执行 | fastmcp_slim/fastmcp/tools/function_tool.py | FunctionTool._execute、FunctionTool.run |
| Context 注解改写 | fastmcp_slim/fastmcp/server/dependencies.py | transform_context_annotations |
| 依赖解析 wrapper | fastmcp_slim/fastmcp/server/dependencies.py | without_injected_parameters、resolve_dependencies |
| 错误类型体系 | fastmcp_slim/fastmcp/exceptions.py | FastMCPError、ToolError、ValidationError、NotFoundError |