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:
outputSchemaon the tool definition (visible intools/list): a JSON Schema describing the tool’s return value.structuredContenton the tool result (intools/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 manualToolResultwith 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
pytestsuite that pins the schemas, the structured payloads, the primitive-wrapping quirk, and FastMCP’s output-validation guarantee. - A
makewrapper with a help screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - 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.gitignorefirst 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
fastmcpis the only runtime dependency. It pulls inpydantic, which does the schema generation, and themcppackage, whoseTextContenttype appears in the manual result later.pytest-asynciolets 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
Weatheris the flat, common case: four scalar fields become an object schema withstring,number, andintegerproperties. The model’s docstring is not decoration — FastMCP copies it into the schema’sdescription, which clients show as documentation for the return type.DailyForecastandForecastshow nesting:Forecast.daysis alist[DailyForecast], which produces a schema with an array of nested objects. You do not write that schema; the type annotation is the schema.STATIONSholds every value the tools return. The highs in Denver’s forecast sum to 115.0, so their mean is exactly23.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:
get_weatherreturns aWeathermodel. FastMCP infers the object schema.get_forecastreturns aForecast, whose schema nests an array of days.average_highreturns a barefloat. A JSON Schema top level has to be an object, so FastMCP wraps the value under a syntheticresultkey.daily_briefingbuilds aToolResultby hand, pairing a human-readable sentence with a separately declared structured payload, and advertises an explicitoutput_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
_stationcentralizes the lookup and raisesToolErrorfor an unknown city. AToolErroris reported to the client as a failed tool call with your message, rather than a stack trace, so callers getUnknown city 'Atlantis'. Known stations: Denver, Seattle.instead of a 500.get_weatherreturns aWeather. The-> Weatherannotation is the whole mechanism: FastMCP reads it, builds the output schema, and serializes the returned model intostructuredContent. You write no schema and no JSON.get_forecastreturns aForecastcontaining a list ofDailyForecast. The nested structure carries straight through to the schema and the structured payload.daysis clamped so out-of-range input can never index past the data.average_highreturns a plainfloat. Because JSON Schema’s top level must be an object, FastMCP wraps scalar and list returns under aresultkey and marks the schema withx-fastmcp-wrap-result. On the wire you get{"result": 23.0}; the client unwraps it back to23.0(shown in Step 5).daily_briefingis the escape hatch. Returning aToolResultlets 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 aToolResult, so theoutput_schema=BRIEFING_SCHEMAon 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 errorrather 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 carryingoutputSchema. Printingget_weather’s schema shows what a caller learns before it ever calls the tool, including thedescriptionFastMCP lifted from the model docstring.result.datais the most useful field: FastMCP reconstructs the structured payload into a typed object, sores.data.temp_cis afloatyou can use, not a substring to parse. For a wrapped primitive it hands back the bare value.result.structured_contentis the raw dict exactly as it crossed the wire — the field to assert against in tests.result.content[0].textis the backward-compatible text block. For the model tools it is the JSON serialization; fordaily_briefingit 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 = autorunsasync def test_*functions without a per-test marker.tests/__init__.pymakestests/a package. pytest then puts the project root onsys.path(it walks up to the first directory without an__init__.py), which is what letsfrom server import mcpresolve.filterwarningssilences 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_advertisedproves the schema reaches the client with the right types, includinghumidityasinteger.test_model_return_is_structuredasserts both views: the rawstructured_contentdict and the reconstructeddataobject.test_nested_collectionandtest_days_argument_is_clampedcover the nestedForecastshape and the input clamp.test_primitive_is_wrapped_under_resultdocuments the one surprising behavior: a bare return is{"result": ...}on the wire but unwrapped indata.test_output_validation_rejects_wrong_shapebuilds a throwaway server with a deliberately broken tool to prove FastMCP’s guarantee — an output that violates its schema is turned into aToolError, 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 := helpmakes a baremakeprint the help screen instead of running the first target. Thegrep/awkline reads the##comments off each target so the help stays in sync with the targets automatically.serveruns the server over stdio, the transport a desktop client launches. The recipe bodies must be tab-indented, not spaces, ormakeerrors.
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_contentisNone. The tool has no output schema. Add a return type annotation (a model,TypedDict, dataclass, or scalar) or passoutput_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 insertstests/onsys.pathinstead of the project root, andfrom server import mcpfails.Output validation errorat runtime. Your tool returned something its schema forbids — oftenNonefrom 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 underresultinstructured_content. Readres.datafor the unwrapped value, or return a model if you want named fields. ToolResulttool has anullschema. FastMCP cannot infer a schema from aToolResult. Declare one withoutput_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)onhumidity) 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.