A tool that returns only text makes every caller re-parse prose. The model reads "Denver is 21.5°C and clear" and has to extract the number again; a program has to write a regex. MCP’s structured output fixes this: a tool returns typed JSON in a structuredContent field, and advertises the shape up front as an output schema so callers know what to expect before they ever call it.

This tutorial builds a small weather server with FastMCP whose tools return a Pydantic model, a nested collection, a bare primitive, and a hand-built result. You will see how FastMCP derives the output schema from a return-type annotation, fills in structuredContent automatically, validates every result against the schema, and how a client reads the typed value back. The stack is Mac-native: uv, make, and pytest.

How structured output works

A tool result has always carried a list of content blocks (usually one text block). Structured output adds two things on top:

  • outputSchema on the tool definition (visible in tools/list): a JSON Schema describing the tool’s return value.
  • structuredContent on the tool result (in tools/call): the actual return value as JSON, matching that schema.

FastMCP fills both in from your Python types. Annotate a tool’s return type with a Pydantic model, dataclass, TypedDict, or even a plain int, and FastMCP generates the schema, serializes the return value into structuredContent, and still emits a text block containing the same JSON so older clients that ignore structured content keep working.

What you will build

  • models.py: Pydantic models (Weather, DailyForecast, Forecast) and a small static dataset, so every run is deterministic.
  • server.py: four tools, each demonstrating one output pattern: an inferred object schema, a nested collection, a wrapped primitive, and a manual ToolResult with an explicitly declared schema.
  • client.py: an in-memory client that prints the advertised schema and reads each result three ways (data, structured_content, text block).
  • A pytest suite that pins the schemas, the structured payloads, the primitive-wrapping quirk, and FastMCP’s output-validation guarantee.
  • A make wrapper with a help screen.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Familiarity with FastMCP tools and the in-memory Client (see Build an MCP Server with FastMCP).

Step 1: Add project hygiene

Create the file

mkdir -p structured-output-fastmcp-server-macos
cd structured-output-fastmcp-server-macos
touch .gitignore

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store

Detailed breakdown

  • Standard Python ignores plus .DS_Store. Creating .gitignore first means the .venv/ and __pycache__/ directories that later steps generate never land in a commit by accident.

Step 2: Initialize the project with uv

Create the files

uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio

Add the code: pyproject.toml

[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server that returns structured, schema-typed output"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.4",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "pytest-asyncio>=1.4.0",
]

Detailed breakdown

  • fastmcp is the only runtime dependency. It pulls in pydantic, which does the schema generation, and the mcp package, whose TextContent type appears in the manual result later.
  • pytest-asyncio lets the async test functions run; the config in Step 6 sets it to auto mode so no per-test decorator is needed.

Step 3: Define the domain models

The tools return these objects. Because the return types are annotated with these models, FastMCP turns each one into an output schema. Keeping the data static (no clock, no randomness, no network) makes every result reproducible, which the tests in Step 6 depend on.

Create the file

touch models.py

Add the code: models.py

"""Domain models and a static weather dataset.

The tools return these Pydantic models directly. FastMCP reads the return type
annotation, generates a JSON Schema from the model, advertises it as the tool's
`outputSchema`, and serializes each returned model into the `structuredContent`
field of the tool result. Nothing here talks to MCP; these are plain models.
"""

from pydantic import BaseModel


class Weather(BaseModel):
    """A current-conditions reading for one station."""

    city: str
    temp_c: float
    humidity: int
    conditions: str


class DailyForecast(BaseModel):
    """One day of a multi-day forecast. `day` is an offset: 1 = tomorrow."""

    day: int
    high_c: float
    low_c: float
    conditions: str


class Forecast(BaseModel):
    """A city plus an ordered list of daily forecasts."""

    city: str
    days: list[DailyForecast]


# Static sample data. Values are fixed so every run and every test is
# deterministic: no clock, no randomness, no network.
STATIONS: dict[str, dict] = {
    "denver": {
        "name": "Denver",
        "temp_c": 21.5,
        "humidity": 34,
        "conditions": "clear",
        "forecast": [
            (1, 24.0, 11.0, "sunny"),
            (2, 26.0, 13.0, "sunny"),
            (3, 19.0, 9.0, "thunderstorms"),
            (4, 22.0, 10.0, "partly cloudy"),
            (5, 24.0, 12.0, "clear"),
        ],
    },
    "seattle": {
        "name": "Seattle",
        "temp_c": 16.0,
        "humidity": 72,
        "conditions": "rain",
        "forecast": [
            (1, 17.0, 10.0, "rain"),
            (2, 18.0, 11.0, "showers"),
            (3, 20.0, 12.0, "cloudy"),
            (4, 21.0, 12.0, "partly cloudy"),
            (5, 19.0, 11.0, "rain"),
        ],
    },
}

Detailed breakdown

  • Weather is the flat, common case: four scalar fields become an object schema with string, number, and integer properties. The model’s docstring is not decoration — FastMCP copies it into the schema’s description, which clients show as documentation for the return type.
  • DailyForecast and Forecast show nesting: Forecast.days is a list[DailyForecast], which produces a schema with an array of nested objects. You do not write that schema; the type annotation is the schema.
  • STATIONS holds every value the tools return. The highs in Denver’s forecast sum to 115.0, so their mean is exactly 23.0 — a clean value that the primitive-return tool and its test can assert without floating-point slop.

Step 4: Write the server and its four tools

Each tool demonstrates a different way a return value becomes structured output:

  1. get_weather returns a Weather model. FastMCP infers the object schema.
  2. get_forecast returns a Forecast, whose schema nests an array of days.
  3. average_high returns a bare float. A JSON Schema top level has to be an object, so FastMCP wraps the value under a synthetic result key.
  4. daily_briefing builds a ToolResult by hand, pairing a human-readable sentence with a separately declared structured payload, and advertises an explicit output_schema.

Create the file

touch server.py

Add the code: server.py

"""A weather FastMCP server that returns structured output.

Each tool advertises an output schema and returns structured JSON:

- `get_weather`    returns a Pydantic model (object schema, inferred).
- `get_forecast`   returns a model that nests a list of models.
- `average_high`   returns a bare float (FastMCP wraps it under a `result` key).
- `daily_briefing` builds its result by hand with `ToolResult`, pairing a
  human-readable text block with a separately declared structured payload.

Run it over stdio with `python server.py`, or import `mcp` and drive it with an
in-memory client (see client.py).
"""

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.tools.tool import ToolResult
from mcp.types import TextContent

from models import STATIONS, DailyForecast, Forecast, Weather

mcp = FastMCP("weather")


def _station(city: str) -> dict:
    """Look up a station case-insensitively or raise a tool error."""
    record = STATIONS.get(city.strip().lower())
    if record is None:
        known = ", ".join(sorted(s["name"] for s in STATIONS.values()))
        raise ToolError(f"Unknown city {city!r}. Known stations: {known}.")
    return record


@mcp.tool
def get_weather(city: str) -> Weather:
    """Current conditions for a city, as a typed Weather object."""
    r = _station(city)
    return Weather(
        city=r["name"],
        temp_c=r["temp_c"],
        humidity=r["humidity"],
        conditions=r["conditions"],
    )


@mcp.tool
def get_forecast(city: str, days: int = 3) -> Forecast:
    """A multi-day forecast. `days` is clamped to the range 1..5."""
    r = _station(city)
    days = max(1, min(days, len(r["forecast"])))
    entries = [
        DailyForecast(day=d, high_c=hi, low_c=lo, conditions=c)
        for (d, hi, lo, c) in r["forecast"][:days]
    ]
    return Forecast(city=r["name"], days=entries)


@mcp.tool
def average_high(city: str) -> float:
    """Mean of the forecast high temperatures, rounded to one decimal."""
    r = _station(city)
    highs = [hi for (_, hi, _, _) in r["forecast"]]
    return round(sum(highs) / len(highs), 1)


BRIEFING_SCHEMA = {
    "type": "object",
    "properties": {
        "city": {"type": "string"},
        "temp_c": {"type": "number"},
        "recommendation": {"type": "string"},
    },
    "required": ["city", "temp_c", "recommendation"],
}


@mcp.tool(output_schema=BRIEFING_SCHEMA)
def daily_briefing(city: str) -> ToolResult:
    """A human sentence plus a hand-built structured payload."""
    r = _station(city)
    temp = r["temp_c"]
    if temp < 5:
        rec = "bundle up"
    elif temp < 20:
        rec = "bring a light jacket"
    else:
        rec = "shorts weather"
    text = f"{r['name']}: {temp}°C and {r['conditions']}. {rec.capitalize()}."
    return ToolResult(
        content=[TextContent(type="text", text=text)],
        structured_content={"city": r["name"], "temp_c": temp, "recommendation": rec},
    )


if __name__ == "__main__":
    mcp.run()

Detailed breakdown

  • _station centralizes the lookup and raises ToolError for an unknown city. A ToolError is reported to the client as a failed tool call with your message, rather than a stack trace, so callers get Unknown city 'Atlantis'. Known stations: Denver, Seattle. instead of a 500.
  • get_weather returns a Weather. The -> Weather annotation is the whole mechanism: FastMCP reads it, builds the output schema, and serializes the returned model into structuredContent. You write no schema and no JSON.
  • get_forecast returns a Forecast containing a list of DailyForecast. The nested structure carries straight through to the schema and the structured payload. days is clamped so out-of-range input can never index past the data.
  • average_high returns a plain float. Because JSON Schema’s top level must be an object, FastMCP wraps scalar and list returns under a result key and marks the schema with x-fastmcp-wrap-result. On the wire you get {"result": 23.0}; the client unwraps it back to 23.0 (shown in Step 5).
  • daily_briefing is the escape hatch. Returning a ToolResult lets you set the text block and the structured content independently, so the human summary and the machine payload can differ. FastMCP cannot infer a schema from a ToolResult, so the output_schema=BRIEFING_SCHEMA on the decorator declares one explicitly.
  • Output validation. Whenever a tool has an output schema — inferred or declared — FastMCP validates the structured result against it before sending. A tool that returns the wrong shape raises Output validation error rather than shipping a payload that violates its own advertised contract. Step 6 tests this.

Step 5: Read the structured output from a client

Create the file

touch client.py

Add the code: client.py

"""Drive the weather server in-memory and read its structured output.

`Client(mcp)` connects to the server object directly (no subprocess, no
network), which makes this file a runnable demo and the basis for the tests.
The client exposes three views of every result:

- `result.data`               — the return value, reconstructed as a typed object.
- `result.structured_content` — the raw `structuredContent` dict from the wire.
- `result.content`            — the text block, kept for backward compatibility.
"""

import asyncio
import json

from fastmcp import Client

from server import mcp


async def main() -> None:
    async with Client(mcp) as client:
        # 1. The advertised output schema (from tools/list).
        tools = {t.name: t for t in await client.list_tools()}
        print("get_weather output schema:")
        print(json.dumps(tools["get_weather"].outputSchema, indent=2))

        # 2. A model return: typed object + structured dict + text.
        res = await client.call_tool("get_weather", {"city": "Denver"})
        print("\ndata:              ", res.data)
        print("structured_content:", res.structured_content)
        print("text block:        ", res.content[0].text)

        # 3. A nested collection.
        res = await client.call_tool("get_forecast", {"city": "Denver", "days": 3})
        print("\nforecast days:", len(res.data.days), "->", res.structured_content)

        # 4. A primitive, wrapped by FastMCP under a `result` key.
        res = await client.call_tool("average_high", {"city": "Denver"})
        print("\naverage_high structured_content:", res.structured_content)
        print("average_high data (unwrapped):  ", res.data)

        # 5. A hand-built ToolResult: text and structured content diverge.
        res = await client.call_tool("daily_briefing", {"city": "Denver"})
        print("\nbriefing text:      ", res.content[0].text)
        print("briefing structured:", res.structured_content)


if __name__ == "__main__":
    asyncio.run(main())

Detailed breakdown

  • list_tools() returns the tool definitions, each carrying outputSchema. Printing get_weather’s schema shows what a caller learns before it ever calls the tool, including the description FastMCP lifted from the model docstring.
  • result.data is the most useful field: FastMCP reconstructs the structured payload into a typed object, so res.data.temp_c is a float you can use, not a substring to parse. For a wrapped primitive it hands back the bare value.
  • result.structured_content is the raw dict exactly as it crossed the wire — the field to assert against in tests.
  • result.content[0].text is the backward-compatible text block. For the model tools it is the JSON serialization; for daily_briefing it is the human sentence, which is deliberately different from the structured payload.

Run it:

uv run python client.py

Expected output:

get_weather output schema:
{
  "description": "A current-conditions reading for one station.",
  "properties": {
    "city": {
      "type": "string"
    },
    "temp_c": {
      "type": "number"
    },
    "humidity": {
      "type": "integer"
    },
    "conditions": {
      "type": "string"
    }
  },
  "required": [
    "city",
    "temp_c",
    "humidity",
    "conditions"
  ],
  "type": "object"
}

data:               Root(city='Denver', temp_c=21.5, humidity=34, conditions='clear')
structured_content: {'city': 'Denver', 'temp_c': 21.5, 'humidity': 34, 'conditions': 'clear'}
text block:         {"city":"Denver","temp_c":21.5,"humidity":34,"conditions":"clear"}

forecast days: 3 -> {'city': 'Denver', 'days': [{'day': 1, 'high_c': 24.0, 'low_c': 11.0, 'conditions': 'sunny'}, {'day': 2, 'high_c': 26.0, 'low_c': 13.0, 'conditions': 'sunny'}, {'day': 3, 'high_c': 19.0, 'low_c': 9.0, 'conditions': 'thunderstorms'}]}

average_high structured_content: {'result': 23.0}
average_high data (unwrapped):   23.0

briefing text:       Denver: 21.5°C and clear. Shorts weather.
briefing structured: {'city': 'Denver', 'temp_c': 21.5, 'recommendation': 'shorts weather'}

The Root(...) in data is the synthetic class FastMCP builds from the output schema to hold the reconstructed object; its field values are what matter.

Step 6: Test the schemas and the payloads

The tests drive the server through the same in-memory Client, so they exercise the real MCP round trip rather than calling the functions directly. That is what lets them assert on structuredContent and the advertised schema.

Create the files

mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_server.py

Add the code: pytest.ini

[pytest]
asyncio_mode = auto
filterwarnings =
    ignore::DeprecationWarning

Detailed breakdown

  • asyncio_mode = auto runs async def test_* functions without a per-test marker.
  • tests/__init__.py makes tests/ a package. pytest then puts the project root on sys.path (it walks up to the first directory without an __init__.py), which is what lets from server import mcp resolve.
  • filterwarnings silences FastMCP’s internal deprecation warnings so a passing run stays quiet.

Add the code: tests/test_server.py

"""Tests for the weather server's structured output.

Every test drives the server through an in-memory `Client`, so it exercises the
real MCP round trip: the tool runs, FastMCP builds the result, and the client
parses `structuredContent` back into `result.data`. The suite checks the
advertised schemas, the structured payloads, the primitive-wrapping quirk, and
FastMCP's output-validation guarantee.
"""

import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError

from server import mcp


async def test_output_schema_is_advertised():
    async with Client(mcp) as client:
        tools = {t.name: t for t in await client.list_tools()}
        schema = tools["get_weather"].outputSchema
        assert schema["type"] == "object"
        assert schema["properties"]["temp_c"]["type"] == "number"
        assert schema["properties"]["humidity"]["type"] == "integer"
        assert set(schema["required"]) == {"city", "temp_c", "humidity", "conditions"}


async def test_model_return_is_structured():
    async with Client(mcp) as client:
        res = await client.call_tool("get_weather", {"city": "Denver"})
        # Raw structured payload from the wire.
        assert res.structured_content == {
            "city": "Denver",
            "temp_c": 21.5,
            "humidity": 34,
            "conditions": "clear",
        }
        # Reconstructed typed object.
        assert res.data.temp_c == 21.5
        assert res.data.conditions == "clear"


async def test_nested_collection():
    async with Client(mcp) as client:
        res = await client.call_tool("get_forecast", {"city": "Denver", "days": 3})
        assert len(res.data.days) == 3
        assert res.data.days[0].day == 1
        assert res.structured_content["days"][2] == {
            "day": 3,
            "high_c": 19.0,
            "low_c": 9.0,
            "conditions": "thunderstorms",
        }


async def test_days_argument_is_clamped():
    async with Client(mcp) as client:
        res = await client.call_tool("get_forecast", {"city": "Denver", "days": 99})
        assert len(res.data.days) == 5


async def test_primitive_is_wrapped_under_result():
    async with Client(mcp) as client:
        res = await client.call_tool("average_high", {"city": "Denver"})
        # On the wire the float lives under a synthetic `result` key ...
        assert res.structured_content == {"result": 23.0}
        # ... but the client unwraps it back to the bare value.
        assert res.data == 23.0


async def test_toolresult_text_and_structured_diverge():
    async with Client(mcp) as client:
        res = await client.call_tool("daily_briefing", {"city": "Denver"})
        assert res.content[0].text == "Denver: 21.5°C and clear. Shorts weather."
        assert res.structured_content == {
            "city": "Denver",
            "temp_c": 21.5,
            "recommendation": "shorts weather",
        }


async def test_declared_schema_is_advertised_for_toolresult():
    async with Client(mcp) as client:
        tools = {t.name: t for t in await client.list_tools()}
        schema = tools["daily_briefing"].outputSchema
        assert schema["required"] == ["city", "temp_c", "recommendation"]


async def test_unknown_city_raises():
    async with Client(mcp) as client:
        with pytest.raises(ToolError):
            await client.call_tool("get_weather", {"city": "Atlantis"})


async def test_output_validation_rejects_wrong_shape():
    """A tool whose result violates its declared schema is rejected by FastMCP,
    turning a silent contract break into a loud error before it reaches the client."""
    guard = FastMCP("guard")

    @guard.tool(output_schema={
        "type": "object",
        "properties": {"ok": {"type": "boolean"}},
        "required": ["ok"],
    })
    def buggy() -> dict:
        return {"ok": "not-a-bool"}  # wrong type on purpose

    async with Client(guard) as client:
        with pytest.raises(ToolError, match="Output validation error"):
            await client.call_tool("buggy", {})

Detailed breakdown

  • test_output_schema_is_advertised proves the schema reaches the client with the right types, including humidity as integer.
  • test_model_return_is_structured asserts both views: the raw structured_content dict and the reconstructed data object.
  • test_nested_collection and test_days_argument_is_clamped cover the nested Forecast shape and the input clamp.
  • test_primitive_is_wrapped_under_result documents the one surprising behavior: a bare return is {"result": ...} on the wire but unwrapped in data.
  • test_output_validation_rejects_wrong_shape builds a throwaway server with a deliberately broken tool to prove FastMCP’s guarantee — an output that violates its schema is turned into a ToolError, not sent.

Step 7: Wrap it in a Makefile

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help install demo serve test clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

install:  ## Sync runtime and dev dependencies
	uv sync

demo:  ## Run the in-memory client and print each structured result
	uv run python client.py

serve:  ## Run the server over stdio (for a real MCP client)
	uv run python server.py

test:  ## Run the test suite
	uv run pytest -q

clean:  ## Remove caches
	rm -rf .pytest_cache __pycache__ tests/__pycache__

Detailed breakdown

  • .DEFAULT_GOAL := help makes a bare make print the help screen instead of running the first target. The grep/awk line reads the ## comments off each target so the help stays in sync with the targets automatically.
  • serve runs the server over stdio, the transport a desktop client launches. The recipe bodies must be tab-indented, not spaces, or make errors.

Step 8: Run everything

make          # prints the help screen
make demo     # runs client.py (output shown in Step 5)
make test     # runs the suite

The bare make prints the target list:

  help       Show this help screen
  install    Sync runtime and dev dependencies
  demo       Run the in-memory client and print each structured result
  serve      Run the server over stdio (for a real MCP client)
  test       Run the test suite
  clean      Remove caches

And the suite passes:

.........                                                                [100%]
9 passed in 0.30s

Troubleshooting

  • structured_content is None. The tool has no output schema. Add a return type annotation (a model, TypedDict, dataclass, or scalar) or pass output_schema= on the decorator. A tool with no annotated return produces a text block only.
  • ModuleNotFoundError: No module named 'server' under pytest. tests/ needs an __init__.py. Without it, pytest inserts tests/ on sys.path instead of the project root, and from server import mcp fails.
  • Output validation error at runtime. Your tool returned something its schema forbids — often None from a path that should return the model, or a field with the wrong type. The message names the offending value; fix the return, not the schema.
  • Primitive results look wrong. A tool returning int, float, str, or a list is wrapped under result in structured_content. Read res.data for the unwrapped value, or return a model if you want named fields.
  • ToolResult tool has a null schema. FastMCP cannot infer a schema from a ToolResult. Declare one with output_schema= on the decorator if you want it advertised.

Recap

Structured output replaces “the model re-reads the text” with a typed contract. You annotated tool return types with Pydantic models and FastMCP did the rest: generated the outputSchema, filled in structuredContent, kept a text block for old clients, and validated every result against its schema. You also saw the two edge cases worth remembering — bare returns get wrapped under result, and a ToolResult needs an explicit schema.

Next improvements:

  • Add field constraints (Field(ge=0, le=100) on humidity) and watch them flow into the schema and the validation.
  • Give a client the schemas and let it render results as tables instead of text.
  • Pair this with tool annotations (readOnlyHint, idempotentHint) so callers know both the shape and the semantics of each tool.