MCP Python SDKのデコレータでツールを定義する実装パターン
MCP Python SDK v2のMCPServer(旧FastMCP)を使い、デコレータと型ヒントだけでツール・構造化出力・依存注入・Lifespanを実装するパターンを、公式ドキュメントのコード例で解説します。
MCP Python SDKのMCPServerはFastMCPから何が変わったのか
MCP Python SDKはv2で高水準サーバークラスの名前を変えました。from mcp.server.fastmcp import FastMCPというimport pathは非推奨ではなく削除され、from mcp.server import MCPServerに置き換わっています。pip install "mcp[cli]"(Python 3.10以上が必要)で入るのは現在この2.x系列で、公式のクイックスタートも「Python MCP SDK 2.0.0以上を使うこと」を前提条件に明記しています。v1系はメンテナンスモードに移りますが引き続き重大な不具合修正は入るため、既存プロジェクトを急いで移行する必要はありません。
デコレータそのものの書き方はv1とほぼ変わりません。@mcp.tool()・@mcp.resource()・@mcp.prompt()は同じ引数を受け取り、入力スキーマも変わらず型ヒントから生成されます(@mcp.resource()だけsecurity=キーワードが増えました)。変わったのは周辺です。mcp.server.fastmcp.*配下はmcp.server.mcpserver.*に移り、ctx.fastmcpはctx.mcp_serverに、get_context()は廃止されてctx: Contextという引数を宣言する形に一本化され、例外の基底クラスFastMCPErrorはMCPServerErrorになりました。
サーバーを立ててClaude Codeに接続するところまでの基礎手順はMCPサーバー自作ガイドに譲ります。本記事はその先、MCPServerのデコレータと型ヒントがどこまでの実装パターンをカバーするかに絞って踏み込みます。
デコレータ1つで型ヒントがJSON Schemaになる
@mcp.tool()を関数に付けるだけで、その関数はツールになります。SDKが関数から読み取るのは3つだけです。関数名がツール名、docstringがツール説明、型ヒントが引数のスキーマになります。
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str, limit: int) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r} (showing up to {limit})."型ヒントは単なるドキュメントではなく契約です。クライアントが"limit": "ten"のような不正な値を送ると、関数が実行される前にSDKが拒否します。引数にデフォルト値を与えれば、それだけでスキーマ上も必須項目から外れます。
制約や説明を足したいときはAnnotatedとPydanticのFieldを組み合わせます。
from typing import Annotated, Literal
from pydantic import Field
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(
query: Annotated[str, Field(description="Title or author to search for.")],
limit: Annotated[int, Field(ge=1, le=50)] = 10,
genre: Literal["fiction", "non-fiction", "poetry"] | None = None,
) -> str:
"""Search the catalog by title or author."""
where = f" in {genre}" if genre else ""
return f"Found 3 books matching {query!r}{where} (showing up to {limit})."Field(ge=1, le=50)は"minimum": 1, "maximum": 50としてスキーマに載り、limit=999のような呼び出しは関数が動く前に弾かれます。エラーメッセージはツール結果としてモデルに返るので、モデルはそれを読んで正しい値で呼び直せます。引数が増えてきたらPydanticのBaseModelを1つの引数にまとめる書き方もでき、ネストしたモデル・モデルのリストも組み合わせ自由です。I/Oを伴うツールはasync defで書き、そうでなければ素のdefのままで構いません。v2ではdefのツールは自動的にワーカースレッドで実行されるため、イベントループを塞ぎません。
戻り値の型注釈がoutput_schemaになる
ツールが返す値にも型ヒントを付けると、それがそのまま構造化出力のスキーマになります。戻り値の型注釈が出力スキーマそのものという設計です。
from mcp.server import MCPServer
mcp = MCPServer("Weather")
READINGS = {"London": 17, "Cairo": 34}
@mcp.tool()
def get_temperature(city: str) -> int:
"""Current temperature in a city, in whole degrees Celsius."""
return READINGS[city]intのようなスカラー型はJSONオブジェクトではないので、{"result": 17}という形にラップされます。content(モデルが読むテキスト)とstructured_content(呼び出し元アプリケーションが読む型付きデータ)の両方が、この1つの戻り値から自動生成される仕組みです。
PydanticのBaseModel・TypedDict・dataclassを返り値の型にすると、ラップなしでそのオブジェクトの構造がスキーマになります。3通りの書き方のどれを使っても生成されるスキーマは同じで、既存のコードベースに合わせて選べます。list[T]は{"result": [...]}にラップされますが、dict[str, T]だけは元からJSONオブジェクトなのでラップされません。
戻り値は送信前にこのスキーマへ検証されます。上流のAPIレスポンスが必須フィールドを欠いていれば、その場でツールエラーになりモデルへ伝わるので、欠けたデータをそのままクライアントへ流すことはありません。逆に戻り値の型注釈が型チェッカーのためだけのものなら@mcp.tool(structured_output=False)でこの仕組み自体を無効化できます。1つだけ落とし穴があります。__init__の中だけでフィールドを設定し、クラス本体には型注釈を書いていないクラスを返すと、SDKはエラーを出さずに構造化出力を諦め、モデルにはオブジェクトのrepr文字列が渡ります。原因が分かりにくい不具合になりやすいので、返すクラスには必ずクラス本体に型注釈を書いてください。
ContextとResolveでモデルに見せない引数を注入する
すべての引数がモデルから来るわけではありません。リクエストの情報やサーバー内部の状態が欲しいときはContext型の引数を宣言するだけで、SDKが実行時に注入します。
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str, ctx: Context) -> str:
"""Search the catalog by title or author."""
return f"[request {ctx.request_id}] Found 3 books matching {query!r}."引数名は何でも構いません(ctx・context・cのどれでも)。SDKは型注釈だけで見つけるので、モデルに公開される入力スキーマにはctxが一切現れません。ctx.sessionでクライアントへの通知チャンネルにアクセスでき、ctx.read_resource(uri)でサーバー自身が公開しているリソースを内部から読めます。
v2で新しく増えたのがResolveです。モデルに直接答えさせたくない値(在庫数のような内部データ、ユーザーにしか答えられない確認事項)は、Annotated[Type, Resolve(resolver_fn)]という形で宣言すると、ツール本体が呼ばれる前に自分で書いた関数がその値を用意してくれます。
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve
mcp = MCPServer("Bookshop")
INVENTORY = {"Dune": 7, "Neuromancer": 0}
class Stock(BaseModel):
title: str
copies: int
async def check_stock(title: str) -> Stock:
return Stock(title=title, copies=INVENTORY.get(title, 0))
@mcp.tool()
async def reserve_book(title: str, stock: Annotated[Stock, Resolve(check_stock)]) -> str:
"""Reserve a copy of a book."""
if stock.copies == 0:
return f"{title!r} is out of stock."
return f"Reserved {title!r} ({stock.copies - 1} copies left)."Contextと同じくstockは入力スキーマに現れず、モデルはtitleしか渡せません。Resolveはユーザーへの質問(elicitation)にも使えます。resolverがElicit(...)を返すと、SDKは接続方式に応じて質問を届けます。旧来のクライアントにはその場でのelicitationリクエストとして、2026-07-28リビジョンのクライアントにはmulti-round-trip(サーバーからの能動リクエストを廃した新方式)として、同じツール本体のまま両方を成立させます。このmulti-round-trip自体の仕組みはMCPのMRTRとはで扱っています。旧来のctx.elicit()もレガシー接続では引き続き動きますが、2026-07-28の接続ではNoBackChannelErrorになるため、新しく書くツールはResolveに寄せておくとどちらの接続方式でも動きます。
Lifespanでプロセス全体の状態を持たせる
データベース接続やHTTPクライアントのように、サーバーの寿命いっぱい持ち回したい状態はlifespanに書きます。@asynccontextmanagerで1つのオブジェクトをyieldすると、それがサーバーの生きている間ずっとハンドラから見えます。
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
class Database:
@classmethod
async def connect(cls) -> "Database":
return cls()
async def disconnect(self) -> None: ...
@dataclass
class AppContext:
db: Database
@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
db = await Database.connect()
try:
yield AppContext(db=db)
finally:
await db.disconnect()
mcp = MCPServer("Bookshop", lifespan=app_lifespan)
@mcp.tool()
def count_books(genre: str, ctx: Context[AppContext]) -> str:
"""Count the books in a genre."""
db = ctx.request_context.lifespan_context.db
return f"{db.query()} books in {genre!r}."yieldの前が起動処理、finallyの中が終了処理で、lifespanはサーバーの生存期間全体で1回だけ実行されます。ツール側はctx.request_context.lifespan_contextでyieldされたオブジェクトにアクセスします。Context[AppContext]のように型引数を付けると、この参照が型チェッカーの補完対象になります。ただしこの書き方はツール専用で、@mcp.resource()や@mcp.prompt()ではctx: Contextという素の型注釈しか使えません。
streamable HTTP経由でサーバーを立てる場合、v2ではlifespanが起動時に1回だけ実行され、その状態が全セッション・全リクエストで共有されます。v1ではセッションごと(stateless_http=Trueのときはリクエストごと)に実行されていたため、コネクションプールやキャッシュをlifespanに置くコストが大きく下がりました。逆に、接続ごとに確保していたリソースをlifespanへそのまま持ち上げると挙動が変わるので、v1から移植する場合は確認しておく必要があります。
v2移植でよくあるつまずき
v1からの移植で最初に踏むのはimportの置き換えですが、それを越えたあとにも動作が変わる箇所がいくつかあります。
トランスポート設定をMCPServer()に渡すとエラーになる。 MCPServer("x", port=9000)のように、v1ではFastMCPのコンストラクタに直接書けていたhost・port・stateless_httpなどは、v2ではrun()側の引数に移りました。MCPServer(...)はサーバーが何であるか(名前・instructions・lifespan・認証)だけを表し、どう配信するかはrun()が担当するという役割分担です。古いコンストラクタ引数をそのまま残すとTypeErrorになります。
同期関数がイベントループを塞まなくなった代わりに、スレッド前提のコードが崩れることがある。 defで書いたツール・リソース・resolverは自動的にワーカースレッドで実行されるようになりました。イベントループをブロックしないという恩恵の裏返しとして、スレッドアフィニティのあるコード(特定のスレッドでしか呼べないライブラリなど)を同期関数の中で使っていると、v1では問題なかった処理が別スレッドに移って壊れることがあります。async defのハンドラはこの変更の影響を受けません。
テストクライアントが2026-07-28を既定でネゴシエートする。 Client(mcp)でサーバーをインメモリ接続すると、v2は既定で2026-07-28リビジョンを話します。v1で通っていたctx.elicit()呼び出しをテストで叩くと、この新しい接続ではNoBackChannelErrorになって落ちます。移植したツールがctx.elicit()をまだ使っているなら、Resolveベースの書き方に寄せるか、テストクライアントをClient(mcp, mode="legacy")で明示的にレガシー接続へ固定してください。
まとめ
MCPServerのデコレータAPIは、名前がFastMCPから変わっても「型ヒントとdocstringがそのままスキーマと説明になる」という骨格は保たれています。差が出るのはResolveによる依存注入と、streamable HTTPのlifespanが1回だけ動くようになった点です。
既存のFastMCPサーバーを移植するなら、importの置き換えとget_context()の除去だけでほとんどのツールは動きます。新しく書くなら、モデルに見せたくない値は最初からContextやResolveで受け取る設計にしておくと、レガシー接続と2026-07-28の接続の両方に同じツール本体で対応できます。
Claudeへの接続やInspectorでの動作確認といった基礎手順はMCPサーバー自作ガイド、拡張機能ごとの対応状況の違いはMCP拡張機能のクライアント対応状況を比較する、キャッシュ制御のttlMs/cacheScopeはMCPのttlMsとcacheScopeでキャッシュ制御を仕様から理解するで扱っています。