目次

  1. なぜMCPテストはWeb APIテストより難しいのか
  2. MCPテストピラミッド — 4層構成
  3. レイヤー1: ユニットテスト(FastMCP Client)
  4. レイヤー2: 契約テスト(mcp-testing-framework)
  5. レイヤー3: スキーマドリフト検出
  6. レイヤー4: 実LLMでのE2Eテスト
  7. GitHub Actionsワークフロー実装例
  8. 本番チェックリスト
  9. FAQ

なぜ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}"
コスト試算 — Claude Haiku 4.5でのナイトリーE2E

代表クエリ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/
✅ 4つのコツ

(1) シークレット隔離: API_KEYはgated environmentで管理し、forkからのPRでは公開しない。(2) フレーキー対策: LLM呼び出しは指数バックオフ + ジッター + リトライ上限。(3) アーティファクト保存: 失敗時のリクエスト/レスポンスをupload-artifactで残す。(4) 並列の競合: OAuth refresh tokenの並列更新は多くのSaaSで競合するため、テストごとに別アカウントを用意するか直列化する。

本番チェックリスト

MCPサーバーをCI/CDに乗せる前の最低限チェックリスト。

225+サービスの実エージェント挙動データを毎週更新

KanseiLinkは日本SaaSと主要グローバルAPIの実測成功率・タイムアウト・選択率データをMCP経由で提供。「あなたのMCPがどのモデルでどう挙動するか」を本番投入前に確認できます。

本番品質データで自社MCPを検証する

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変更でツール選択率が変わる」は業界一般に観測される傾向で、具体的な変動幅は対象モデル・ツール構成により異なる。価格・仕様は予告なく変更される可能性があります。