Saltar a contenido

Salida estructurada

Traducción automática

Esta página se tradujo automáticamente a partir de la documentación en inglés, y la página en inglés es la versión de referencia. Si algo no se lee bien, Traducciones explica cómo avisarnos.

Una herramienta que devuelve un simple str produce el resultado dos veces: como texto en content y como {"result": "..."} en structured_content.

Esta página trata de ese segundo canal: de dónde sale, todas las formas que puede tomar y cómo el SDK garantiza que sea fiel.

La versión corta: la anotación del tipo de retorno es el esquema de salida. Ya la escribiste.

El esquema de salida

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]

La línea que importa es la firma: -> int.

Gracias a ella, la herramienta que el SDK envía durante tools/list lleva un output_schema junto al esquema de entrada que construye a partir de tus parámetros (de ese se ocupa Herramientas):

{
  "properties": {
    "result": {"title": "Result", "type": "integer"}
  },
  "required": ["result"],
  "title": "get_temperatureOutput",
  "type": "object"
}

Un int suelto no es un objeto JSON, así que el SDK lo envuelve en {"result": ...}. Llama a la herramienta y se llenan los dos canales:

result.content             # [TextContent(text="17")]
result.structured_content  # {"result": 17}

Todos los escalares reciben el mismo envoltorio: str, int, float, bool, bytes, None.

Dos canales

¿Por qué enviar el mismo valor dos veces?

  • content es para el modelo. Un modelo de lenguaje lee texto; es la única parte del resultado que ve.
  • structured_content es para la aplicación dentro de la que se ejecuta el modelo: código que quiere 17, no una frase que contenga "17".
  • output_schema es el contrato entre ambos, publicado antes de que la herramienta se llame por primera vez.

Devuelves un único valor de Python. El SDK rellena los tres.

Devolver un modelo

Declara la forma como un BaseModel de Pydantic y devuelve una instancia:

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

Ahora WeatherData es el esquema. Sin envoltorio, sin clave 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 es el objeto, campo por campo:

result.structured_content  # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}

Y el modelo no se queda fuera. El SDK serializa el mismo objeto como texto JSON para content:

{
  "temperature": 16.2,
  "humidity": 0.83,
  "conditions": "Overcast"
}

Fíjate en que el Field(description=...) de temperature y humidity acabó en el esquema. El mismo Field que describía tus entradas describe tus salidas.

Info

Si has usado el response_model de FastAPI, esto ya lo conoces: un modelo de Pydantic como respuesta declarada, serializado y documentado por ti. La única diferencia es que aquí la anotación de retorno es toda la declaración.

Un TypedDict

No todas las formas merecen una clase. Un TypedDict produce el mismo esquema:

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

Un TypedDict es un dict normal en tiempo de ejecución, así que eso es lo que construyes y devuelves. El esquema, la validación y structured_content son idénticos a los de la versión con BaseModel (salvo las descripciones, para las que TypedDict no tiene sitio).

Una dataclass

Las dataclasses también funcionan, igual que cualquier clase normal cuyos atributos tengan anotaciones de tipo. El SDK construye internamente un modelo de Pydantic a partir de las anotaciones.

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

Tres formas de escribirlo, un solo esquema. Usa la que ya tenga tu código.

Listas

Un list[...] tampoco es un objeto JSON, así que recibe el envoltorio {"result": ...}, con tu tipo de elemento dentro como referencia en $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"
}

Pide un pronóstico de dos días y structured_content es {"result": [{...}, {...}]}. content se convierte en dos bloques TextContent, uno por elemento: una lista se aplana para el modelo en lugar de volcarse como una sola cadena.

tuple[...], las uniones y Optional[...] se envuelven de la misma manera.

Diccionarios

dict[str, ...] es el único genérico que ya es un objeto JSON, así que no se envuelve:

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}

Las claves deben ser str. Un dict[int, float] no puede ser un objeto JSON, así que recurre al envoltorio {"result": ...}.

Validación

output_schema no es documentación. Lo que devuelva tu función se valida contra él antes de salir del servidor.

No lo notas mientras construyes el valor a mano: Pydantic ya se aseguró de que tu WeatherData fuera un WeatherData. Lo notas el día que los datos vienen de algún sitio que no controlas:

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])

La anotación promete WeatherData. La respuesta del servicio externo dejó de enviar humidity.

Check

Llama a get_weather y no le entrega al cliente en silencio un objeto medio vacío. La llamada falla, y las primeras líneas del error nombran el campo:

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]

Ese texto vuelve como resultado de la herramienta con is_error=True, así que el modelo sabe que la llamada falló en lugar de leer con toda confianza un tiempo que no existe.

Por cierto, devolver un dict normal desde una herramienta -> WeatherData está bien. Es exactamente lo que produjo json.loads. La validación se aplica al valor, no al tipo de Python.

Desactivarlo

A veces la anotación de retorno es para tu verificador de tipos, no para el protocolo. Pasa structured_output=False y la herramienta es solo texto:

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

Sin output_schema, sin envoltorio, sin validación. structured_content es None y content es la cadena que devolviste.

Lo contrario, structured_output=True, convierte la detección automática en un requisito: una herramienta cuyo tipo de retorno no pueda producir un esquema lanza una excepción al importar el módulo en lugar de recurrir al texto.

Una clase sin anotaciones de tipo

Hay una forma de acabar sin salida estructurada sin haberlo pedido: devolver una clase que no tiene anotaciones en su cuerpo.

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 asigna name y online dentro de __init__, pero la clase no declara nada. El SDK lee las anotaciones de la clase, no encuentra ninguna y desiste.

Warning

Desiste en silencio. output_schema es None, structured_content es None y el texto que lee el modelo es el repr del objeto:

"<server.Station object at 0x7f539d75b230>"

Ni error, ni aviso: una herramienta inútil. Mueve las anotaciones al cuerpo de la clase o pasa structured_output=True, que convierte esto en un error inmediato en cuanto se importa el módulo: Function get_station: return type <class 'server.Station'> is not serializable for structured output.

Tip

¿Necesitas control total (construir el CallToolResult tú mismo o adjuntar un _meta que la aplicación pueda ver pero el modelo no)? Eso es El Server de bajo nivel.

Resumen

  • La anotación del tipo de retorno es el esquema de salida. Se publica en tools/list como output_schema.
  • Los escalares, las listas, las tuplas y las uniones se envuelven en {"result": ...}. Los modelos, los TypedDict, las dataclasses, las clases con anotaciones y dict[str, ...] ya son objetos y se quedan como están.
  • Cada resultado lleva content (texto, para el modelo) y structured_content (datos, para la aplicación).
  • Lo que devuelves se valida contra el esquema. Una discrepancia es un error de herramienta, no un resultado corrupto.
  • structured_output=False excluye una herramienta. Una clase sin anotaciones de tipo queda excluida en silencio; vigílalo.

Ahora dominas todo lo que una herramienta puede responder. A continuación, la segunda primitiva: Recursos.