目次
なぜMCPテストはWeb APIテストより難しいのか
「MCPサーバーは普通のWeb APIと同じくユニットテストとE2Eを書けばいい」——この理解で本番投入したチームの大半が、リリース後数週間以内にエージェント挙動の劣化に気づいて慌てる。理由は 3つの非対称性 にある。
非対称性1: 呼び出し側がLLM(非決定的)
通常のWeb APIテストでは「同じ入力なら同じ出力」が前提。一方MCPでは、呼び出し側がClaudeやGPTのようなLLMで、同じユーザー意図でも引数の渡し方が毎回変わる。「今月の請求書を作って」という指示で、あるときは {date: "2026-05"}、あるときは {from: "2026-05-01", to: "2026-05-31"} が渡る。両方を受けられるかをテストしていないと、本番で散発的に失敗する。
非対称性2: ツールスキーマがLLMのプロンプト的役割を持つ
MCPのツール description は単なるドキュメントではなく、LLMがどのツールを呼ぶかを決める判断材料。「Send a message to Slack」を「Post to Slack channel」に書き換えただけで、エージェントのツール選択率が大きく変わる事例が業界で報告されている。コードロジックは無傷でも、説明文のリファクタリングが本番劣化を引き起こすのがMCP特有の罠だ。
非対称性3: プロトコル仕様が高頻度で更新される
MCP仕様は2024年〜2026年にかけてStreamable HTTP追加、メタデータ拡張、認証フロー(OAuth 2.1ベース)導入など継続的に更新されてきた。サードパーティライブラリ(SDK・クライアント)は仕様変更に追随するが、あなたのMCPサーバーが古い仕様準拠のままだと、新しいエージェントから接続できなくなる。仕様準拠を継続検証する仕組みがCIに必要だ。
「ユニットテストが100%パスしているから安全」——これはMCPでは通用しない。LLMがツールを正しく選び、適切な引数を渡し、エラー時に意味のある復帰ができるかは、サーバー実装の単体テストでは絶対に検証できない領域だ。
MCPテストピラミッド — 4層構成
2026年に本番運用に耐えるMCPサーバーは、以下の4層をCI/CDに組み込んでいる。下層ほど高速・高頻度、上層ほど高コスト・低頻度で実行する。
| レイヤー | 主な検証対象 | 推奨ツール | 実行タイミング |
|---|---|---|---|
| 1. ユニット | 個々のツール実装(ロジック) | FastMCP Client (in-memory) | 全PR / 全push |
| 2. 契約 | MCP仕様準拠 / プロトコル整合 | mcp-testing-framework | 全PR |
| 3. スキーマドリフト | ツール定義のスナップショット | 独自スナップショット + diff | 全PR |
| 4. E2E (実LLM) | ツール選択 + 引数生成 + 復帰 | Anthropic SDK + 評価データセット | ナイトリー / リリースタグ |
レイヤー1: ユニットテスト(FastMCP Client)
FastMCP(Python向けMCPフレームワーク)に同梱の Client は、サーバープロセスを立ち上げずに メモリ内でMCPサーバーを直接呼び出せる。ネットワーク遅延ゼロ・認証スキップ・並列実行可で、各テスト数十msで終わる。CI/CDのユニットテスト層で第一選択になる。
# tests/test_create_invoice.py
import pytest
from fastmcp import FastMCP, Client
# 本番のサーバーインスタンスを直接importして使う
from my_mcp_server import mcp
@pytest.mark.asyncio
async def test_create_invoice_success():
async with Client(mcp) as client:
result = await client.call_tool(
"create_invoice",
{"amount": 10000, "client": "Acme Inc"}
)
assert result.is_error is False
assert "invoice_id" in result.content[0].text
@pytest.mark.asyncio
async def test_create_invoice_invalid_amount():
async with Client(mcp) as client:
result = await client.call_tool(
"create_invoice",
{"amount": -100, "client": "Acme Inc"}
)
assert result.is_error is True
# エラーメッセージにフィールド名が含まれているか
assert "amount" in result.content[0].text.lower()
ポイントは 「ツールが返すエラーがLLMに対して有用か」を検証すること。Internal error しか返さないAPIは、エージェントが復帰できず無限リトライに陥る。エラーメッセージにフィールド名・修正方法のヒントが含まれているかを、テストレベルで担保する。
レイヤー2: 契約テスト(mcp-testing-framework)
個々のツール実装が正しくても、MCPプロトコル全体として仕様準拠かは別問題。initializeレスポンスにcapabilitiesが正しく含まれるか、tools/listのJSONスキーマが規格に沿っているか、エラーレスポンスがJSON-RPC 2.0仕様を満たすか——これらを毎PRで自動検証するのが契約テスト層だ。
PyPIで公開されている mcp-testing-framework は、サーバーバイナリを起動して実プロトコル経由で網羅的に検証するフレームワーク。ローカル/CI両方で同じテストが走る。
# pip install mcp-testing-framework
# tests/test_mcp_contract.py
from mcp_testing_framework import ContractTester
def test_mcp_protocol_compliance():
tester = ContractTester(
command=["python", "-m", "my_mcp_server"],
transport="stdio",
)
# MCP仕様準拠を網羅検証
report = tester.run_full_compliance_check()
assert report.passed, f"Compliance failures: {report.failures}"
def test_tool_schemas_valid():
tester = ContractTester(command=["python", "-m", "my_mcp_server"])
tools = tester.list_tools()
for tool in tools:
# JSON Schema Draft 2020-12 準拠か
assert tester.validate_input_schema(tool), \
f"Invalid inputSchema for tool: {tool.name}"
レイヤー3: スキーマドリフト検出
もっとも見落とされがちな層。ツールスキーマの「些細な変更」が本番のエージェント挙動を破壊するのを、コードレビュー段階で気づくための仕組みだ。
スナップショット方式
サーバーの tools/list レスポンスをJSONとしてリポジトリに保存し、PRごとに差分を検出する。差分があれば必ずレビューを通す——シンプルだが効果的。
# scripts/snapshot_tools.py
import json
from fastmcp import Client
from my_mcp_server import mcp
async def main():
async with Client(mcp) as client:
tools = await client.list_tools()
# 名前順に並べて再現性確保
normalized = sorted(
[t.model_dump() for t in tools],
key=lambda x: x["name"],
)
with open("tests/snapshots/tools.json", "w") as f:
json.dump(normalized, f, indent=2, ensure_ascii=False, sort_keys=True)
# CI上では: python scripts/snapshot_tools.py && git diff --exit-code tests/snapshots/
CIで git diff --exit-code が非ゼロを返せばPRはfailし、レビュアーが 「このdescription変更はツール選択率にどう影響するか」 を意識的に判断する文化が生まれる。
選択率回帰テスト(高度)
さらに踏み込むなら、代表クエリ10〜20件を用意してClaude/GPT/Geminiが 正しいツールを選ぶ率 を定点観測する。閾値(例: 90%)を下回ったらPRをブロックする仕組みが、ツールsprawlの抑制に効く。KanseiLinkのAgent Voiceデータでも、モデル横断の選択率変動が本番品質と高相関を示している。
レイヤー4: 実LLMでのE2Eテスト
最後の砦は 実LLMでのエンドツーエンドテスト。Claude Haiku 4.5など軽量モデルを使い、代表ユーザー意図に対してエージェントが「正しいツールを選び、正しい引数で呼び、結果を解釈し、適切に応答する」を検証する。
import os
from anthropic import Anthropic
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
EVAL_CASES = [
{
"user": "今月の請求書を全部出して",
"expected_tool": "list_invoices",
"expected_args_contain": ["2026-05"],
},
{
"user": "Acme社に10,000円の請求書を作って",
"expected_tool": "create_invoice",
"expected_args_contain": ["10000", "Acme"],
},
]
def test_eval_case(case):
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
tools=load_mcp_tools_as_anthropic_format(),
messages=[{"role": "user", "content": case["user"]}],
)
# ツール呼び出しが含まれているか
tool_use = next(
(b for b in response.content if b.type == "tool_use"),
None,
)
assert tool_use is not None, "No tool was called"
assert tool_use.name == case["expected_tool"]
args_str = json.dumps(tool_use.input)
for needle in case["expected_args_contain"]:
assert needle in args_str, f"Missing {needle} in {args_str}"
代表クエリ20件 × 平均2,000トークン入出力 × 月30日 = 月1.2Mトークン。Claude Haiku 4.5の料金水準なら月数百円〜数千円規模に収まる。本番事故1回(数時間ダウンタイム + 顧客影響)と比べれば、桁違いに安い投資だ。価格は変動するため運用前に最新公式料金を確認すること。
GitHub Actionsワークフロー実装例
4層を統合したGitHub Actionsの最小例。ユニット・契約・スナップショットは全PR、E2Eはナイトリーとリリースタグのみで実行する。
# .github/workflows/mcp-test.yml
name: MCP Server Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 16 * * *" # 毎日 01:00 JST (16:00 UTC)
jobs:
fast-tests:
name: Unit + Contract + Schema Drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- name: Unit tests (FastMCP Client)
run: pytest tests/unit -v
- name: Contract tests (mcp-testing-framework)
run: pytest tests/contract -v
- name: Snapshot drift check
run: |
python scripts/snapshot_tools.py
git diff --exit-code tests/snapshots/ \
|| (echo "::error::Tool schema drift detected. Review and commit snapshot." && exit 1)
e2e-tests:
name: E2E with Real LLM
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
environment: production # gated environment for secrets
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- name: Run E2E evaluation
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: pytest tests/e2e -v --json-report
- name: Upload artifact on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-failure-traces
path: .pytest_cache/
(1) シークレット隔離: API_KEYはgated environmentで管理し、forkからのPRでは公開しない。(2) フレーキー対策: LLM呼び出しは指数バックオフ + ジッター + リトライ上限。(3) アーティファクト保存: 失敗時のリクエスト/レスポンスをupload-artifactで残す。(4) 並列の競合: OAuth refresh tokenの並列更新は多くのSaaSで競合するため、テストごとに別アカウントを用意するか直列化する。
本番チェックリスト
MCPサーバーをCI/CDに乗せる前の最低限チェックリスト。
- FastMCP Client (or 同等のメモリ内テストランナー) でユニットテストが書かれている
- mcp-testing-framework または同等の契約テストでMCP仕様準拠を検証している
- tools/listのスナップショットがリポジトリに保存され、PR diffで差分検出される
- ツールエラーがLLM消費可能な構造(フィールド名・修正ヒント)を返している
- 実LLM E2Eテストがナイトリー or リリースタグでトリガーされる
- 失敗時のリクエスト/レスポンスがCIのartifactとして残る
- シークレットがgated environment(forkからのPRでは公開しない)で管理されている
- テスト用OAuthアカウントが本番アカウントと分離されている
- 並列テストでrefresh token競合が起きない仕組みになっている
- 選択率回帰テスト(任意)でツールsprawlの兆候を監視している
FAQ
Q1. なぜMCPテストはWeb APIテストより難しい?
3つの非対称性。(1)呼び出し側がLLMで非決定的、(2)ツールスキーマがプロンプト的役割を持ちdescription一語でツール選択率が変わる、(3)MCP仕様自体が高頻度で更新される。通常の「リクエスト→レスポンス」テストでは捕まえきれない領域が大きい。
Q2. FastMCP Clientとmcp-testing-frameworkの違いは?
FastMCP Clientはメモリ内のユニットテスト向け(高速・低コスト)。mcp-testing-frameworkは実プロトコル経由の契約テスト向け(MCP仕様準拠検証)。両方併用が現実解で、ユニットでロジック回帰、契約で仕様準拠を担保する。
Q3. ツールdescriptionだけ変えたPRをCIで検出できる?
tools/listスナップショットをリポジトリに保存しPR diffで検出するのが定石。さらに踏み込むなら代表クエリ10〜20件で「正しいツールが選ばれた率」を定点観測する選択率回帰テストを追加する。
Q4. 実LLM E2Eテストはコストがかかりすぎる?
全PR必要なし。「ユニット/契約/スナップショットは全PR、E2Eはナイトリー + リリースタグ + スキーマ変更PRのみ」の3トリガー方式が推奨。Claude Haiku 4.5なら月数百〜数千円規模に収まる試算で、本番事故1回より圧倒的に安い。価格は変動するため公式料金を確認のこと。
Q5. GitHub Actionsでの落とし穴は?
(1)シークレットはgated environmentで隔離(forkからのPRに漏らさない)、(2)LLM呼び出しは指数バックオフ + リトライ上限でフレーキー対策、(3)失敗時はartifact uploadで再現性確保、(4)並列実行時のOAuth refresh token競合を直列化または別アカウント化で回避。
Q6. テスト用のSaaSアカウントはどう用意する?
本番と完全分離する。freee/Slack/kintone等の主要SaaSはサンドボックス環境を提供しているため、CI専用アカウントをサンドボックスに作る。サンドボックス未提供のSaaSは、最小プランの専用アカウントを切り、データを毎週リセットするスクリプトを用意するのが現実解。
本記事の技術的内容は2026年5月時点の公開情報・公式ドキュメントに基づく。FastMCP Client(github.com/jlowin/fastmcp)、mcp-testing-framework(pypi.org/project/mcp-testing-framework/)はそれぞれ公式リポジトリ/PyPIで2026年5月時点に公開。コード例は実装の概念示唆を目的とした擬似コードで、本番投入時は対象MCP SDKの最新APIに合わせた検証が必要。Anthropic Claude Haiku 4.5の料金は変動するため、運用コスト試算は実行前に最新公式料金で再計算してください。「description変更でツール選択率が変わる」は業界一般に観測される傾向で、具体的な変動幅は対象モデル・ツール構成により異なる。価格・仕様は予告なく変更される可能性があります。