Python MCPサーバーの作り方—SDK v2は同期関数を別スレッドで回す

pip install mcp を今日実行すると 2.1.1 が入ります。2026年7月28日に出た v2 系で、今年7月までの記事に載っている from mcp.server.fastmcp import FastMCP はこの1行で止まります。何がどう変わったのか、v1 系の最新 1.29.1 と並べて実測しました。

目次

v1 のサーバーは import で止まる

v1 向けに書いた server.py を 2.1.1 の環境で起動した結果です。

$ python server.py
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver or pin 'mcp<2' to keep running v1 code.

SDK 側が mcp/server/fastmcp.py をダミーとして残していて、import した瞬間にこのメッセージが飛ぶ仕組み。エラー文の中に移行ガイドの URL と回避策が両方入っています。

固定するか書き換えるか

README の冒頭に “keep a <2 upper bound on your requirement (for example mcp>=1.28,<2) until you’ve migrated” とあります。v1.x ブランチは critical bug fix と security patch を受け続けるため、mcp>=1.28,<2 で固定しても当面は困りません。ただ、後述の 2026-07-28 プロトコルを喋れるのは v2 だけです。

今回の環境

  • Python 3.14.6 / uv 0.11.25
  • mcp 2.1.1(mcp-types 2.1.1、httpx2 2.12.0)と、比較用に mcp 1.29.1(httpx 0.28.1)
  • Claude Code 2.1.263

MCPServer で書く最小サーバー

from pydantic import BaseModel, Field
from mcp.server import MCPServer

mcp = MCPServer("inventory", version="0.1.0")


class Stock(BaseModel):
    sku: str = Field(description="商品コード")
    qty: int = Field(description="在庫数")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b


@mcp.tool()
def get_stock(sku: str) -> Stock:
    """SKUの在庫数を返す"""
    return Stock(sku=sku, qty=42)


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

クラス名以外は v1 の FastMCP と同じ書き方です。mcp.run() の既定トランスポートは stdio で、mcp run server.py --transport streamable-http のように CLI からも起動できます。version= を省くと serverInfo の version が空文字で返ってきました。v1 では SDK 自身の版(1.29.1)が勝手に入ります。

型ヒントが input_schema になる

SDK が引数の型ヒントから JSON Schema を組み立て、docstring を description に載せます。tools/list が返した add の定義を抜き出しました。

{"type": "object",
 "properties": {"a": {"title": "A", "type": "integer"},
                "b": {"title": "B", "type": "integer"}},
 "required": ["a", "b"], "title": "addArguments"}

デフォルト値のない引数は required に入り、Annotated[int, Field(ge=1)] のような制約はそのまま schema のキーワードになります。引数が型に合わなければ SDK が関数を呼ぶ前に弾き、その文言がモデルに返ります。

Error executing tool add: 1 validation error for addArguments
a
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='x', input_type=str]

戻り値は “Two channels” で返る

公式 Structured Output ページの “Two channels” という節名のとおり、結果は2経路で返ってきました。戻り値の型が Pydantic モデルなら SDK が output_schema を生成し、structured_content(dict)と content(JSON 文字列の TextContent)の両方に同じ値が載ります。

CALL get_stock {'sku': 'A-1'} is_error= False
  structured_content: {'sku': 'A-1', 'qty': 42}
  content: [('text', '{\n  "sku": "A-1",\n  "qty": 42\n}')]
CALL add {'a': 40, 'b': 2} is_error= False
  structured_content: {'result': 42}
  content: [('text', '42')]

プリミティブの intstr{"result": ...} に包まれた形。人間向けの文章を返すだけのツールなら @mcp.tool(structured_output=False) で schema 生成を止められます。

import エラーにならない挙動の変化

What’s new in v2 の “Behavior that changes without an import error” に並ぶ項目のうち、手元で実害が出たものを追いました。import は通るのに、ツールを呼んだ時点で結果が変わります。

同期ツールは AnyIO worker thread で動く

実行スレッド名を返すだけのツールを defasync def で用意し、v1 と v2 で呼びました。

import asyncio
import threading


@mcp.tool()
def which_thread() -> str:
    return threading.current_thread().name


@mcp.tool()
async def which_thread_async() -> str:
    return threading.current_thread().name


@mcp.tool()
def loop_in_sync() -> str:
    return repr(asyncio.get_running_loop())
# mcp 1.29.1
which_thread        : MainThread
which_thread_async  : MainThread
loop_in_sync        : <_UnixSelectorEventLoop running=True closed=False debug=False>

# mcp 2.1.1
which_thread        : AnyIO worker thread
which_thread_async  : MainThread
loop_in_sync        : is_error=True  "Error executing tool loop_in_sync"
                      (サーバー側ログ: RuntimeError: no running event loop)

v2 の SDK は def のツールを anyio.to_thread.run_sync でワーカースレッドに投げます。公式 Tools ページの “A plain def tool works too: the SDK runs it in a thread so it never blocks the server” が、この挙動の説明。ブロッキング I/O を書いても他のリクエストが止まらなくなった反面、同期関数の中で asyncio.get_running_loop()asyncio.create_task() を呼ぶコードは v2 で例外になります。

スレッドをまたげないオブジェクトも同じ理由で壊れます。モジュールレベルで開いた sqlite3 接続を同期ツールから使うと、v1 では {'result': 1} が返り、v2 では ProgrammingError: SQLite objects created in a thread can only be used in that same thread がサーバーログに出て、モデルには Error executing tool count_rows だけが届きました。同じ接続を async def のツールから使う分には v2 でも通ります。メインスレッドで動かしたい処理は async def にするか、接続をツールの中で開きます。

print() は stderr に逸れる、ただし起動前は別

stdio トランスポートでは stdout がそのままプロトコルの配線ですが、ツールの中で print() を呼ぶとどうなるか。print("debug: noisy called", flush=True) を呼ぶサーバーを、両方の SDK クライアントから叩きました。

# mcp 1.29.1 のクライアント側ログ
Failed to parse JSONRPC message from server
pydantic_core._pydantic_core.ValidationError: 1 validation error for JSONRPCMessage
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='debug: noisy called', input_type=str]

# mcp 2.1.1
debug: noisy called        (stderr に出る)
CALL noisy is_error= False {'result': 'ok'}

v1 では print の1行が JSON-RPC の行として流れ、クライアントが解析に失敗します。今回使った v1 の SDK クライアントはこの行を読み飛ばして呼び出し自体は成功しましたが、他のホストが同じ寛容さかは確認していません。v2 では migration guide の “stdio_server keeps the protocol streams on private descriptors” のとおり、SDK が配線を別のファイルディスクリプタに退避し、stdout に書かれたものを stderr へ流します。

逃れられないのが起動前の出力です。モジュールの先頭に print("booting", flush=True) を置いたサーバーを v2 クライアントから起動すると、こう出ました。

pydantic_core._pydantic_core.ValidationError: 1 validation error for union[JSONRPCRequest,JSONRPCNotification,JSONRPCResponse,JSONRPCError]
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='booting', input_type=str]

Connect to a real host ページの “output flushed to stdout before then… hands the host a corrupt message” がそのまま起きています。退避が効くのは SDK が stdio の配線を張ってからなので、それより前の print は防げません。ログは logging に寄せて stderr に出します。

例外の文言はモデルに届かない

Handling errors ページの “Which one to raise” が判断基準です。ツール内で素の Exception を投げると、モデルに返るのは Error executing tool <name> の1行だけで、原因はサーバー側の ERROR ログにしか残りません。モデルに読ませて言い直させたいなら mcp.server.mcpserver.exceptions.ToolError、リクエスト自体を拒否したいなら mcp.MCPError(JSON-RPC エラーになる)を投げます。v1 の McpError は v2 で MCPError に改名されました。

v1 と v2 の対応表

移行ガイド “Migration Guide: v1 to v2” のうち、MCPServer を使う側に関係する行だけ抜き出しました。

項目mcp 1.29.1mcp 2.1.1
サーバークラスmcp.server.fastmcp.FastMCPmcp.server.MCPServer
既定のサーバー名FastMCPmcp-server
serverInfo.versionSDK の版(1.29.1)が入る空文字。version= で渡す
ポートなどの設定FastMCP(port=9000)mcp.run(transport="sse", port=9000)
Context の取り方mcp.get_context()引数 ctx: Context で受ける
結果の属性名inputSchema / isErrorinput_schema / is_error(ワイヤーは camelCase のまま)
例外クラスMcpErrorMCPError
同期ハンドラの実行場所イベントループのスレッドAnyIO worker thread
HTTP クライアントhttpx 0.28.1httpx2 2.12.0
型定義mcp.typesmcp-types パッケージ(SDK と同じ版に固定)

httpx2 は Pydantic チームによる httpx のフォークで、OAuth クライアントを自作している場合は httpx2.Auth を継承し直す必要があります。差分はhttpx2とは—Pydanticのhttpxフォークと移行で壊れる箇所にまとめています。

サジェストに並ぶ “mcp python sdk vs fastmcp” の fastmcp は別パッケージで、jlowin/fastmcp の 4.0.0(2026年8月31日)は公式 SDK v2 に依存する形で組み直されました。この記事の MCPServer とは import 元が違います。

Claude Code から呼ぶ

claude mcp add は — の後ろがそのまま起動コマンド

claude mcp add inventory -- /abs/path/.venv/bin/python /abs/path/server.py

# 依存を uv に任せる公式の書き方
claude mcp add inventory -- uv run --with "mcp[cli]" mcp run /abs/path/server.py

-- より前が Claude Code のオプション(-s local|project|user-e KEY=value-t stdio|sse|http)、後ろがサーバーの起動コマンドです。パスを絶対パスにするのは、Connect to a real host ページの “The host launches your server from its working directory, not the one you registered from” が理由で、相対パスは Claude Code が自分の起動ディレクトリ基準で解決します。-s project を付けた場合の保存先はリポジトリ直下の .mcp.json。Git で共有する前提の置き場です。

{
  "mcpServers": {
    "inventory": {
      "command": "/abs/path/.venv/bin/python",
      "args": ["/abs/path/server.py"]
    }
  }
}

uv 経由の初回起動は 1.81秒、2回目以降は 0.71秒でした(uv run --with "mcp[cli]" mcp version の実測)。venv の python を直接指定すれば import mcp.server 込みで 0.27秒です。

–mcp-config で設定を汚さずに試す

上の JSON を /tmp/mcp2/mcp.json に置き、claude -p から読ませました。--strict-mcp-config を付けると、ユーザー設定に入っている他の MCP サーバーを読み込みません。

claude -p "inventory サーバーの get_stock ツールで sku=A-1 の在庫数を調べて、数字だけ答えてください" \
  --mcp-config /tmp/mcp2/mcp.json --strict-mcp-config \
  --allowedTools mcp__inventory__get_stock \
  --permission-mode default --output-format stream-json --verbose
INIT mcp_servers= [{'name': 'inventory', 'status': 'connected'}]
TOOL_USE mcp__inventory__get_stock {'sku': 'A-1'}
TOOL_RESULT "{\"sku\":\"A-1\",\"qty\":42}"
ASSISTANT_TEXT 42
RESULT {'duration_ms': 8553, 'num_turns': 3, 'total_cost_usd': 0.285}

ツール名は mcp__<server>__<tool> の形になります。モデルに渡った結果は改行なしの JSON 1行で、サーバーが content に入れた整形済みテキストとは形が違いました。

1回目の実行は --permission-mode を付けずに走らせて失敗しています。この環境の既定が plan mode で、Cannot call mcp__inventory__get_stock while in plan mode. と拒否され、4ターン分の $0.41 だけ消えました。読み取り専用のツールでも plan mode では MCP を呼べません。

繋ぐ前に Client(mcp) で叩く

v2 では Python 側の MCP クライアントも mcp.Client に一本化されました。サーバーのインスタンスをそのまま渡すとプロセス内で繋がります。

import asyncio
from mcp import Client
from server import mcp


async def main() -> None:
    async with Client(mcp) as c:
        result = await c.call_tool("get_stock", {"sku": "A-1"})
        print(result.structured_content)


asyncio.run(main())

接続まで 27.5ms。stdio でサブプロセスを起動する Client(StdioServerParameters(...)) だと 248ms でした。pytest のフィクスチャにするなら Client(mcp, raise_exceptions=True) で、ツール内の例外がテスト側に素通しになります。mcp dev server.py の Inspector は npx を呼ぶため、Node が無い環境では npx not found. Please ensure Node.js and npm are properly installed で止まります。

streamable-http では initialize が消えた

What’s new in v2 の “No handshake, no session” を確かめるため、mcp run server.py --transport streamable-http で立てたサーバーの前に TCP の中継を挟み、Claude Code 2.1.263 が何を送るかを記録しました。

Claude Code 2.1.263 が送る順番

POST /mcp HTTP/1.1
User-Agent: claude-code/2.1.263 (sdk-cli)
mcp-method: server/discover
mcp-protocol-version: 2026-07-28

{"jsonrpc":"2.0","id":"server-discover-probe-1","method":"server/discover",
 "params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
                    "io.modelcontextprotocol/clientInfo":{"name":"claude-code","version":"2.1.263", ...}}}}

続けて subscriptions/listenprompts/listresources/listtools/list の順に届き、initialize は一度も来ません。mcp-session-id ヘッダーも往復しません。クライアントがプロトコル版と自分の情報を毎リクエストの _meta に同梱するので、サーバー側にセッション状態を残さない設計で、処理に必要な情報はリクエスト本文だけで揃います。サーバーの応答には "resultType":"complete""ttlMs":0 が付いていて、キャッシュ可否もここで伝えます。

素の POST は 2025 年式として扱われる

curl -s -X POST http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":1,"b":2}}}'
# HTTP 400
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request: Missing session ID"}}

サーバーは _metamcp-protocol-version も無い POST を 2025-11-25 系のクライアントと見なし、initialize 済みのセッション ID を要求します。v2 の Client(url, mode="legacy") で繋ぐと従来どおり initialize が飛び、応答ヘッダーに mcp-session-id が付きました。見分けの材料はセッションではなく、リクエストごとの _metamcp-protocol-version ヘッダーです。

認証付きで公開する場合はサーバー側が MCPServer(token_verifier=...) を持ち、Claude Code 側の手順はClaude CodeのMCP認証—claude mcp loginとトークンの保存先に書きました。Go で同じサーバーを組む手順はGoでMCPサーバーを自作する—go-sdk v1.7でClaude Codeに繋ぐまでです。

まとめ

  • pip install mcp は 2.1.1 を入れる。v1 のコードは mcp>=1.28,<2 で固定するか、MCPServer に書き換える
  • 名前の変更より効くのは挙動の変更。同期ツールは AnyIO worker thread で動き、ツール内の print は stderr に逸れる。起動前の print は今も配線を壊す
  • Claude Code へは claude mcp add name -- 絶対パス。試すだけなら claude -p --mcp-config --strict-mcp-config で設定を汚さない
  • Claude Code 2.1.263 は streamable-http に対して initialize を送らず、server/discover から始める。素の POST は旧式扱いで 400 になる
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次