コンテンツにスキップ

構造化出力

機械翻訳

このページは英語版ドキュメントから自動翻訳されたものであり、正式な版は英語版のページです。不自然な箇所があれば、翻訳についてで報告の方法を説明しています。

単なる str を返すツールは、結果を 2 回生み出します。content にはテキストとして、structured_content には {"result": "..."} として入ります。

このページで扱うのは、その 2 つ目のチャネルです。それがどこから来るのか、どんな形を取りうるのか、そして SDK がその正しさをどう担保しているのかを見ていきます。

ひとことで言えば、戻り値の型アノテーションが出力スキーマです。もう書いてあります。

出力スキーマ

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]

重要なのはシグネチャの行、-> int です。

この行があるおかげで、SDK が tools/list で送るツールには、パラメーターから組み立てる入力スキーマ(こちらは ツール で扱っています)の隣に output_schema が付きます。

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

int 単体は JSON オブジェクトではないため、SDK はそれを {"result": ...}ラップします。ツールを呼び出すと、両方のチャネルが埋まります。

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

スカラーはどれも同じラッパーに包まれます。strintfloatboolbytesNone のすべてが対象です。

2 つのチャネル

なぜ同じ値を 2 回送るのでしょうか。

  • contentモデルのためのものです。言語モデルが読むのはテキストであり、結果のうちモデルの目に入るのはこの部分だけです。
  • structured_content は、モデルがその中で動いているアプリケーションのためのものです。つまり「17」を含んだ文章ではなく、17 そのものが欲しいコードです。
  • output_schema は両者をつなぐ契約で、ツールが一度でも呼ばれる前に公開されます。

返すのは Python の値 1 つです。3 つすべてを埋めるのは SDK です。

モデルを返す

形を Pydantic の BaseModel として宣言し、そのインスタンスを返します。

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 そのものがスキーマです。ラッパーも 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 は、そのオブジェクトをフィールドごとにそのまま写したものです。

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

モデルも置き去りにはしません。SDK は同じオブジェクトを JSON テキストにシリアライズして content に入れます。

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

temperaturehumidity に付けた Field(description=...) がスキーマに入っている点に注目してください。入力を説明したのと同じ Field が、出力も説明します。

Info

FastAPI の response_model を使ったことがあれば、これはすでにおなじみのはずです。宣言したレスポンスとして Pydantic モデルを置けば、シリアライズもドキュメント化も任せられる、というものです。唯一の違いは、ここでは戻り値のアノテーションだけで宣言が完結する点です。

TypedDict

どんな形にもクラスがふさわしいわけではありません。TypedDict でも同じスキーマになります。

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

TypedDict は実行時にはただの dict なので、組み立てて返すのもそれです。スキーマもバリデーションも structured_content も、BaseModel 版と同一です(説明だけは付きません。TypedDict には説明を書く場所がないからです)。

データクラス

データクラスも使えますし、属性に型ヒントの付いた普通のクラスならどれでも使えます。SDK が裏側で、アノテーションから Pydantic モデルを組み立てます。

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

書き方は 3 通り、スキーマは 1 つです。コードベースにすでにあるものを使ってください。

リスト

list[...] も JSON オブジェクトではないので、{"result": ...} ラッパーに包まれます。要素の型は、その中で $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"
}

2 日分の予報を要求すると、structured_content{"result": [{...}, {...}]} になります。content のほうは、要素ごとに 1 つずつ、2 つTextContent ブロックになります。リストは 1 本の文字列として丸ごと出力されるのではなく、モデル向けに平坦化されます。

tuple[...]、ユニオン、Optional[...] も同じようにラップされます。

辞書

dict[str, ...] は、それ自体がすでに JSON オブジェクトである唯一のジェネリック型なので、ラップされません。

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}

キーは str でなければなりません。dict[int, float] は JSON オブジェクトになれないため、{"result": ...} ラッパーにフォールバックします。

バリデーション

output_schema は単なるドキュメントではありません。関数が返すものは何であれ、サーバーを出る前にこのスキーマに照らして検証されます

値を手で組み立てているうちは、このことに気づきません。WeatherData が本当に WeatherData であることは、Pydantic がすでに保証しているからです。気づくのは、自分では制御できない場所からデータが来るようになった日です。

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

アノテーションは WeatherData を約束しています。ところが、上流のレスポンスが humidity を送ってこなくなりました。

Check

get_weather を呼び出しても、中身が半分欠けたオブジェクトがこっそりクライアントに渡ることはありません。呼び出しは失敗し、エラーの冒頭の数行にそのフィールド名が示されます。

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]

このテキストは is_error=True の付いたツール結果として返ってくるので、モデルは、ありもしない天気を自信満々に読み上げる代わりに、呼び出しが失敗したと分かります。

ちなみに、-> WeatherData のツールから単なる dict を返してもかまいません。json.loads が返したのはまさにそれです。バリデーションの対象は Python の型ではなく、値です。

オプトアウト

戻り値のアノテーションが、プロトコルのためではなく型チェッカーのためにある場合もあります。structured_output=False を渡せば、ツールはテキストのみになります。

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

output_schema も、ラップも、バリデーションもありません。structured_contentNone になり、content は返した文字列そのものです。

その逆の structured_output=True は、自動検出を必須要件に変えます。戻り値の型からスキーマを作れないツールは、テキストにフォールバックするのではなく、インポート時に例外を送出します。

型ヒントのないクラス

頼んでもいないのに非構造化になってしまう道が 1 つだけあります。本体にアノテーションが 1 つもないクラスを返すことです。

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__init__ の中で nameonline を設定していますが、クラス自体は何も宣言していません。SDK はクラスのアノテーションを読みにいき、何も見つからず、諦めます。

Warning

しかも黙って諦めます。output_schemaNonestructured_contentNone で、モデルが読むテキストはオブジェクトの repr です。

"<server.Station object at 0x7f539d75b230>"

エラーも警告もなく、役に立たないツールができあがります。アノテーションをクラス本体へ移すか、structured_output=True を渡してください。後者なら、モジュールをインポートした瞬間に Function get_station: return type <class 'server.Station'> is not serializable for structured output というハードエラーに変わります。

Tip

完全な制御が必要な場合(CallToolResult を自分で組み立てたい、あるいはアプリケーションからは見えてモデルからは見えない _meta を付けたいなど)は、低レベル Server を参照してください。

まとめ

  • 戻り値の型アノテーションが出力スキーマです。tools/listoutput_schema として公開されます。
  • スカラー、リスト、タプル、ユニオンは {"result": ...} でラップされます。モデル、TypedDict、データクラス、アノテーション付きクラス、dict[str, ...] はもともとオブジェクトなので、そのままです。
  • どの結果も content(モデル向けのテキスト) structured_content(アプリケーション向けのデータ)の両方を持ちます。
  • 返したものはスキーマに照らして検証されます。食い違いは壊れた結果ではなく、ツールエラーになります。
  • structured_output=False を渡すと、そのツールはオプトアウトします。型ヒントのないクラスは黙ってオプトアウトするので、気をつけてください。

これで、ツールが返せるものはすべて押さえました。次は 2 つ目のプリミティブ、リソース です。