Aller au contenu

Sortie structurée

Traduction automatique

Cette page a été traduite automatiquement à partir de la documentation en anglais, et la page en anglais fait foi. Si quelque chose vous semble incorrect, la page Traductions explique comment le signaler.

Un outil (tool) qui renvoie une simple str produit le résultat deux fois : sous forme de texte dans content, et sous la forme {"result": "..."} dans structured_content.

Cette page porte sur ce second canal : d’où il vient, toutes les formes qu’il peut prendre et la façon dont le SDK en garantit l’exactitude.

En bref : l’annotation du type de retour est le schéma de sortie. Vous l’avez déjà écrite.

Le schéma de sortie

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 ligne qui compte est la signature : -> int.

Grâce à elle, l’outil que le SDK envoie lors de tools/list porte un output_schema à côté du schéma d’entrée qu’il construit à partir de vos paramètres (la page Outils traite de celui-là) :

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

Un int seul n’est pas un objet JSON, le SDK l’enveloppe donc dans {"result": ...}. Appelez l’outil et les deux canaux sont remplis :

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

Tous les scalaires reçoivent la même enveloppe : str, int, float, bool, bytes, None.

Deux canaux

Pourquoi envoyer la même valeur deux fois ?

  • content est destiné au modèle. Un modèle de langage lit du texte ; c’est la seule partie du résultat qu’il voit.
  • structured_content est destiné à l’application dans laquelle le modèle s’exécute : du code qui veut 17, pas une phrase contenant « 17 ».
  • output_schema est le contrat entre les deux, publié avant même le premier appel de l’outil.

Vous renvoyez une seule valeur Python. Le SDK remplit les trois.

Renvoyer un modèle

Déclarez la forme comme un BaseModel Pydantic et renvoyez une instance :

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 est désormais le schéma. Pas d’enveloppe, pas de clé 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 est l’objet, champ pour champ :

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

Et le modèle n’est pas oublié. Le SDK sérialise le même objet en texte JSON pour content :

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

Remarquez que les Field(description=...) de temperature et humidity ont atterri dans le schéma. Le même Field qui décrivait vos entrées décrit vos sorties.

Info

Si vous avez utilisé le response_model de FastAPI, vous connaissez déjà cela : un modèle Pydantic comme réponse déclarée, sérialisé et documenté pour vous. La seule différence est qu’ici l’annotation de retour constitue toute la déclaration.

Un TypedDict

Toutes les formes ne méritent pas une classe. Un TypedDict produit le même schéma :

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 est un simple dict à l’exécution : c’est donc ce que vous construisez et renvoyez. Le schéma, la validation et structured_content sont identiques à la version BaseModel (à l’exception des descriptions, pour lesquelles TypedDict n’a pas de place).

Une dataclass

Les dataclasses fonctionnent aussi, tout comme n’importe quelle classe ordinaire dont les attributs portent des annotations de type. Le SDK construit en coulisses un modèle Pydantic à partir des annotations.

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

Trois écritures, un seul schéma. Utilisez celle que votre base de code emploie déjà.

Listes

Une list[...] n’est pas non plus un objet JSON : elle reçoit donc l’enveloppe {"result": ...}, avec votre type d’élément sous forme de référence $defs à l’intérieur :

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

Demandez une prévision sur deux jours et structured_content vaut {"result": [{...}, {...}]}. content devient deux blocs TextContent, un par élément : une liste est aplatie pour le modèle plutôt que déversée en une seule chaîne.

tuple[...], les unions et Optional[...] sont enveloppés de la même façon.

Dictionnaires

dict[str, ...] est le seul générique qui est déjà un objet JSON ; il n’est donc pas enveloppé :

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}

Les clés doivent être des str. Un dict[int, float] ne peut pas être un objet JSON ; il retombe donc sur l’enveloppe {"result": ...}.

Validation

output_schema n’est pas de la documentation. Tout ce que renvoie votre fonction est validé par rapport à lui avant de quitter le serveur.

Vous ne le remarquez pas tant que vous construisez la valeur à la main : Pydantic s’est déjà assuré que votre WeatherData était bien un WeatherData. Vous le remarquez le jour où les données viennent d’un endroit que vous ne contrôlez pas :

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

L’annotation promet un WeatherData. La réponse en amont a cessé d’envoyer humidity.

Check

Appelez get_weather : il ne remet pas discrètement au client un objet à moitié vide. L’appel échoue, et les premières lignes de l’erreur nomment le champ :

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]

Ce texte revient comme résultat de l’outil avec is_error=True : le modèle sait donc que l’appel a échoué au lieu de lire avec assurance une météo qui n’existe pas.

Au passage, renvoyer un simple dict depuis un outil -> WeatherData ne pose aucun problème. C’est exactement ce que json.loads a produit. La validation porte sur la valeur, pas sur le type Python.

Désactiver la sortie structurée

Parfois, l’annotation de retour est destinée à votre vérificateur de types, pas au protocole. Passez structured_output=False et l’outil devient purement textuel :

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

Aucun output_schema, aucune enveloppe, aucune validation. structured_content vaut None et content est la chaîne que vous avez renvoyée.

L’inverse, structured_output=True, transforme la détection automatique en exigence : un outil dont le type de retour ne peut pas produire de schéma lève une exception à l’import au lieu de se rabattre sur du texte.

Une classe sans annotations de type

Il existe une façon de se retrouver sans sortie structurée sans l’avoir demandé : renvoyer une classe qui n’a aucune annotation dans son corps.

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 définit name et online dans __init__, mais la classe ne déclare rien. Le SDK lit les annotations de la classe, n’en trouve aucune et abandonne.

Warning

Il abandonne silencieusement. output_schema vaut None, structured_content vaut None, et le texte que lit le modèle est le repr de l’objet :

"<server.Station object at 0x7f539d75b230>"

Aucune erreur, aucun avertissement, un outil inutile. Déplacez les annotations dans le corps de la classe, ou passez structured_output=True, qui transforme cela en erreur franche dès l’import du module : Function get_station: return type <class 'server.Station'> is not serializable for structured output.

Tip

Besoin d’un contrôle total (construire vous-même le CallToolResult, ou attacher un _meta que l’application voit mais pas le modèle) ? C’est le sujet de Le Server de bas niveau.

Récapitulatif

  • L’annotation du type de retour est le schéma de sortie. Elle est publiée dans tools/list sous le nom output_schema.
  • Les scalaires, listes, tuples et unions sont enveloppés dans {"result": ...}. Les modèles, les TypedDict, les dataclasses, les classes annotées et dict[str, ...] sont déjà des objets et restent tels quels.
  • Chaque résultat porte content (du texte, pour le modèle) et structured_content (des données, pour l’application).
  • Ce que vous renvoyez est validé par rapport au schéma. Une incohérence est une erreur d’outil, pas un résultat corrompu.
  • structured_output=False désactive la sortie structurée d’un outil. Une classe sans annotations de type la désactive silencieusement ; surveillez ce cas.

Vous maîtrisez désormais tout ce qu’un outil peut répondre. Ensuite, la deuxième primitive : Ressources.