구조화된 출력 (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"
}

temperaturehumidityField(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_contentBaseModel 버전과 같은 규칙을 따라요: 클래스 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이 이미 WeatherDataWeatherData인지 확인했으니까요. 통제하지 않는 곳에서 데이터가 오는 날 눈치채요:

# 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=TrueError 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_contentNone, 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__ 안에서 nameonline을 설정하지만, 클래스는 아무것도 선언하지 않아요. SDK가 클래스 애너테이션을 읽고, 찾을 게 없으니 포기해요.

!!! warning 조용히 포기해요. output_schemaNone, structured_contentNone, 모델이 읽는 텍스트는 객체의 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)**예요.