跳转至

结构化输出

机器翻译

本页由英文文档自动翻译而来,以英文页面为准。如果有读起来不对的地方,翻译页面说明了如何反馈。

返回普通 str 的工具会把结果产出两次:一次是 content 里的文本,一次是 structured_content 里的 {"result": "..."}

本页讲的就是这第二个通道:它从哪里来、可能有哪些形态,以及 SDK 如何保证它货真价实。

一句话概括:返回类型注解就是输出模式(output schema)。你其实已经写好了。

输出模式

server.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 时发出的工具除了根据参数构建的输入模式(详见 工具),还会带上一个 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}

所有标量都是同样的包装:strintfloatboolbytesNone

两个通道

为什么同一个值要发两次?

  • content 是给模型看的。语言模型读的是文本;整个结果里它只看得到这一部分。
  • structured_content 是给模型所在的应用程序用的:代码想要的是 17,而不是一句含有“17”的话。
  • output_schema 是二者之间的契约,早在工具被调用之前就已发布。

你只返回一个 Python 值,SDK 把这三样全部填好。

返回模型

用 Pydantic BaseModel 声明形状,并返回一个实例:

server.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 就是模式。没有包装,也没有 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"
}

注意,temperaturehumidity 上的 Field(description=...) 进入了模式。描述输入的那个 Field,同样描述了输出。

Info

如果用过 FastAPI 的 response_model,这一套你已经熟悉:把 Pydantic 模型声明为响应,序列化和文档都替你做好。唯一的不同是,在这里返回注解就是全部的声明。

TypedDict

不是每种形状都值得专门写一个类。TypedDict 产出的模式完全一样:

server.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,所以构建并返回的也就是它。模式、校验和 structured_content 都与 BaseModel 版本完全相同(只是少了描述,TypedDict 里没有地方写)。

dataclass

dataclass 也行,任何属性带类型提示的普通类同样可以。SDK 会在幕后根据注解构建出一个 Pydantic 模型。

server.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")

三种写法,一个模式。代码库里本来用哪种,就用哪种。

列表

list[...] 也不是 JSON 对象,所以同样套上 {"result": ...} 包装,元素类型以 $defs 引用的形式放在里面:

server.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)]
{
  "$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 块,每个元素一个:列表会为模型逐项展开,而不是整个转储成一个字符串。

tuple[...]、联合类型和 Optional[...] 的包装方式相同。

字典

dict[str, ...] 是唯一一个本身就是 JSON 对象的泛型,所以不会被包装:

server.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}
{
  "additionalProperties": {"type": "number"},
  "title": "get_temperaturesDictOutput",
  "type": "object"
}
result.structured_content  # {"London": 16.2, "Reykjavik": 4.4}

键必须是 strdict[int, float] 成不了 JSON 对象,所以会退回到 {"result": ...} 包装。

校验

output_schema 并非只是文档。函数返回的任何内容,在离开服务器之前都会对照它校验

手工构建值的时候你察觉不到:Pydantic 早已保证你的 WeatherData 确实是 WeatherData。等到哪天数据来自你控制不了的地方,你就会察觉了:

server.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 了。

Check

调用 get_weather,它不会悄悄把一个缺了一半的对象递给客户端。调用会失败,错误的头几行直接点名那个字段:

Error executing tool get_weather: 1 validation error for WeatherData
humidity
  Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]

这段文本作为工具结果返回,并带着 is_error=True,于是模型知道调用失败了,而不会信心十足地去读根本不存在的天气数据。

顺带一提,从 -> WeatherData 的工具里返回普通 dict 完全没问题。json.loads 产出的正是它。校验针对的是值,而不是 Python 类型。

选择退出

有时返回注解是写给类型检查器看的,而不是给协议的。传入 structured_output=False,工具就变成纯文本:

server.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_contentNonecontent 就是你返回的字符串。

反过来,structured_output=True 会把自动检测变成硬性要求:返回类型产不出模式的工具会在导入时直接抛错,而不是退回到纯文本。

没有类型提示的类

有一种情况,你没有要求也会落得非结构化:返回一个类体上没有任何注解的类。

server.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__ 里设置了 nameonline,但本身什么都没声明。SDK 去读类注解,一个也没找到,于是放弃。

Warning

而且是悄无声息地放弃。output_schemaNonestructured_contentNone,模型读到的文本是这个对象的 repr

"<server.Station object at 0x7f539d75b230>"

没有报错,没有警告,只剩一个没用的工具。把注解挪到类体上,或者传入 structured_output=True——后者会在模块导入的那一刻就让它直接报错:Function get_station: return type <class 'server.Station'> is not serializable for structured output

Tip

需要完全掌控(自己构建 CallToolResult,或者附加应用程序看得见、模型看不见的 _meta)?详见 底层 Server

回顾

  • 返回类型注解就是输出模式,在 tools/list 中以 output_schema 发布。
  • 标量、列表、元组和联合类型会被包装进 {"result": ...}。模型、TypedDict、dataclass、带注解的类以及 dict[str, ...] 本身已是对象,保持原样。
  • 每个结果都同时带有 content(文本,给模型) structured_content(数据,给应用程序)。
  • 返回的内容会对照模式校验。不匹配就是工具错误,而不是一个损坏的结果。
  • structured_output=False 让工具退出结构化输出。没有类型提示的类会悄无声息地退出;要当心。

至此,工具能回传的一切都由你掌控。接下来是第二种原语:资源