Skip to content

Latest commit

 

History

History
245 lines (174 loc) · 9.15 KB

File metadata and controls

245 lines (174 loc) · 9.15 KB

Structured Output {#structured-output}

A tool that returns a plain str produces the result twice: as text in content, and as {"result": "..."} in structured_content.

This page is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest.

The short version: the return type annotation is the output schema. You already wrote it.

The output schema {#the-output-schema}

--8<-- "docs_src/structured_output/tutorial001.py"

The line that matters is the signature: -> int.

Because of it, the tool the SDK sends during tools/list carries an output_schema next to the input schema it builds from your parameters (Tools covers that one):

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

A bare int isn't a JSON object, so the SDK wraps it in {"result": ...}. Call the tool and both channels are filled:

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

Every scalar gets the same wrapper: str, int, float, bool, bytes, None.

Two channels {#two-channels}

Why send the same value twice?

  • content is for the model. A language model reads text; this is the only part of the result it sees.
  • structured_content is for the application the model runs inside: code that wants 17, not a sentence containing "17".
  • output_schema is the contract between them, published before the tool is ever called.

You return one Python value. The SDK fills in all three.

Return a model {#return-a-model}

Declare the shape as a Pydantic BaseModel and return an instance:

--8<-- "docs_src/structured_output/tutorial002.py"

WeatherData is the schema now. No wrapper, no result key:

{
  "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 is the object, field for field:

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

And the model is not left out. The SDK serializes the same object to JSON text for content:

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

Notice the Field(description=...) on temperature and humidity landed in the schema. The same Field that described your inputs describes your outputs.

!!! info If you've used FastAPI's response_model, you already know this: a Pydantic model as the declared response, serialized and documented for you. The only difference is that here the return annotation is the whole declaration.

A TypedDict {#a-typeddict}

Not every shape deserves a class. A TypedDict produces the same schema:

--8<-- "docs_src/structured_output/tutorial003.py"

A TypedDict is a plain dict at runtime, so that is what you build and return. The schema, the validation, and structured_content are identical to the BaseModel version (minus the descriptions, which TypedDict has no place for).

A dataclass {#a-dataclass}

Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes.

--8<-- "docs_src/structured_output/tutorial004.py"

Three spellings, one schema. Use whichever your codebase already has.

Lists {#lists}

A list[...] isn't a JSON object either, so it gets the {"result": ...} wrapper, with your item type as a $defs reference inside it:

--8<-- "docs_src/structured_output/tutorial005.py"
{
  "$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"
}

Ask for a two-day forecast and structured_content is {"result": [{...}, {...}]}. content becomes two TextContent blocks, one per item: a list is flattened for the model rather than dumped as one string.

tuple[...], unions, and Optional[...] are wrapped the same way.

Dictionaries {#dictionaries}

dict[str, ...] is the one generic that already is a JSON object, so it isn't wrapped:

--8<-- "docs_src/structured_output/tutorial006.py"
{
  "additionalProperties": {"type": "number"},
  "title": "get_temperaturesDictOutput",
  "type": "object"
}
result.structured_content  # {"London": 16.2, "Reykjavik": 4.4}

The keys must be str. A dict[int, float] can't be a JSON object, so it falls back to the {"result": ...} wrapper.

Validation {#validation}

output_schema is not documentation. Whatever your function returns is validated against it before it leaves the server.

You don't notice while you build the value by hand: Pydantic already made sure your WeatherData was a WeatherData. You notice the day the data comes from somewhere you don't control:

--8<-- "docs_src/structured_output/tutorial007.py"

The annotation promises WeatherData. The upstream response stopped sending humidity.

!!! check Call get_weather and it does not quietly hand the client a half-empty object. The call fails, and the first lines of the error name the field:

```text
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]
```

That text comes back as the tool result with `is_error=True`, so the model knows the call failed
instead of confidently reading weather that isn't there.

Returning a plain dict from a -> WeatherData tool is fine, by the way. That's exactly what json.loads produced. Validation is on the value, not on the Python type.

Opting out {#opting-out}

Sometimes the return annotation is for your type checker, not for the protocol. Pass structured_output=False and the tool is text-only:

--8<-- "docs_src/structured_output/tutorial008.py"

No output_schema, no wrapping, no validation. structured_content is None and content is the string you returned.

The opposite, structured_output=True, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text.

A class without type hints {#a-class-without-type-hints}

There is one way to end up unstructured without asking for it: return a class that has no annotations on its body.

--8<-- "docs_src/structured_output/tutorial009.py"

Station sets name and online inside __init__, but the class declares nothing. The SDK reads class annotations, finds none, and gives up.

!!! warning It gives up silently. output_schema is None, structured_content is None, and the text the model reads is the object's repr:

```text
"<server.Station object at 0x7f539d75b230>"
```

No error, no warning, a useless tool. Move the annotations onto the class body, or pass
`structured_output=True`, which turns this into a hard error the moment the module imports:
`Function get_station: return type <class 'server.Station'> is not serializable for structured output`.

!!! tip Need full control (building the CallToolResult yourself, or attaching _meta that the application can see but the model can't)? That's The low-level Server.

Recap {#recap}

  • The return type annotation is the output schema. It's published in tools/list as output_schema.
  • Scalars, lists, tuples and unions are wrapped in {"result": ...}. Models, TypedDicts, dataclasses, annotated classes and dict[str, ...] are objects already and stay as they are.
  • Every result carries content (text, for the model) and structured_content (data, for the application).
  • What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
  • structured_output=False opts a tool out. A class without type hints opts out silently; watch for it.

You now own everything a tool can say back. Next, the second primitive: Resources.