MCPサーバーの受け入れテスト
Model Context Protocol(MCP) のサーバーは公式がSDKを提供しています. 言語にもよりますがSDKのおかげでMCPサーバーにかかわるコードは1ファイルに収まる程度, 非常に簡単に実装することができます. とはいえ書き捨てのツールのつもりが社内展開されてしまうといったこと, あると思います. そうなってしまうと, 依存関係の更新などのメンテナンスや機能追加が必要になってきます. そのため初めから受け入れテストを実装しておくと安心です. MCPサーバーの受け入れテストは簡単に実装することができます.
単体テストは?
MCPサーバー特有の単体テストを考えると, JSONを受け取ってJSONを返す関数のテストとみなすことができます. これは一般的なWebサーバーのアプリケーション層のテストと同様と言ってもよいでしょう. MCPサーバーの単体テストも同じようにできるはずです. しかし後述の通り, MCPサーバーのテストは受け入れテストであっても test size を small にすることができます. そのため, まずは受け入れテストを実装するほうが効果が高いと考えます.
環境
実装するMCPサーバーは stdio サーバーとします.
開発環境は何でもよいのですが, ローカルMCPの場合は権限を細かく設定できる Deno が最も適切と思います. そのため以下では Deno を前提にしています.
- Deno 2.3.1
- @modelcontextprotocol/sdk 1.11.3
テストフレームワークはなんでもよいのですが, ここでは Vitest を利用します.
- vitest 3.1.3
MCPサーバーと受け入れテストの実装例は以下のリポジトリにあります.
以下では受け入れテストとして以下の4つの方法を紹介します.
-
@modelcontextprotocol/inspector
を利用する -
InMemoryTransport
を利用する -
Client
を利用する - 直接操作する
手っ取り早く受け入れテストを実装したい場合は InMemoryTransport
を利用する方法がよいです.
@modelcontextprotocol/inspector を利用する
Model Context Protocol 公式からテスト用ツールとして modelcontextprotocol/inspector が提供されています. これを利用することでGUI上で簡単にテストすることができますし, CLIから実行することも可能です[1].
しかし, このツールは Node.js と Python しか対応しておらず, テストといっても動作確認用のツールという程度です.
InMemoryTransport を利用する
公式から提供されているテストツールでは厳密な自動テストが実行できないため, 以下では一般的な自動テストフレームワークを利用した方法をとります. @modelcontextprotocol/sdk
に含まれる InMemoryTransport
はメモリ上で Server と Client の接続を提供してくれます[2]. これを利用することで簡単にMCPの接続を含めてテストすることができます. しかもメモリ上で動作するため test size も small にでき, いいことづくめです[3]. MCPサーバーのテストを実装したくなったら, まずはこの方法を試してみるとよいでしょう. 実装例は以下にあるため, ここでは割愛します.
Deno で RooCode 用にローカルMCPサーバーをさっと作る
TypeScript で MCP サーバーを実装し、Claude Desktop から利用する
結論としてはこれで十分なので後述の方法は読まなくていいです.
Client を利用する
愚直に受け入れテストを実装することを考えると Client
をテストから直接動かすことになるでしょう. 前述の InMemoryTransport
を利用する方法とは異なり Transport 層も実際のものを利用することになります. 公式にMCPクライアントの実装例があるため, これを参考にするとよいでしょう[4].
サーバーとの接続は実際にMCPサーバーを立ち上げるときと同様にエントリポイントを直接指定して実行します.
import { expect, test } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio";
test(
"when passing correct command then returns correct calculation result",
async () => {
const transport = new StdioClientTransport({
command: "deno",
args: ["run", "-A", `${Deno.cwd()}/main.ts`],
});
const client = new Client({ name: "example-client", version: "1.0.0" });
await client.connect(transport);
const actual = await client.callTool({
name: "execute-maxima",
arguments: { command: "diff(sin(x), x)" },
});
expect(actual.content).toStrictEqual([{ type: "text", text: "cos(x)" }]);
},
1000,
);
この方法は test size が medium test 以上のテストとして実装することになり, 実行時間がどうしても長くなります. またSDKにも依存しており Transport 層を実際の環境で実行することの意味もあまりありません. InMemoryTransport
を利用する方法のほうがよいでしょう.
直接操作する
結局のところMCPの仕組みは stdio であればプロセスにJSONを流し込んでプロセスからJSONを受け取るだけです. これをそのまま受け入れテストとして実装すればテストからSDKへの依存関係を無くすことができ, 堅牢なテストを実装することができます.
MCPでのメッセージのやり取りにはまず Client から initialize
メッセージの送信と initialized
メッセージの送信が必要です. その後に実際のツールの呼び出しを行います.
Connection lifecycle - Core architecture - Model Context Protocol
import { expect, test } from "vitest";
test(
"when passing correct command then returns correct calculation result",
async () => {
const command = new Deno.Command("deno", {
args: ["run", "-A", "--no-check", `${Deno.cwd()}/main.ts`],
stdin: "piped",
stdout: "piped",
stderr: "piped",
});
const child = command.spawn();
const writer = child.stdin.getWriter();
const reader = child.stdout.pipeThrough(new TextDecoderStream())
.getReader();
await writer.write(new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: "init-1",
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test-client-stdio", version: "0.0.1" },
},
}) + "\n",
));
const readLines = async () => {
const line = await reader.read();
if (line.done) {
return; // 出力がなくなったら完了
}
try {
return JSON.parse(line.value); // JSONを受け取ったら完了
} catch {
return await readLines(); // JSONでない場合は再帰的に読み捨て
}
};
await readLines();
await writer.write(new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
method: "initialized",
params: {},
}) + "\n",
));
await writer.write(new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: "call-1",
method: "tools/call",
params: {
name: "execute-maxima",
arguments: { command: "diff(sin(x), x)" },
},
}) + "\n",
));
const actual = await readLines();
expect(actual.result.content).toStrictEqual([{
type: "text",
text: "cos(x)",
}]);
await writer.close();
child.kill("SIGTERM");
await child.status;
},
1000,
);
上の例では入出力からログ出力を取り除くため, JSON出ない場合は読み捨てるという処理を行っています. コード量は増えたものの, やっていることはシンプルなのでテストコードとしてはシンプルです. この場合もプロセス呼び出しがあるため test size は medium 以上となります.
MCPサーバーの受け入れテストを実装する方法を4例紹介しました. 方法によりデメリットもありますが, 自身の目的に合わせてテストを実装できるとよいでしょう. MCPサーバーは現時点(2025-05-20)では仕様もシンプルで実装も簡単です. こういったツールでチャレンジしておくと, 他のプロダクトでも活きるはずです.
-
Inspector - Model Context Protocol https://modelcontextprotocol.io/docs/tools/inspector ↩︎
-
typescript-sdk/src/inMemory.ts at 3f429895fb923717fe2b15934eeb6a11e2578e64 · modelcontextprotocol/typescript-sdk https://github.com/modelcontextprotocol/typescript-sdk/blob/3f429895fb923717fe2b15934eeb6a11e2578e64/src/inMemory.ts ↩︎
-
Google Testing Blog: Test Sizes https://testing.googleblog.com/2010/12/test-sizes.html ↩︎
-
For Client Developers - Model Context Protocol https://modelcontextprotocol.io/quickstart/client#creating-the-client-2 ↩︎
Discussion