powered by TechFeed
表示モード
ハウツー

MCPツールはユニットテストをパスしても壊れる — エージェントが「実際に使える」ことを4層で検証する方法

9月3日、Fahad Khalidが「How I Test MCP Tools and MCP Apps」と題した記事を公開した。この記事では、MCPツールおよびMCP Appsを実際に動作する環境で段階的にテストするための実践的な方法論について詳しく紹介されている。

9月3日、Fahad Khalidが「How I Test MCP Tools and MCP Apps」と題した記事を公開した。この記事では、MCPツールおよびMCP Appsを実際に動作する環境で段階的にテストするための実践的な方法論について詳しく紹介されている。


MCPツールは通常のユニットテストをパスしても、エージェントが実際に使おうとすると壊れることがある。モデルが間違ったツールを選ぶかもしれない。引数が間違っているかもしれない。ツールの説明が曖昧すぎて、モデルが迷子になるかもしれない。

この問題の核心は、コードの正しさとエージェントの動作は別物だという点にある。

著者はこれをレイヤー(層)に分けてテストする方法を提案している。各レイヤーが異なる問いに答える構造だ。

MCPとはModel Context Protocol(MCP)は、LLMアプリケーションが外部ツールやデータソースと標準化された方法でやり取りするためのオープンプロトコル。Anthropicが2024年に公開し、Claude、Cursor、VSCode Copilotなど主要なAIエージェント環境で広く採用が進んでいる。MCPサーバーは「ツール」を公開し、エージェントはそれを発見・呼び出す形で動作する。


レイヤー1:まず普通のコードをテストする

MCPを介す前に、ツールの背後にある実際の動作を確認する。ノートを作るツールなら、ノートが実際に存在するか確認する。設定を更新するツールなら、設定を読み直す。

test("creates a note", async () => {
  const store = new Map();
  const result = await createNote({ title: "Release notes", store });
  expect(store.get(result.id)).toEqual({ id: result.id, title: "Release notes" });
});

著者が強調するのは「結果を検証する、実装ではなく」という原則だ。モックが呼ばれたかを確認するより、ノートが実際に作られたかを確認する方が強い。ソースコードにツール名の文字列があっても、動作が壊れていることはある。


レイヤー2:本物のMCPクライアントを通してテストする

次はMCPの境界を確認するレイヤーだ。TypeScriptサーバーであれば、本番で使うHTTPハンドラーに本物のMCPクライアントを接続できる。ポートやソケットを使わず、fetchを注入してハンドラー経由で呼び出す。

import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { createMcpHandler } from "@modelcontextprotocol/server";

test("creates a note through MCP", async () => {
  const store = new Map();
  const handler = createMcpHandler(() => createServer({ store }));
  const transport = new StreamableHTTPClientTransport(
    new URL("http://test.local/mcp"),
    { fetch: (url, init) => handler.fetch(new Request(url, init)) },
  );
  const client = new Client(
    { name: "test", version: "1.0.0" },
    { versionNegotiation: { mode: "auto" } },
  );
  await client.connect(transport);
  try {
    const result = await client.callTool({
      name: "create_note",
      arguments: { title: "Release notes" },
    });
    expect(result.isError).toBe(false);
    expect(result.structuredContent).toMatchObject({ title: "Release notes" });
  } finally {
    await client.close();
    await handler.close();
  }
});

公式TypeScript SDKにはサーバーテストガイドがあり、このパターンが説明されている。

なお、サーバーが通常stdioで動くなら、実際の子プロセスを起動するテストも1本追加する。認証などのミドルウェアがあれば、そのフルスタックを通したリクエストも1本は送る。トランスポート固有のバグは、起動・認証・バージョンネゴシエーション・シャットダウン時に現れることが多い


レイヤー3:MCP InspectorでスモークテストとCI対応

MCP InspectorのCLIはCIでも使えて、モデルなしでツールの一覧取得や呼び出しができる。

npx @modelcontextprotocol/inspector --cli node dist/server.js \
  --method tools/call \
  --tool-name create_note \
  --tool-arg title="Release notes" \
  --format json

これでパッケージ済みサーバーが起動してMCPを話せることは証明できる。ただし、エージェントがそのツールを正しく選ぶかどうかは証明できない

プロトコル準拠の確認にはMCPコンフォーマンスフレームワークも使える。ただし、これはプロトコルをテストするのであって、ツール設計がモデルにとって分かりやすいかは別問題だ。


レイヤー4(核心):モデルを実際に動かすエージェントテスト

ここが最も重要なレイヤーだ。

エージェントに普通のユーザーリクエストを渡し、テスト対象のMCPサーバーだけを接続して、モデル自身にツールを発見・選択させる。

const run = await runAgent({
  prompt: "Create a note named Release notes",
  mcpServer: { command: "node", args: ["dist/server.js"] },
});
expect(await findNote("Release notes")).toBeDefined();

runAgentはモデルAPIを薄くラップしたループでもエージェントSDKでもよい。重要なのは、モデルがMCP経由でツールを見ること。テストからcreate_noteを直接呼ばないことだ。

評価の優先順位は著者によれば次の順番だ:

  1. 期待した状態変化が起きたか?
  2. エージェントはセーフティルールを守ったか?
  3. どのツールを、どの引数で呼んだか?
  4. 最終的な回答は有用だったか?

エージェントが「完了しました」と言っても何も証明しない。データベースを読む、生成されたファイルを取得する、APIをクエリする——こうした確認が必要だ。

ツール呼び出しのトレースも有用で、同じ検索ツールを12回呼んだ、ページネーションを無視した、情報収集前に書き込みツールを使った——こうした問題をトレースで発見できる。ただし、有効なパスが複数ある場合に一つの呼び出しシーケンスを強制はしない。

1回では不十分:複数回実行してパスレートを見る

モデルの出力は非決定的だ。1回通っても通常のユニットテストとは意味が違う。

const results = [];
for (let attempt = 0; attempt < 5; attempt++) {
  const workspace = await createIsolatedWorkspace();
  const run = await runAgent({ prompt: "Create a note named Release notes", mcpServer, workspace });
  results.push({
    passed: await workspace.hasNote("Release notes"),
    toolCalls: run.toolCalls,
  });
}
const passed = results.filter((r) => r.passed).length;
console.log(`${passed}/${results.length} trials passed`);

各試行は独立した状態を持つこと。1回目の実行でノートが作られていると、2回目が誤ってパスする。

著者はこれらのテストをPR毎のチェックには含めず、ナイトリーや手動、リリース前に実行する運用を推奨している。

ローカルLLMという選択肢

ホスト型モデルなしでも、ツール呼び出し対応のローカルモデル(Ollamaなど)をOpenAI互換エンドポイント経由で使える。

LOCAL_LLM_URL=http://localhost:11434/v1 \
LOCAL_LLM_MODEL=my-tool-capable-model \
bun run eval:mcp

繰り返しテストのコストを下げ、プライベートプロジェクトではデータをローカルに留められる利点がある。結果が不安定になることもあるが、著者は「証明」としてではなく「追加の回帰シグナル」として使う。小さなモデルがツール名と説明を理解できれば、大きなモデルはさらに扱いやすいという下限確認にもなる。


MCP Appsはさらに一層必要

MCP Apps(ツール結果をインタラクティブなUIとして表示する拡張で、ui://リソースやiframeブリッジを介してホスト環境と通信する仕組み)には、ツールに加えてui://リソース、HTML、iframeブリッジ、ホストとの通信が絡む。

まずブラウザなしでMCP側の契約を確認する(ツールのメタデータに正しいリソースURIがあるか、MIMEタイプがtext/html;profile=mcp-appかなど)。

次に実際のブラウザでテストする。公式のext-appsリポジトリにbasic-hostがあり、ローカルで動作確認できる。自動化にはPlaywrightを使い、接続ハンドシェイクの完了を待って操作する。**sleep(2)で待つのではなく、実際の状態を待つ**のが原則だ。


テストの実行タイミングまとめ

タイミング 内容
毎回の変更時 通常の動作テスト、インプロセスMCPテスト
定期CI パッケージ済みトランスポートのスモークテスト、コンフォーマンス確認、MCP Appsのブラウザテスト
ナイトリー/手動 複数回のモデル参加型エバル、ローカルLLM実行
リリース前 実際のMCPホストでエンドツーエンドのスモークテスト1本

著者の結論は明快だ。「MCPサーバーが動くことを証明する単一のテストは存在しない。安価なテストが通常のコード問題を、プロトコルテストがMCP問題を、ブラウザテストがApp問題を、エージェントテストがモデルが実際に全体を使えるかを示す」。

詳細はHow I Test MCP Tools and MCP Appsを参照していただきたい。