はじめに
MCPサーバーを書いて、Claudeに繋いで、動いた。でもテストが1つもありません。
これは私の実際の状態でした。理由は単純で、「AIが呼ぶ道具」のテストの書き方が分からなかったからです。HTTPのAPIと違って、リクエストはClaudeが組み立てます。何が来るか分からないものをどう検証するのか。
結論から言うと、MCPサーバーのテストは「プロトコル層」「ツール層」「統合層」の3段階に分けると書けます。この記事では、実際に書いたテストと、最初に失敗した設計を書きます。
なぜテストが必要か(実害)
私はMCPサーバーで引数の型を間違えて受け取るバグを出しました。Claudeは {"count": "5"} のように文字列で数値を送ってくることがあります。int を期待していたので、ツールが例外を投げ、Claudeは「ツールが失敗しました」とだけ返しました。
人間にはエラーの内容が見えません。 この種のバグはテストがなければ永遠に見つかりません。
3段階のテスト設計
① プロトコル層: JSON-RPCのやり取りが仕様どおりか
② ツール層: 個々のツールの入出力が正しいか(← ここが一番おいしい)
③ 統合層: 実際にAIから呼んで意図どおり動くか
投資対効果が最も高いのは②です。 ①はSDKが担保してくれます。③は自動化が難しい。②を厚くするのが正解です。
① プロトコル層:initializeとtools/list
SDKを使えば最小限で済みます。サーバーが起動して、ツール一覧を返せることだけ確認します。
import pytest
from mcp_server import server # 自作サーバーのモジュール
def test_initialize():
"""初期化ハンドシェイクが通る"""
resp = server.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05"}})
assert resp["result"]["protocolVersion"] is not None
def test_tools_list_not_empty():
"""ツールが1つ以上公開されている"""
resp = server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
tools = resp["result"]["tools"]
assert len(tools) > 0
# 説明文が空のツールはAIが迷うため、必須にする
for t in tools:
assert t["description"].strip(), f"{t['name']} の説明が空です"
最後のアサーションが地味に効きます。 説明文が空のツールは、AIが使ってくれません。テストで強制すると書き忘れが消えます。
② ツール層:入出力を直接検証する(最重要)
ツール関数をプロトコル層から切り離してテストします。これができるように設計することが大事です。
from mcp_server.tools import search_notes, SearchNotesInput
def test_search_notes_returns_matches():
"""キーワードに一致するノートを返す"""
out = search_notes(SearchNotesInput(keyword="MCP", limit=5))
assert isinstance(out, list)
for item in out:
assert "MCP" in item["title"] or "MCP" in item["body"]
def test_search_notes_zero_hits_is_not_error():
"""0件はエラーではなく空リストを返す(AIが再試行できるように)"""
out = search_notes(SearchNotesInput(keyword="存在しないキーワードxyz", limit=5))
assert out == [] # 例外を投げない
型のゆれを吸収する(実際に踏んだバグ)
ここが最初に失敗した設計です。私は型を厳密に検証し、str で来たらエラーにしていました。
# ❌ 失敗した設計:型が違うと即エラー
def old_tool(count: int):
if not isinstance(count, int):
raise ValueError("count must be int") # Claudeは "5" を送ることがある
AIは型を厳密に守りません。 正解は「受け入れて正規化する」です。
from pydantic import BaseModel, field_validator
class SearchInput(BaseModel):
keyword: str
limit: int = 5
@field_validator("limit", mode="before")
@classmethod
def coerce_limit(cls, v):
""""5" のような文字列も受け入れる(AIは型を守らない)"""
try:
return int(v)
except (TypeError, ValueError):
return 5 # 不正値は既定値へ倒す(エラーにしない)
def test_limit_is_coerced_from_string():
assert SearchInput(keyword="x", limit="5").limit == 5
def test_limit_falls_back_on_garbage():
assert SearchInput(keyword="x", limit="abc").limit == 5
「AIからの入力は信用せず、ただし拒否もしない」 がMCPツールの正しい姿勢です。
③ 統合層:境界値とエラー時の応答
def test_tool_error_is_reported_to_ai():
"""ツールが失敗したとき、AIが理解できる形で返る"""
result = call_tool("search_notes", {"keyword": None})
# 例外が外に漏れず、isError フラグで返る
assert "isError" in result or "content" in result
def test_large_result_is_truncated():
"""巨大な結果を返さない(コンテキストを食い潰さない)"""
out = search_notes(SearchInput(keyword="", limit=10000))
assert len(str(out)) < 100_000
「AIに返すエラーは、AIが読める形にする」 のが重要です。スタックトレースをそのまま返しても、AIは復帰できません。
失敗パターン3つ
- プロトコル層のテストだけ書いた — ハンドシェイクは通るのに、ツールの中身が壊れていた。ツール層を厚くするのが正解。
-
型を厳密に検証してエラーにした — AIは
"5"を送ってくる。正規化して受け入れるべきだった。 - LLMを実際に呼ぶ統合テストを書いた — 実行が遅く、結果が不安定で、CIで回せなかった。LLM呼び出しはモックする。
# LLM呼び出しはモックして、テストを決定的にする
from unittest.mock import patch
@patch("mcp_server.agent.call_llm")
def test_tool_selection_with_mock(mock_llm):
mock_llm.return_value = {"tool": "search_notes", "args": {"keyword": "MCP"}}
result = run_agent("MCPについて調べて")
assert "search_notes" in str(result)
テスト層の比較
| 層 | 書くコスト | 得られる安心 | CIで回せる | 優先度 |
|---|---|---|---|---|
| ① プロトコル | 低 | 低(SDKが担保) | ○ | 低 |
| ② ツール | 中 | 高(バグの大半はここ) | ○ | 最優先 |
| ③ 統合(モック) | 中 | 中 | ○ | 中 |
| ③ 統合(実LLM) | 高 | 低(不安定) | × | 最小限 |
②に7割の時間を使ってください。 私の経験では、MCPサーバーのバグの大半はツール層で見つかります。
まとめ
- テストは3層に分ける。②ツール層に投資する
- AIからの入力は型を守らない。正規化して受け入れ、拒否しない
- 0件はエラーではない。空の結果を返してAIに再試行させる
- LLM呼び出しはモックする。実LLMテストはCIで回せない
- 説明文が空のツールはAIが使わない。テストで強制する
AIが相手のテストは「正しさの証明」より「壊れたときにAIが復帰できるか」を見るものです。そこが普通のAPIテストと違う点です。
📗 MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・読み放題対象)