ミドルウェア
ミドルウェアとは、サーバーが受け取るすべてのメッセージを包み込む 1 つの非同期関数です。
async (ctx, call_next) の形で書き、server.middleware に追加します。API はこれだけです。
Warning
ミドルウェアのリストは、ソース上で暫定(provisional)とマークされています。シグネチャやセマンティクスは 2.x のマイナーリリースで変わる可能性があります。メッセージを「観察」する(計時、ログ、トレース)ため、あるいは「拒否」するために使ってください。サーバーの土台にはしないでください。
MCPServer は構築時にこのリストを受け取り(MCPServer(name, middleware=[...]))、mcp.middleware として公開します。低レベルの Server も同じリストを server.middleware として公開します。以下の例では低レベルの Server を使います。Server(name, on_call_tool=...) に馴染みがなければ、先に低レベルの Server を読んでください。
計時ミドルウェア
サーバー 1 つ、ツール 1 つ、そして各メッセージにかかった時間をログに出すミドルウェア 1 つです。
import logging
import time
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
logger = logging.getLogger(__name__)
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="search_books",
description="Search the catalog by title or author.",
input_schema={
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
)
]
)
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
query = (params.arguments or {})["query"]
return CallToolResult(content=[TextContent(type="text", text=f"Found 3 books matching {query!r}.")])
async def log_timing(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
start = time.perf_counter()
try:
return await call_next(ctx)
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
logger.info("%s took %.1f ms", ctx.method, elapsed_ms)
server = Server("Bookshop", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
server.middleware.append(log_timing)
ctxはハンドラーが受け取るのと同じServerRequestContextです。ctx.methodは生のメソッド文字列、ctx.paramsはバリデーション前の生のパラメーターです。call_next(ctx)はチェーンの残り、つまりバリデーション、ハンドラーの検索、ハンドラー本体を実行します。返ってきたものをそのまま返せば、レスポンスには手が加わりません。try/finallyは意図的なものです。ハンドラーが例外を送出しても計時されます。失敗はcall_nextから出てくる例外としてミドルウェアに届くからです。server.middleware.append(...)で登録します。リストは外側から順に実行されるので、middleware[0]が通信路に最も近いミドルウェアです。
試してみる
クライアントを接続し、ツールを一覧し、1 つ呼び出してください。ログには 3 行出ます。
server/discover took 18.3 ms
tools/list took 0.1 ms
tools/call took 0.1 ms
呼び出しは 2 回なのに、行は 3 つです。最初の行は server/discover、つまり何かを要求する前に、クライアントが接続をセットアップするために送ったリクエストです。
ここがポイントです。ミドルウェアは受信するすべてのメッセージを包みます。
- 接続のセットアップ。
server/discover、あるいはレガシーセッションではinitializeとnotifications/initializedです。 - すべてのリクエストとすべての通知。通知の場合は
ctx.request_id is Noneであり、call_next(ctx)はNoneを返し、何を返しても破棄されます。 - サーバーにハンドラーがないメソッドでさえ対象です。
call_nextはMCPError(-32601, "Method not found")を送出し、それがミドルウェアを「通り抜けて」クライアントへ向かいます。
ミドルウェアの中でできること
ためらうべき度合いが小さいものから順に並べます。
- 観察する。 時間を計る、数える、ログに出す。上の例がこれです。
- 拒否する。
call_next(ctx)を呼ぶ「代わりに」MCPErrorを送出すると、そのメッセージ 1 つに JSON-RPC エラーで応答します。接続は維持され、次のメッセージは通ります。サーバーが呼び出し側ごとにsubscriptions/listenを制御するのはこの方法です。サブスクリプションのページの誰が監視できるかを決めるで順を追って説明しています。 - 書き換える。
ctxはデータクラスです。await call_next(dataclasses.replace(ctx, params=...))とすると、クライアントが送ったものとは異なるパラメーターをチェーンの残りに渡せます。initializeに対しては決して行わないでください。クライアントが受け取る結果は書き換えたパラメーターから組み立てられますが、サーバーは元の通信路上のパラメーターから接続状態を確定します。両者が、ネゴシエートした内容について食い違ったままハンドシェイクを終える可能性があります。 - 応答する。
call_next(ctx)を呼ばずに結果を返すと、それがレスポンスとしてクライアントへ送られます。call_nextが渡してくるのは完成した送信形式であり、パイプラインは返したものに一切手を加えないので、エンベロープ全体が自分の責任になります。2026 年世代の接続ではこれにserverInfoの_metaスタンプが含まれます。SDK はハンドラーの結果にはこれを付けますが、ミドルウェアが返すものには付けません。
Check
initialize もミドルウェアが包むものの 1 つであり、ミドルウェアはそのための「唯一の」フックです。add_request_handler で乗っ取ろうとすると、SDK は拒否します。
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
Warning
initialize はインラインで処理されます。ミドルウェアチェーンが返るまで、サーバーはそれ以上の受信メッセージを読みません。そのため、initialize の処理中にサーバーからクライアントへのリクエスト(ctx.session.send_request(...) やエリシテーション(elicitation))を await すると、接続がデッドロックします。待っているレスポンスは決して読まれないからです。送りっぱなしの通知は問題ありません。
デフォルトで有効な唯一のミドルウェア
SDK が同梱するミドルウェアはちょうど 1 つで、すでにサーバーのリストに載っています。すべてのメッセージに対して OpenTelemetry のスパンを発行するミドルウェアです。自分で追加する必要はなく、ほとんどの場合は意識することもありません。エクスポーターをインストールするまでは何もしません。専用のページがあります。OpenTelemetry を参照してください。
Info
ASGI ミドルウェアを書いたことがあれば、この形はもう知っています。Starlette の (scope, receive, send) が (ctx, call_next) になり、トランスポートの「後」で、生の HTTP リクエストではなくデコード済みのメッセージに対して動きます。2 つは組み合わせられます。streamable_http_app() 上の Starlette ミドルウェアは HTTP を見て、こちらは MCP を見ます。
まとめ
- ミドルウェアは
async (ctx, call_next) -> resultです。MCPServer(middleware=[...])として渡すか(またはmcp.middlewareに追加し)、低レベルのServerではserver.middlewareに追加します。 - 受信するすべてのメッセージ(
server/discover、initialize、リクエスト、通知、未知のメソッド)を包み、外側から順に実行されます。 ctx.request_id is Noneで、通知とリクエストを見分けます。call_nextを呼ぶ代わりに例外を送出すると、メッセージを 1 つ拒否できます。接続は維持されます。- SDK 自身の OpenTelemetry トレースもミドルウェアであり、すでにリストに載っています。OpenTelemetry を参照してください。
- この仕組み全体が暫定です。観察には使っても、その上に何かを築かないでください。
リクエストを包むものはこれですべてです。認可は、そもそもそのリクエストを実行させるかどうかを決めるものです。