python-sdk 结构化输出完全指南:让 MCP 工具返回类型注解即输出 Schema
【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk
<输出文章>
MCP Python SDK 结构化输出指南:从返回类型注解到structured_content全流程解析
本指南以官方 python-sdk(Model Context Protocol 的 Python 官方实现)中docs/servers/structured-output.md为核心骨架,系统讲解 MCP 服务器工具(Tool)的**结构化输出(Structured Output)**机制:output_schema从何而来、structured_content有哪些形态、SDK 如何保证输出与 Schema 一致。读完本文,你将掌握用纯类型注解声明工具输出契约、在content与structured_content双通道间取舍、以及如何借助structured_output=False/True精确控制工具行为。
一句话概括本机制的核心:工具的返回类型注解就是输出 Schema——你早已把它写好了。
什么是结构化输出:返回类型注解即输出 Schema
一个返回普通str的工具,其结果会被同时产出两次:
- 以文本形式出现在
content中; - 以
{"result": "..."}形式出现在structured_content中。
本文讨论的就是这第二个通道:它来自哪里、能呈现哪些形态、SDK 又是如何保证它"诚实"的。
先看最简单的一个工具(完整示例见 tutorial001.py):
from mcp.server import MCPServer mcp = MCPServer("Weather") READINGS = {"London": 17, "Cairo": 34, "Reykjavik": 4} @mcp.tool() def get_temperature(city: str) -> int: """Current temperature in a city, in whole degrees Celsius.""" return READINGS[city]关键就在函数签名里的-> int。正是这个注解,让 SDK 在tools/list阶段发布工具时,除了根据参数构建的输入 Schema(参见 Tools 一文),还会携带一份output_schema:
{ "properties": { "result": {"title": "Result", "type": "integer"} }, "required": ["result"], "title": "get_temperatureOutput", "type": "object" }裸的int本身不是 JSON 对象,因此 SDK 将其包装为{"result": ...}。调用该工具后,两个通道都会被填充:
result.content # [TextContent(text="17")] result.structured_content # {"result": 17}所有标量类型都使用同样的包装规则:str、int、float、bool、bytes、None。
两个通道:给模型读的文本,给应用读的数据
为什么要把同一个值发送两次?
content是给模型(model)看的。语言模型读取的是文本,这是模型能看到的结果的唯一部分;structured_content是给**模型所运行的应用(application)**看的:代码想要的是数字17,而不是一句包含 "17" 的话;output_schema是两者之间的契约,在工具被调用之前就已对外发布。
你只需返回一个 Python 值,SDK 会为你填满全部三个部分。从源码实现看,这一转换发生在 func_metadata.py 的FuncMetadata.convert_result()方法中:它将函数返回值转换为CallToolResult(content=unstructured_content, structured_content=structured_content),其中非字符串值通过_convert_to_content()序列化为 JSON 文本(pydantic_core.to_json),结构化部分则经由 PydanticTypeAdapter校验后按 JSON 模式导出。
返回一个 Pydantic 模型:声明即 Schema
声明一个 PydanticBaseModel并返回其实例(见 tutorial002.py):
from pydantic import BaseModel, Field from mcp.server import MCPServer mcp = MCPServer("Weather") class WeatherData(BaseModel): temperature: float = Field(description="Degrees Celsius.") humidity: float = Field(description="Relative humidity, 0 to 1.") conditions: str @mcp.tool() def get_weather(city: str) -> WeatherData: """Current weather for a city.""" return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast")此时WeatherData本身就是 Schema——没有包装层,也没有result键:
{ "properties": { "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, "conditions": {"title": "Conditions", "type": "string"} }, "required": ["temperature", "humidity", "conditions"], "title": "WeatherData", "type": "object" }structured_content就是该对象本身,字段一一对应:
result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}模型也没有被冷落。SDK 会把同一对象序列化为 JSON 文本放进content:
{ "temperature": 16.2, "humidity": 0.83, "conditions": "Overcast" }注意temperature和humidity上的Field(description=...)也进入了 Schema。描述输入参数的那套Field机制,同样用于描述输出字段。
如果你用过 FastAPI 的
response_model,对这个模式一定不陌生:声明一个 Pydantic 模型作为响应,由框架负责序列化与文档化。唯一的差别在于,这里返回类型注解就是全部声明。
从实现上看,_create_output_model()对BaseModel子类走的是"直接使用"分支(model = type_annotation),并且convert_result()在校验后通过validated.model_dump(mode="json", by_alias=True)导出,确保返回值与output_schema严格同构。
使用 TypedDict:不想写类时的轻量选择
并非每种形状都值得定义一个类。TypedDict可以产出同样的 Schema(见 tutorial003.py):
from typing import TypedDict from mcp.server import MCPServer mcp = MCPServer("Weather") class WeatherData(TypedDict): temperature: float humidity: float conditions: str @mcp.tool() def get_weather(city: str) -> WeatherData: """Current weather for a city.""" return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast")TypedDict在运行时就是普通的dict,所以你就直接构造并返回一个 dict。Schema、校验和structured_content遵循与BaseModel版本相同的规则:
- 添加类 docstring 或
Annotated[..., Field(description=...)],它们会变成字段描述; - 用
NotRequired标记的可选键,如果你在 dict 中省略了它,它也不会出现在structured_content中。
实现细节上,SDK 在 func_metadata.py 的_pydantic_readable_typeddict()中做了兼容处理:在 Python 3.12 以下,typing.TypedDict会被重建为typing_extensions.TypedDict,以便 Pydantic 读取其键、docstring 与配置(包括ReadOnly、NotRequired等限定符),这样工具作者完全无需关心版本差异。
使用 dataclass:任何带类型注解的类都可以
Dataclass 同样可用,任何属性带类型提示的普通类也都可以。SDK 会在幕后根据这些注解构建一个 Pydantic 模型(见 tutorial004.py):
from dataclasses import dataclass from mcp.server import MCPServer mcp = MCPServer("Weather") @dataclass class WeatherData: temperature: float humidity: float conditions: str @mcp.tool() def get_weather(city: str) -> WeatherData: """Current weather for a city.""" return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast")三种写法(BaseModel/TypedDict/ dataclass),产出同一套 Schema。用你代码库中已有的那一种就好。
实现层面,_create_output_model()对"其他带注解的类"走_create_model_from_class():它用get_type_hints()提取类属性注解,通过create_model(cls.__name__, __config__=ConfigDict(from_attributes=True), ...)动态构建 Pydantic 模型;类上带默认值的字段会成为可选字段,没有默认值的字段进入 required 集合。
返回列表:{"result": ...}包装与$defs引用
list[...]本身也不是 JSON 对象,因此同样会被包进{"result": ...},而你的元素类型会以$defs引用的形式出现在包装内部(见 tutorial005.py):
from pydantic import BaseModel from mcp.server import MCPServer mcp = MCPServer("Weather") class WeatherData(BaseModel): temperature: float humidity: float conditions: str @mcp.tool() def get_forecast(city: str, days: int) -> list[WeatherData]: """Daily forecast for a city.""" return [WeatherData(temperature=16.2 + day, humidity=0.83, conditions="Overcast") for day in range(days)]生成的 Schema:
{ "$defs": { "WeatherData": { "properties": { "temperature": {"title": "Temperature", "type": "number"}, "humidity": {"title": "Humidity", "type": "number"}, "conditions": {"title": "Conditions", "type": "string"} }, "required": ["temperature", "humidity", "conditions"], "title": "WeatherData", "type": "object" } }, "properties": { "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} }, "required": ["result"], "title": "get_forecastOutput", "type": "object" }请求两天预报时,structured_content是{"result": [{...}, {...}]};而content则变成两个TextContent块——每个元素一块。列表会为模型展平,而不是作为一个字符串整体倾倒。这一点在_convert_to_content()中有明确实现:对list/tuple值会递归展平(chain.from_iterable),将每个元素分别转换为内容块。
tuple[...]、联合类型(union)以及Optional[...]遵循相同的包装规则。
返回字典:dict[str, ...]是唯一不包装的泛型
dict[str, ...]本身已经是一个 JSON 对象,因此不会被包装(见 tutorial006.py):
from mcp.server import MCPServer mcp = MCPServer("Weather") READINGS = {"London": 16.2, "Cairo": 34.1, "Reykjavik": 4.4} @mcp.tool() def get_temperatures(cities: list[str]) -> dict[str, float]: """Current temperature for each city, in degrees Celsius.""" return {city: READINGS[city] for city in cities}生成的 Schema:
{ "additionalProperties": {"type": "number"}, "title": "get_temperaturesDictOutput", "type": "object" }调用结果:
result.structured_content # {"London": 16.2, "Reykjavik": 4.4}注意键必须是str。dict[int, float]无法成为 JSON 对象,因此会回退到{"result": ...}包装。这一点在_create_output_model()的GenericAlias分支中有精确判断:仅当get_origin(type_expr) is dict且键类型为str时才直接使用,并打上Field(title=f"{func_name}DictOutput")作为 Schema 标题;其余情况一律走包装路径。
实现层面,字典型结果使用 Pydantic 的TypeAdapter进行校验与序列化。如果你检查某个工具的FuncMetadata.output_model,它会持有该字典类型注解及其 Schema 标题——FuncMetadata在构造时若发现output_model非空而output_schema为空,会立即用TypeAdapter(...).json_schema()派生 Schema(见 func_metadata.py 的model_post_init)。
输出验证:Schema 不是文档,而是执行标准
output_schema不是摆设。无论你的函数返回什么,在离开服务器之前都会被拿来与它校验。
当你亲手构建值时不会察觉到这一点——Pydantic 已经确保你的WeatherData就是WeatherData。真正遇到麻烦的是数据来自你无法控制的地方的那一天(见 tutorial007.py):
import json from pydantic import BaseModel from mcp.server import MCPServer mcp = MCPServer("Weather") UPSTREAM = {"London": '{"temperature": 16.2, "conditions": "Overcast"}'} class WeatherData(BaseModel): temperature: float humidity: float conditions: str @mcp.tool() def get_weather(city: str) -> WeatherData: """Current weather for a city.""" return json.loads(UPSTREAM[city])注解承诺返回WeatherData,但上游响应停止了发送humidity字段。
调用get_weather时,服务器不会悄悄地把一个残缺对象交给客户端,而是让这次调用失败:客户端收到is_error=True与Error executing tool get_weather,模型因此知道调用失败了,而不是自信地"读"出并不存在的天气数据。字段名则记录在服务器ERROR级别的日志中,供你排查:
Tool 'get_weather' raised an unexpected exception ... pydantic_core._pydantic_core.ValidationError: 1 validation error for WeatherData humidity Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]顺带一提:从一个-> WeatherData的工具返回普通dict完全没问题——这正是json.loads产出的东西。校验针对的是值本身,而不是 Python 类型。
从源码看,校验在convert_result()中执行:adapter.validate_python(result, by_alias=True, by_name=True)会抛出pydantic_core.ValidationError,进而以工具错误的形式反馈给客户端;相关行为在 test_tool_manager.py 的TestStructuredOutput测试类中有覆盖(如test_tool_with_basemodel_output断言structured_content == {"name": "John", "age": 30})。
主动退出与强制开启:structured_output=False / True
有时返回类型注解是写给类型检查器看的,而不是给协议用的。传入structured_output=False,工具就变成纯文本模式(见 tutorial008.py):
from mcp.server import MCPServer mcp = MCPServer("Weather") @mcp.tool(structured_output=False) def weather_report(city: str) -> str: """A human-readable weather report for a city.""" return f"{city}: 17 degrees, overcast, light rain easing by evening."此时没有output_schema、没有包装、没有校验。structured_content为None,content就是你返回的字符串。
与之相反,structured_output=True会把自动检测升级为硬性要求:一个返回类型无法生成 Schema 的工具,会在导入时(注册时)直接报错,而不是默默回退为文本。这一行为由 func_metadata.py 的func_metadata()实现:它使用StrictJsonSchema(一个"遇到警告即抛异常"的 JSON Schema 生成器,emit_warning()直接 raiseValueError)派生 Schema;当structured_output=True而模型创建失败时,抛出InvalidSignature("Function ... return type ... is not serializable for structured output")。参数structured_output的三种取值语义在func_metadata()的 docstring 中有明确说明:
None:根据返回类型注解自动检测;True:强制创建结构化工具(返回类型允许的前提下);False:无条件创建非结构化工具。
内容块与媒体:默认自动退出
内容块与媒体(TextContent、EmbeddedResource、Image、Audio及其同类,无论是单独出现、作为list/tuple/Sequence的元素,还是作为联合类型的成员)都会为你自动退出结构化输出:它们是给模型读的,因此自动检测不会从它们身上派生 Schema(Image与Audio详见 Images, audio & icons)。不过structured_output=True仍会为这些内容块类强制生成一个 Schema。
实现上,_returns_content()专门检测返回注解是否为内容块类型(裸类型、Annotated包裹、联合成员或list/tuple/Sequence元素),命中即返回非结构化元数据(FuncMetadata(arg_model=...)且无output_model)。相关注释明确说明:若为其派生 Schema,会把块自身模型当作output_schema发布,并(除非工具自行构建CallToolResult)把每个块原样回显进structured_content,因此默认予以排除。
一个没有类型注解的类:静默失守的陷阱
有一种情况会让你无意间落入非结构化:返回一个类体上没有任何注解的类(见 tutorial009.py):
from mcp.server import MCPServer mcp = MCPServer("Weather") class Station: def __init__(self, name: str, online: bool): self.name = name self.online = online @mcp.tool() def get_station(name: str) -> Station: """Look up a weather station by name.""" return Station(name=name, online=True)Station在__init__里设置了name和online,但类本身没有声明任何注解。SDK 读取类注解时一无所获,于是放弃。
最危险的是:它放弃得悄无声息。output_schema为None,structured_content为None,模型读到的文本是对象的repr:
"<server.Station object at 0x7f539d75b230>"没有报错、没有警告,一个毫无用处的工具。解决方式有两种:
- 把注解移到类体上(例如用 dataclass 或
BaseModel重写); - 传入
structured_output=True,把这种情况变成模块导入时的硬错误:Function get_station: return type <class 'server.Station'> is not serializable for structured output。
从源码看,_create_output_model()对"其他类类型"分支用get_type_hints(type_annotation)探测;若返回空 dict(即类无注解),model保持None,自动检测模式下静默回退为非结构化。
需要完全控制(自行构建
CallToolResult,或附加应用可见而模型不可见的_meta)?那是 The low-level Server 的范畴。
小结:结构化输出的五条要点
- 返回类型注解就是输出 Schema,并在
tools/list中以output_schema形式对外发布; - 标量、列表、元组与联合类型被包装进
{"result": ...};模型、TypedDict、dataclass、带注解的类以及dict[str, ...]本身就是对象,保持原样; - 每个结果都同时携带
content(给模型的文本)与structured_content(给应用的数据); - 返回值会与 Schema 校验,不匹配就是工具错误,而不是损坏的结果;
structured_output=False让单个工具退出;内容块、Image、Audio默认退出;没有类型注解的类会静默退出,务必留意。
至此,你已经掌握了一个工具能"说"回的全部内容。下一步,请阅读第二个原语:Resources。
【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考