AIエージェント・MCP

MCPサーバーの作り方とAIエージェントへの接続手順|Python SDK 2.3でツール定義・認可を実装

MCPサーバーの作り方とAIエージェントへの接続手順|Python SDK 2.3でツール定義・認可を実装

AIエージェントに社内システムを操作させたいとき、MCP(Model Context Protocol)でサーバーを1本書けば、Claude Code・Claude Desktop・VS Codeなど複数のホストから同じツールを呼べます。この記事では、公式Python SDK mcp 2.3.0(2026-10-02公開)で小さなチケット管理サーバーを書き、stdioとHTTPの両方でつないで、認可の応答まで確認した結果をもとに手順を説明します。仕様は2026年10月9日時点の現行版である2026-07-28版を前提にしています。MCPそのものの仕組みはMCP(Model Context Protocol)とは?AIと外部ツールをつなぐ標準規格の仕組みで扱っています。

まとめ:自作MCPサーバーをエージェントへつなぐ要点

  • Python SDKは2系で書く。2.0.0(2026-07-28)でFastMCPはMCPServerへ改名され、from mcp.server.fastmcp import FastMCPはModuleNotFoundErrorになります。1系のコードを動かすならmcp<2に固定します。
  • ツール定義は関数のdocstringと型から自動生成されます。docstringがdescription、引数の型とField(description=...)がinputSchema、戻り値の型がoutputSchemaになります。
  • annotationsは既定が「破壊的・外部接続あり」です。何も付けないとdestructiveHintとopenWorldHintはtrue扱いなので、読み取り専用のツールにはreadOnlyHint=Trueを明示します。
  • 手元のホストからはstdio、複数人やリモートからはStreamable HTTP。認可仕様はHTTP向けで、stdioでは環境変数などで認証情報を渡します。
  • HTTPで公開するならOAuth 2.1のリソースサーバーとして作る。トークンが無い要求には401とresource_metadataを返し、自分宛てでないトークンは拒否します。受け取ったトークンを下流APIへそのまま渡すのは仕様で禁止です。
  • 単一アプリで数本のツールを使うだけならMCPは不要です。複数のホストで同じツールを共有するときに効きます。

MCP接続の構成と2026-07-28仕様で変わった前提

MCPは、エージェントを動かすアプリ(ホスト)の中にあるMCPクライアントが、外部のMCPサーバーへJSON-RPC 2.0で要求を送る構成です。エージェントはまずtools/listでツールの名前・説明・入力スキーマを受け取り、LLMが必要と判断したときにtools/callで実行します。サーバー側が書くのは「どんなツールがあり、呼ばれたら何をするか」だけで、LLMの呼び出しやループはホストが担います。

2026-07-28仕様のステートレス化

2026-07-28版の変更履歴(changelog)は、主な変更の先頭にinitializeハンドシェイクの廃止と、Streamable HTTPからのセッション(Mcp-Session-Idヘッダー)の削除を挙げています。要求ごとにプロトコル版とクライアントの能力を_metaに載せ、サーバーは対応版をserver/discoverで返します。tools/listの結果は接続の状態や同じ接続上の別の要求によって変えてはならず、順序も固定することが推奨されました。一方、要求に付いた認可(トークンのスコープ)に応じて返すツールを絞ることは認められています。利用者ごとにツールを出し分けたいなら、セッションではなくスコープで設計します。トランスポートの変更点はStreamable HTTPとは?MCPのトランスポートとSSEの違い、2026-07-28仕様の変更点で詳しく扱っています。

公式SDKの現行版

言語 パッケージ 最新版(2026-10-09時点)
Python mcp(PyPI) 2.3.0(2026-10-02)
TypeScript(分割版) @modelcontextprotocol/server ほか 2.3.1(2026-10-05)
TypeScript(一体型) @modelcontextprotocol/sdk 1.32.1(2026-10-05)
Go modelcontextprotocol/go-sdk v1.8.0(2026-09-14)

Python 2.3.0のソースには対応版として2024-11-05から2026-07-28までの5つが登録されていて、旧仕様のクライアントとはinitializeで、2026-07-28のクライアントとは要求ごとの版指定で話します。手元の検証では、stdio・HTTPとも2026-07-28で接続しました。TypeScriptはMCP TypeScript SDK v2とは?パッケージ分割とステートレス化の移行要点、GoはMCP Go SDKの使い方|v1.8.0でサーバーを作る手順とmcp-goとの違いが詳しい手順です。

FastMCPのサンプルコードが2系で動かない理由

Python SDK 1系を前提にした解説記事のコードは、from mcp.server.fastmcp import FastMCPで始まります。検索上位の日本語記事にもこの書き方のものが残っています。これは1系の書き方で、いまpip install mcpすると入る2.3.0では、次のエラーで起動しません。

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.

2系の変更点をまとめた公式文書(whats-new.md)によると、改名は非推奨期間を置かずに行われました。旧モジュールは、移行先を案内するエラーを出すためだけに残されています。影響が大きいのは、requirements.txtにmcp>=1.0のように上限を書かずに配布されているOSSのMCPサーバーです。新しく入れ直した環境で突然起動しなくなるため、上限なしの依存はmcp<2に固定するか、MCPServerへ書き換えます。

紛らわしいのは、FastMCPという名前の別プロジェクト(PyPIのfastmcp)が今も開発されている点です。公式SDKに取り込まれていたのはその1.0相当で、独立版は認証やプロキシなどの機能を増やし続けています。公式SDKのMCPServerと独立版fastmcpのどちらで書くかは、FastMCPとは|PythonでMCPサーバーを最速構築するフレームワークの使い方とFastMCP 4とは?sessionless対応と廃止APIから読む3系からの移行判断で比べています。この記事では依存の少ない公式SDKで進めます。

Python SDK 2.3でMCPサーバーを実装する手順

導入:Python 3.10以上と依存の上限

mcp 2.3.0はPython 3.10以上が必要です。メジャー版をまたぐと上のような互換性の切れ目があるため、<3の上限を付けて入れます。

python3 -m venv .venv
. .venv/bin/activate
# 検証時の版を再現する場合は python -m pip install "mcp==2.3.0"
python -m pip install "mcp>=2.3,<3"
python -c "import importlib.metadata as m; print(m.version('mcp'))"
# 2.3.0

ツール定義のコード:読み取りと書き込みの分離

例として、チケットを検索するツールとクローズするツールを1本のサーバーに載せます。次のコードをticket_server.pyとして保存してください。手元で動作を確認したコードです。データはプロセス内の辞書に保持するため、再起動すると初期状態に戻り、別プロセスとは共有されません。

from typing import Annotated

from pydantic import BaseModel, Field

from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

mcp = MCPServer("ticket-desk")

TICKETS = {
    "T-101": {"title": "ログイン画面が500を返す", "status": "open"},
    "T-102": {"title": "CSV出力の文字化け", "status": "open"},
}


class Ticket(BaseModel):
    id: str
    title: str
    status: str


@mcp.tool(
    title="チケット検索",
    annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
)
def search_tickets(
    keyword: Annotated[str, Field(description="件名に含まれる語。空文字なら全件")],
    status: Annotated[str, Field(description="open または closed")] = "open",
) -> list[Ticket]:
    """社内チケットを件名と状態で検索する。更新はしない。"""
    return [
        Ticket(id=k, **v)
        for k, v in TICKETS.items()
        if keyword in v["title"] and v["status"] == status
    ]


@mcp.tool(
    title="チケットをクローズ",
    annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True, openWorldHint=False),
)
def close_ticket(ticket_id: Annotated[str, Field(description="T-で始まるチケットID")]) -> Ticket:
    """指定したチケットをクローズする。取り消しはできない。"""
    if ticket_id not in TICKETS:
        raise ToolError(f"{ticket_id} は存在しません。search_tickets でIDを確認してください")
    t = TICKETS[ticket_id]
    t["status"] = "closed"
    return Ticket(id=ticket_id, **t)


if __name__ == "__main__":
    import sys
    mcp.run(sys.argv[1] if len(sys.argv) > 1 else "stdio")

デコレーター@mcp.tool()を付けた関数がそのままツールになり、関数名がツール名になります。titleは人に見せる表示名で、LLMが読むのはdocstringから作られるdescriptionと引数の説明です。MCPServer.run()は引数なしならstdioで動き、"streamable-http"を渡すと既定で127.0.0.1:8000の/mcpで待ち受けます。

生成されるスキーマ:docstringと型ヒントの行き先

このサーバーにtools/listを送ると、close_ticketは次の定義として返ってきます(outputSchemaは省略)。

{
  "name": "close_ticket",
  "title": "チケットをクローズ",
  "description": "指定したチケットをクローズする。取り消しはできない。",
  "inputSchema": {
    "type": "object",
    "properties": {
      "ticket_id": {"description": "T-で始まるチケットID", "title": "Ticket Id", "type": "string"}
    },
    "required": ["ticket_id"]
  },
  "annotations": {"destructiveHint": true, "idempotentHint": true, "openWorldHint": false}
}

戻り値をPydanticモデルにすると、その構造がoutputSchemaになり、呼び出し結果には人が読むテキスト(content)と機械が読むJSON(structuredContent)の両方が入ります。list[Ticket]のようにオブジェクト以外を返すと、SDKが{"result": [...]}で包んだスキーマを作ります。ホスト側で結果を加工する予定があるなら、最初から辞書型のモデルを返す設計にしておくと扱いやすくなります。

ツールエラーの返し方:ToolErrorと予期しない例外の違い

仕様はエラーを2種類に分けています。要求の形が壊れているときのJSON-RPCエラー(プロトコルエラー)と、LLMが読んで引数を直せるようにisError: trueで返すツール実行エラーです。後者はLLMに渡すことが推奨(SHOULD)されています。SDK 2.3.0での実際の振る舞いは次のとおりでした。

  • 引数の型違い:keywordに数値を渡すと、Pydanticの検証メッセージがisError: trueで返り、LLMは型を直して再試行できます。
  • ToolErrorを送出:書いたメッセージがそのままLLMに届きます。上のコードでは存在しないIDに対し「search_tickets でIDを確認してください」と次の手を示しています。
  • それ以外の例外(KeyErrorなど):LLMには「Error executing tool close_ticket」だけが返り、例外の中身はサーバーのログに残ります。内部情報は漏れませんが、LLMには原因を知る手がかりがありません。

想定できる失敗はToolErrorで理由と次の操作を返す、と決めておくと、エージェントの空回りが減ります。

ツール定義の設計基準:名前・説明文・annotations

ツール選択を改善する際は、まず名前と説明文を見直します。選択結果には、入力スキーマ、利用モデル、会話の文脈、ホストの設定も影響します。仕様が推奨する名前の条件は、1〜128文字、大文字小文字を区別、使える文字は英数字・アンダースコア・ハイフン・ドットのみ、です。日本語やスペースを含む名前は避けます。説明文には「何をするか」に加えて「何をしないか」(上の例なら「更新はしない」「取り消しはできない」)を書くと、似たツールの取り違えが減ります。

annotationsはツールの性質をホストに伝えるヒントで、2026-07-28版スキーマの既定値は次のとおりです。

ヒント 意味 省略時
readOnlyHint 環境を変更しない false
destructiveHint 既存データを壊しうる true
idempotentHint 同じ引数の再実行で追加の影響なし false
openWorldHint 外部の世界とやり取りする true

何も書かないツールは「書き込みをして既存データを壊すかもしれず、外部ともやり取りする」扱いになります。ホストが確認ダイアログを出す判断にこの値を使う場合、読み取り専用のツールにも毎回確認が出てしまいます。逆に、仕様はクライアントに対し、信頼できないサーバーのannotationsを信用してはならない(MUST)と定めています。サーバー側がreadOnlyHint=Trueと書いても、それだけで安全の根拠にはなりません。実際の制御は次の章で扱うトークンの権限とホストの承認設定で行います。

transportの選び方と接続確認

stdioとStreamable HTTPの分岐

観点 stdio Streamable HTTP
起動 ホストが子プロセスとして起動 サーバーを常駐させる
利用者 その端末の1人 複数人・複数ホスト
認証情報 環境変数など OAuth 2.1(仕様が推奨)
向く用途 手元のファイル・CLI操作 社内APIの共有窓口

stdioでは標準出力がそのまま通信路になるため、ツールの中でprint()すると通信が壊れます。ログは標準エラーかロガーへ出します。HTTPで127.0.0.1やlocalhostに立てると、SDKはDNSリバインディング対策を自動で有効にし、Hostヘッダーが一致しない要求を421 Misdirected Requestで拒否しました。リバースプロキシの裏で公開するときは、transport_securityで許可するホスト名を明示します。

Claude Codeへの登録とプロジェクト共有

Claude Code 2.1.295で次のコマンドを実行し、登録と状態を確認しました。--より後ろはサーバーの起動コマンドとしてそのまま渡されます。

# チームで共有する場合は project スコープ(.mcp.json に書き込まれる)
claude mcp add --scope project ticket-desk -- /path/to/.venv/bin/python /path/to/ticket_server.py

# HTTPで公開したサーバーを登録する場合
claude mcp add --transport http ticket-desk https://mcp.example.com/mcp

claude mcp list
# ticket-desk: ... - ⏸ Pending approval (run `claude` to approve)

スコープは3つで、local(既定・自分のこのプロジェクトだけ)、project(.mcp.jsonに書かれリポジトリで共有)、user(自分の全プロジェクト)です。projectスコープのサーバーは、各メンバーがclaudeを対話起動して承認するまで「Pending approval」のまま接続されません。公式ドキュメントによると、v2.1.196以降はワークスペースを信頼するまで、リポジトリにコミットされた設定(enableAllProjectMcpServersなど)による承認も無視されます。ワークスペースを信頼した後の接続可否は、各自の承認設定によって決まります。

MCP Inspectorでの単体確認

ホストに組み込む前に、公式のMCP InspectorのCLIモードでツール一覧と呼び出しを確かめておくと、問題の切り分けが楽になります。

npx -y @modelcontextprotocol/[email protected] --cli .venv/bin/python ticket_server.py --method tools/list
npx -y @modelcontextprotocol/[email protected] --cli .venv/bin/python ticket_server.py \
  --method tools/call --tool-name search_tickets --tool-arg keyword=ログイン

手元ではどちらも成功し、structuredContentにチケットが1件返りました。ここで確認したツール名・説明文・入力スキーマは、ホストがLLMへ渡すツール定義の主要な情報です。どの情報をモデルへ渡すかはホストの実装に依存します。

リモート公開時の認可:OAuth 2.1・RFC 9728・RFC 8707

MCPの認可は任意ですが、HTTPで実装する場合はOAuth 2.1に従うことが推奨されています。MCPサーバーはOAuthのリソースサーバーで、トークンの発行は認可サーバー(社内IdPやAuth0など)の役割です。認可サーバーはMCPサーバーと同じ場所に配置することも、別サービスとして配置することもできます。仕様の主な要件は次の4点です。

  • MCPサーバーはRFC 9728のProtected Resource Metadataを公開し、authorization_serversに認可サーバーを1つ以上載せる(MUST)。
  • クライアントはRFC 8707のresourceパラメーターで、どのMCPサーバー宛てのトークンかを指定する(MUST)。サーバーは自分宛てでないトークンを拒否する(MUST)。
  • クライアント登録は利用可能な事前登録情報を最優先し、それがなければ認可サーバーが対応するClient ID Metadata Documents(CIMD)を使います。Dynamic Client Registrationは後方互換用に格下げ(2026-07-28で非推奨)。CIMDはCIMD(Client ID Metadata Documents)とは?MCPでDCRに代わるOAuthクライアント登録の仕組みと実装で解説しています。
  • stdioの実装はこの認可仕様に従わず、環境から認証情報を取得する(SHOULD)。

Python SDKでは、トークン検証をtoken_verifierに、リソースサーバーとしての設定をAuthSettingsに書きます。

from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer

MCP_URL = "http://127.0.0.1:8001/mcp"


class DemoVerifier:
    """本番では認可サーバーのイントロスペクションかJWT検証に置き換える"""

    async def verify_token(self, token: str) -> AccessToken | None:
        if token != "demo-token":
            return None
        return AccessToken(token=token, client_id="agent-a", scopes=["tickets:read"], resource=MCP_URL)


mcp = MCPServer(
    "ticket-desk",
    token_verifier=DemoVerifier(),
    auth=AuthSettings(
        issuer_url="https://auth.example.com",
        resource_server_url=MCP_URL,
        required_scopes=["tickets:read"],
        validate_token_resource=True,  # 他のサーバー宛てのトークンを拒否する
    ),
)

上の設定例をauth_server.pyとして保存し、末尾に次の起動処理を追加して、python auth_server.pyで実行します。この例は固定トークンによる認可応答の確認用で、チケット操作ツールやOAuthのトークン取得フローは含みません。

if __name__ == "__main__":
    mcp.run("streamable-http", port=8001)

起動後にトークンなしで要求すると、SDKは次のように401とメタデータの場所を返しました。クライアントはこのresource_metadataをたどって認可サーバーを見つけます。正しいトークンを付けた接続は2026-07-28で成功し、別の値では拒否されました。

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8001/.well-known/oauth-protected-resource/mcp"

GET /.well-known/oauth-protected-resource/mcp
{"resource":"http://127.0.0.1:8001/mcp","authorization_servers":["https://auth.example.com"],"scopes_supported":["tickets:read"],"bearer_methods_supported":["header"]}

validate_token_resourceは省略すると警告付きでfalse扱いになり、トークンが自分宛てかを検証しません。SDKの注記では3.0で既定がtrueに変わる予定です。それまでは明示的にtrueにするか、検証器の中でaudienceを確認します。

もう1つの落とし穴がトークンのパススルーです。MCPサーバーが裏でGitHubや社内APIを呼ぶとき、クライアントから受け取ったトークンをそのまま転送してはいけません。仕様は、上流のAPIには上流の認可サーバーが発行した別のトークンを使うよう定めています。転送してしまうと、監査ログ上は誰が操作したのか追えず、権限の範囲もMCPサーバー側で絞れなくなります。

書き込みツールを本番に載せる統制:最小権限・承認・監査

ツールの仕様は、ツール呼び出しを人が拒否できる状態を常に保ち、操作には確認を出すよう推奨しています。承認をどこに置くかの設計はヒューマンインザループ(HITL)とは?意味・HOTLとの違い・承認の実装と導入判断にまとめました。MCPサーバーでは、次の順に権限を絞ると事故の範囲を小さくできます。

  • 読み取りと書き込みでサーバーかスコープを分ける。上の例ならtickets:readとtickets:writeを分け、書き込みスコープのトークンを持つエージェントを限定します。
  • ホスト側で書き込みツールに確認を出す。Claude Codeなら権限ルールでツール単位に指定でき、mcp__ticket-desk__search_ticketsは許可、mcp__ticket-desk__close_ticketは確認(ask)にするといった分け方ができます。設定の全体像はClaude Codeのセキュリティ設定|権限ルール・サンドボックス・管理設定で守る実装手順を参照してください。
  • ツールの戻り値を命令として扱わない。チケット本文やWebページに「ほかのツールを呼べ」と書かれていれば、LLMはそれに従いうるからです(プロンプトインジェクション)。外部の文章を返すツールと書き込みツールを同じエージェントに持たせるときほど、承認を厚くします。
  • 監査ログは「どのクライアントが、誰として」で残す。ツール実行時には、検証済みトークンから取得した発行者(claimsのiss)・client_id・subjectを記録します。subjectは省略可能で発行者内の識別子なので、人への対応付けには認可サーバー側の利用者情報が必要です。引数は秘密情報や個人情報を除外して記録します。

MCPを使わない方がよい場面:採用判断の線引き

MCPが効くのは、同じツール群を複数のホストやエージェントから使うときと、既製のMCPサーバーを流用できるときです。社内APIを1回MCPサーバーにしておけば、Claude Code・Claude Desktop・VS Codeのどれからでも同じ権限設計で呼べます。

反対に、自社アプリの中で1つのLLMが数本の関数を呼ぶだけなら、MCPは挟まない方が軽く済みます。LLMのAPIにあるツール使用(function calling)でスキーマを直接渡せば、プロセス間通信も認可サーバーも要りません。仕組みはAIツール使用(Tool Use)とは?LLMが外部を呼ぶ仕組みと実装の判断基準を解説、ループの組み方はAIエージェントの作り方:ツール定義からループ・承認・トレースまでコードで組むで扱っています。よくある失敗は、利用者が1人・ツールが2本の段階でHTTP化とOAuthまで作り込み、肝心のツールの説明文が粗いまま、というものです。まずstdioで動かして説明文と引数を詰め、共有の必要が出てからHTTPと認可に進む順番を勧めます。

よくある質問

MCPサーバーはPython以外の言語でも作れますか?

作れます。公式SDKはTypeScript・Go・C#・Java・Kotlinなどにもあり、2026-10-09時点でTypeScriptの分割版は2.3.1、Goはv1.8.0です。どの言語で作ったサーバーも、同じMCPクライアントから呼べます。

from mcp.server.fastmcp import FastMCP でエラーになるのはなぜですか?

mcp 2.0.0(2026-07-28)でFastMCPがMCPServerに改名され、旧モジュールが削除されたためです。from mcp.server.mcpserver import MCPServerに書き換えるか、1系のまま使うならmcp<2に固定します。

stdioとStreamable HTTPはどちらで作るべきですか?

自分の端末のホストから使うだけならstdioです。複数人で共有する、別のマシンから呼ぶ、といった要件が出たらStreamable HTTPにし、OAuth 2.1の認可を付けます。SDKではrun()の引数を変えるだけで切り替えられます。

エージェントがツールを呼ばない、または間違ったツールを呼ぶのはなぜですか?

先に接続と承認を疑います。claude mcp listで状態が接続済みか、権限ルールで拒否されていないかを確認し、問題がなければ説明文を見直します。docstringに用途と「しないこと」を書き、引数にはField(description=...)で形式や例を添えます。MCP Inspectorでtools/listを見て、LLMに届く文面を確かめてください。

MCPサーバーの認証にAPIキーを使ってもよいですか?

stdioなら、環境変数でAPIキーを渡すのが仕様どおりのやり方です。HTTPで公開する場合、仕様が推奨するのはOAuth 2.1で、APIキーを全員で共有する方式では、キーだけから利用者を区別できません。利用者別にキーを発行して対応付ける設計もありますが、MCPのOAuth認可フローとの相互運用性は別途考慮が必要です。社内の検証用に限ってヘッダー認証で始め、本番前にOAuthへ移す進め方が現実的です。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

ほか 28 件の記事からもリンクされています。

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.15 テックブログ リゾートトラスト不正アクセス:会員2.6万件・カハラ横浜3.6万件の対象確認と窓口
  2. 2025.09.03 コラム ストループ効果とは?原典1935年の実測値と年齢別の平均・テストのやり方
  3. 2026.10.16 テックブログ バーガーキング不正アクセスで約1,118万件の情報漏洩|対象確認・案内メールの真偽と運営側のログ点検
  4. 2026.01.20 テックブログ PineDramaとは?TikTokのショートドラマアプリの日本配信・料金・安全性を解説
  5. 2025.09.03 コラム Google AIモードの履歴の消し方と残る理由|いつまで残るか・使い方・オフにする方法【2026年10月】

RELATED POSTS 関連記事

目次