구조화된 출력 (Structured Output)
구조화된 출력 (Structured Output)
평범한 str을 반환하는 도구는 그 결과를 두 번 만들어요: content에는 텍스트로, structured_content에는 {"result": "..."}로.
이 페이지는 그 두 번째 채널에 관한 것이에요: 어디서 오는지, 취할 수 있는 모든 형태, 그리고 SDK가 어떻게 정직함을 지키는지.
짧게 말하면 이래요: 반환 타입 애너테이션이 곧 출력 스키마예요. 이미 쓰고 있죠.
출력 스키마
# docs_src/structured_output/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 동안 보내는 도구는, 파라미터에서 만든 입력 스키마 옆에 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는 둘 사이의 계약이고, 도구가 호출되기 전에 공개돼요.
파이썬 값을 하나 반환하면 SDK가 세 가지를 모두 채워요.
모델 반환하기
형태를 Pydantic BaseModel로 선언하고 인스턴스를 반환해요:
# docs_src/structured_output/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 가 이제 스키마예요. 래퍼도 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가 같은 객체를 content용 JSON 텍스트로 직렬화해요:
{
"temperature": 16.2,
"humidity": 0.83,
"conditions": "Overcast"
}
temperature와 humidity의 Field(description=...)가 스키마에 들어온 걸 주목하세요. 입력을 설명한 그 Field가 출력도 설명해요.
!!! info
FastAPI의 response_model을 써봤다면 아는 내용이에요: 선언된 응답으로 Pydantic 모델, 직렬화되고 문서화됨. 유일한 차이는 여기선 반환 애너테이션이 선언 전체라는 것.
TypedDict
모든 형태가 클래스를 받을 자격이 있는 건 아니에요. TypedDict는 같은 스키마를 만들어요:
# docs_src/structured_output/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라서 그걸 만들어 반환하면 돼요. 스키마, 검증, structured_content는 BaseModel 버전과 같은 규칙을 따라요: 클래스 docstring이나 Annotated[..., Field(description=...)]를 추가하면 설명이 되고, dict에서 빠뜨린 NotRequired 키는 structured_content에도 없어요.
dataclass
dataclass도 동작하고, 속성에 타입 힌트가 있는 일반 클래스도 동작해요. SDK가 뒤에서 애너테이션으로 Pydantic 모델을 만들어요.
# docs_src/structured_output/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")
철자 세 가지, 스키마 하나. 코드베이스에 이미 있는 걸 쓰면 돼요.
리스트
list[...]도 JSON 객체가 아니므로 {"result": ...} 래퍼를 받고, 항목 타입이 안에 $defs 참조로 들어가요:
# docs_src/structured_output/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)]
{
"$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"
}
2일 예보를 요청하면 structured_content는 {"result": [{...}, {...}]}예요. content는 항목당 하나씩 두 TextContent 블록이 돼요: 리스트는 하나의 문자열로 던져지지 않고 모델을 위해 평평해져요.
tuple[...], union, Optional[...]도 같은 방식으로 감싸져요.
딕셔너리
dict[str, ...]는 이미 JSON 객체인 유일한 제네릭이라 감싸지지 않아요:
# docs_src/structured_output/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}
{
"additionalProperties": {"type": "number"},
"title": "get_temperaturesDictOutput",
"type": "object"
}
result.structured_content # {"London": 16.2, "Reykjavik": 4.4}
키는 str이어야 해요. dict[int, float]는 JSON 객체가 될 수 없으니 {"result": ...} 래퍼로 돌아가요.
딕셔너리 결과는 검증과 직렬화에 Pydantic의 TypeAdapter를 써요. 도구의 FuncMetadata.output_model을 검사하면 스키마 제목이 있는 딕셔너리 타입 애너테이션이 담겨 있어요.
검증
output_schema는 문서가 아니에요. 함수가 반환하는 무엇이든 서버를 떠나기 전에 그 스키마에 대해 검증돼요.
값을 손으로 만들 땐 눈치채지 못해요: Pydantic이 이미 WeatherData가 WeatherData인지 확인했으니까요. 통제하지 않는 곳에서 데이터가 오는 날 눈치채요:
# docs_src/structured_output/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 보내기를 멈췄어요.
!!! check
get_weather를 호출하면 반절짜리 객체를 조용히 클라이언트에게 넘기지 않아요. 호출이 실패해요: 클라이언트는 is_error=True와 Error executing tool get_weather를 받으니, 모델은 없는 날씨를 자신 있게 읽는 대신 호출이 실패했음을 알아요. 필드 이름은 서버 로그의 ERROR에 여러분을 위해 있어요:
```text
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`가 만든 것이니까요. 검증은 파이썬 타입이 아니라 값에 대한 거예요.
옵트아웃
때로 반환 애너테이션은 프로토콜이 아니라 타입 체커를 위한 거예요. structured_output=False를 넘기면 도구가 텍스트 전용이 돼요:
# docs_src/structured_output/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는 자동 감지를 요구사항으로 바꿔요: 반환 타입이 스키마를 만들 수 없는 도구는 텍스트로 폴백 대신 import 시점에 raise 해요.
콘텐츠 블록과 미디어
콘텐츠 블록과 미디어(TextContent, EmbeddedResource, Image, Audio 등 — 단독으로, list·tuple·Sequence의 항목으로, union의 팔로)는 여러분을 위해 옵트아웃돼요: 모델이 읽는 것이므로 자동 감지는 그들로부터 스키마를 유도하지 않아요. structured_output=True는 콘텐츠 블록 클래스에도 하나를 강제해요.
타입 힌트 없는 클래스
요청하지 않았는데 구조화되지 않은 채로 끝나는 길이 하나 있어요: 본문에 애너테이션이 없는 클래스를 반환하는 경우예요.
# docs_src/structured_output/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가 클래스 애너테이션을 읽고, 찾을 게 없으니 포기해요.
!!! warning
조용히 포기해요. output_schema는 None, structured_content는 None, 모델이 읽는 텍스트는 객체의 repr이에요:
```text
"<server.Station object at 0x7f539d75b230>"
```
에러도 경고도 없이 쓸모없는 도구가 생겨요. 애너테이션을 클래스 본문으로 옮기거나, `structured_output=True`를 넘기세요. 그러면 모듈이 import 될 때 하드 에러가 돼요: `Function get_station: return type <class 'server.Station'> is not serializable for structured output`.
!!! tip
완전한 제어가 필요하다면(CallToolResult를 직접 만들거나, 애플리케이션은 볼 수 있지만 모델은 볼 수 없는 _meta를 붙이거나) **저수준 서버(The low-level Server)**가 그 답이에요.
요약
- 반환 타입 애너테이션이 출력 스키마예요.
tools/list에서output_schema로 공개돼요. - 스칼라·리스트·튜플·union은
{"result": ...}로 감싸져요. 모델·TypedDict·dataclass·애너테이트된 클래스·dict[str, ...]는 이미 객체라 그대로 남아요. - 모든 결과는
content(모델용 텍스트) 그리고structured_content(애플리케이션용 데이터)를 담아요. - 반환하는 것은 스키마에 대해 검증돼요. 불일치는 손상된 결과가 아니라 도구 에러예요.
structured_output=False는 도구를 옵트아웃해요. 콘텐츠 블록,Image,Audio는 기본으로 옵트아웃되고, 타입 힌트 없는 클래스는 조용히 옵트아웃되니 주의하세요.
이제 도구가 말할 수 있는 모든 것을 다뤘어요. 다음은 두 번째 프리미티브인 **리소스(Resources)**예요.