結構化輸出
回傳普通 str 的工具會把結果產生兩份:一份是 content 裡的文字,一份是 structured_content 裡的 {"result": "..."}。
這一頁談的就是第二個通道:它從哪裡來、可以有哪些形狀,以及 SDK 如何確保它名副其實。
簡單說:回傳型別註記就是輸出 schema。你早就寫好了。
輸出 schema
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(這部分在 工具 說明),旁邊還帶了一個 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是給模型的。語言模型讀的是文字,結果裡它只看得到這個部分。structured_content是給模型所在的應用程式的:那些程式碼要的是17,不是一句包含「17」的話。output_schema是兩者之間的合約,在工具被呼叫之前就已經公布。
你回傳一個 Python 值,SDK 把三者都填好。
回傳一個模型
把形狀宣告成 Pydantic BaseModel,再回傳一個實例:
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,同樣可以描述輸出。
Info
如果用過 FastAPI 的 response_model,這一套你早就認識了:把 Pydantic 模型宣告為回應,序列化和文件都幫你做好。唯一的差別是,這裡的回傳註記就是全部的宣告。
TypedDict
不是每個形狀都值得寫一個類別。TypedDict 會產生同樣的 schema:
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,所以建立並回傳的就是它。schema、驗證和 structured_content 都跟 BaseModel 版本一模一樣(少了描述,因為 TypedDict 沒有地方放)。
dataclass
dataclass 也可以,任何屬性帶有型別提示的普通類別也都可以。SDK 會在背後用這些註記建出一個 Pydantic 模型。
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")
三種寫法,同一個 schema。程式碼庫裡已經用哪一種,就用哪一種。
串列
list[...] 同樣不是 JSON 物件,所以也會套上 {"result": ...} 包裝,元素型別則以 $defs 參照的形式放在裡面:
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[...]、union 和 Optional[...] 也用同樣的方式包裝。
字典
dict[str, ...] 是唯一本身就是 JSON 物件的泛型,所以不會被包裝:
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}
鍵必須是 str。dict[int, float] 沒辦法成為 JSON 物件,所以會退回 {"result": ...} 包裝。
驗證
output_schema 不是寫來看的說明文件。函式回傳的任何東西,在離開伺服器之前都會拿它來驗證。
自己手動建值的時候感覺不到:Pydantic 早就確保 WeatherData 確實是 WeatherData。等到哪天資料來自你無法掌控的地方,就會感覺到了:
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,工具就只有文字:
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 的工具,會在匯入時引發例外,而不是退回文字。
沒有型別提示的類別
有一種情況會在沒有要求的前提下變成非結構化:回傳一個本體上沒有任何註記的類別。
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 讀取類別註記,什麼都沒找到,於是放棄。
Warning
而且是默默放棄。output_schema 是 None,structured_content 是 None,模型讀到的文字是物件的 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。
重點回顧
- 回傳型別註記就是輸出 schema,會在
tools/list裡以output_schema公布。 - 純量、串列、tuple 和 union 會包進
{"result": ...}。模型、TypedDict、dataclass、帶註記的類別和dict[str, ...]本來就是物件,維持原樣。 - 每個結果都帶有
content(文字,給模型)和structured_content(資料,給應用程式)。 - 回傳的東西會拿 schema 驗證。不符合就是工具錯誤,不會是一份壞掉的結果。
structured_output=False讓工具退出。沒有型別提示的類別會默默退出,要留意。
工具能回覆的一切,現在都掌握在你手上了。接下來是第二個基本元件:資源。