AI活用

MCPサーバーのToolをVitestで自動テスト|InMemoryTransportで入力と応答を検証

MCP TypeScript SDK v1系のToolを、InMemoryTransportと実Clientで自動テストする一式。入力拒否、isError、依存先の差し替え、スナップショットとCI設定例を15テストで確認します。

この記事の目次
  1. Handlerを直接呼ぶテストと、Clientから呼ぶテストを分ける
  2. テスト用のプロジェクトと検索依存を用意する
  3. Tool登録をファクトリにし、検索処理を引数から渡す
  4. InMemoryTransportでClientとServerを接続する
  5. 一覧・正常系・不正入力・外部失敗を15ケースで確かめる
  6. callToolのresolveと、Toolの成功は同じではない
  7. テストを実行し、意図しない変更で失敗するかを見る
  8. スナップショットの更新は、変更内容を読んでから行う
  9. CIへ置くときも、同じコマンドを実行する

Inspectorでは検索できていたMCP Toolを直したあと、空の検索語まで外部APIへ渡すようになってしまった。こうした変更を見つけるには、テスト用ClientからTool一覧を取得し、正常入力と不正入力を実際に送るテストを用意します。

TypeScript SDKのInMemoryTransport.createLinkedPair()でClientとServerを同じプロセス内につなげば、別のサーバープロセスやLLMのAPIキーを用意せずに検証できます。偽の関数へ置き換えるのはノート検索の外部依存だけで、MCPのClient、Server、入力検証は実際のSDKを通します。

対象:この記事は@modelcontextprotocol/sdk 1.30.0を使うv1系のサーバー向けです。この版に含まれる最新のprotocol versionは2025-11-25です。別パッケージ構成のv2 SDKや新しいプロトコルへ、そのまま同じimport・挙動を当てはめないでください。以下は2026年9月12日に固定した組み合わせで確認しています。

Handlerを直接呼ぶテストと、Clientから呼ぶテストを分ける

検索関数だけを呼ぶテストでは、Tool名の登録漏れや公開する入力スキーマの変更までは確認できません。今回はclient.listTools()とclient.callTool()を使い、クライアントが受け取る形を検査します。

確かめたい変更 見るもの
Toolを消した・名前を変えた tools/listの名前一覧
検索語や件数の条件を変えた 公開inputSchemaと、不正入力で依存先が呼ばれないこと
既定値や正規化を変えた 依存先へ実際に渡った引数
戻り値を変えた structuredContentとtextの一致、件数、含めないフィールド
外部依存が失敗した isErrorと、返してよいメッセージ

Toolをまだ登録していない場合は、Node.jsでMCPサーバーを作る手順を先に使えます。ここでは、接続を始める処理からTool登録を切り離し、毎回新しいサーバーを作る形にします。

スポンサーリンク

テスト用のプロジェクトと検索依存を用意する

次のファイルを一つのフォルダーに置きます。外部のノートサービス、データベース、stdio起動処理は不要です。

mcp-tool-tests/
├── package.json
├── package-lock.json
├── tsconfig.json
├── vitest.config.ts
├── src/server.ts
├── test/client.ts
├── test/tools.test.ts
└── .github/workflows/test.yml

Node.js 24.13.0、SDK 1.30.0、Zod 4.6.2、Vitest 5.0.0、TypeScript 7.0.2で検証しました。本文から新しく作る場合は、package.jsonを保存してnpm installを実行し、生成されたpackage-lock.jsonも管理します。lockfileがある状態ではnpm ciで揃えられます。

package.json

{
  "name": "mcp-tool-testing-example",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "engines": {
    "node": ">=24.13.0 <25"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.30.0",
    "zod": "4.6.2"
  },
  "devDependencies": {
    "vitest": "5.0.0",
    "typescript": "7.0.2",
    "@types/node": "24.13.4"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
}

vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['test/**/*.test.ts'],
    testTimeout: 5000,
    hookTimeout: 5000,
  },
});

vitest runは一度実行して終了する指定です。tsc --noEmitは型チェック用に別途実行します。Vitestでテストが通ったことだけを、TypeScriptの型チェック成功として扱わないようにします。実行方法はVitestのGetting Startedを参照できます。

Tool登録をファクトリにし、検索処理を引数から渡す

createServer(searchNotes)へ、検索する関数を渡します。Toolは検索語をtrimし、件数の既定値を5、範囲を1〜10にします。未知の入力項目はstrict()で拒否します。ノートの本文は返さず、IDとタイトルへ絞ります。

src/server.ts

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

export type SearchNotes = (query: string, limit: number) => Promise<{
  id: string;
  title: string;
  body: string;
}[]>;

// 接続は呼び出し側で行い、テストごとに新しいServerを作る。
export function createServer(searchNotes: SearchNotes) {
  const server = new McpServer({ name: 'note-search-test-example', version: '1.0.0' });
  server.registerTool('search_notes', {
    description: 'ノートを検索し、IDとタイトルを返す',
    inputSchema: z.object({
      query: z.string().trim().min(1).max(100),
      limit: z.number().int().min(1).max(10).default(5),
    }).strict(),
    outputSchema: z.object({
      query: z.string(),
      count: z.number().int().nonnegative(),
      items: z.array(z.object({ id: z.string(), title: z.string() })),
    }),
    annotations: { readOnlyHint: true },
  }, async ({ query, limit }) => {
    try {
      const notes = await searchNotes(query, limit);
      const items = notes.slice(0, limit).map(({ id, title }) => ({ id, title }));
      const result = { query, count: items.length, items };
      return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
    } catch {
      // 外部依存の生の例外メッセージをTool応答へ渡さない。
      return { isError: true, content: [{ type: 'text', text: 'ノートを検索できませんでした。時間を置いて再実行してください。' }] };
    }
  });
  return server;
}

外部依存が例外を投げたときは、固定したToolエラーへ変換しています。SDKが例外を扱ってくれるからといって、生のエラーメッセージをそのまま応答に使わないためです。この例の検索関数は固定データへ差し替えるので、認証や実データのアクセス制御を検証しているわけではありません。

InMemoryTransportでClientとServerを接続する

linked pairの片方をServer、もう片方をClientへ渡します。Server側を接続してからClient側を接続し、初期化が終わったClientで検査関数を実行します。成功しても失敗しても、finallyで接続を閉じます。

test/client.ts

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer, type SearchNotes } from '../src/server.js';

export async function withClient(search: SearchNotes, check: (client: Client) => Promise<void>) {
  const server = createServer(search);
  const client = new Client({ name: 'test-client', version: '1.0.0' });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  try {
    await server.connect(serverTransport);
    await client.connect(clientTransport);
    await check(client);
  } finally {
    try { await client.close(); }
    finally { await server.close(); }
  }
}

テストごとにClientとServerを作り直すため、前のテストの登録状態や検索モックを共有しません。withClient()の外で作ったServerを使い回す構成へ変える場合は、状態が残ることも含めて設計し直してください。

この接続は同じプロセス内のメッセージ受け渡しです。stdioの標準出力、HTTPのヘッダー、認証、実際のネットワーク障害は通りません。それらの確認を省ける仕組みではなく、Toolの変更を短いテストで見つけるために使います。

一覧・正常系・不正入力・外部失敗を15ケースで確かめる

次のファイルは、そのまま実行できるテスト一式です。スナップショットには今回確認したTool名、入力スキーマ、annotationsだけを入れています。検索結果の件数や内容、不正入力で処理が呼ばれないことは、別のassertionで検査します。

test/tools.test.ts

import { expect, test, vi } from 'vitest';
import { ErrorCode, ResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type { SearchNotes } from '../src/server.js';
import { withClient } from './client.js';

test('公開するTool一覧と入力制約を固定する', async () => {
  await withClient(vi.fn<SearchNotes>().mockResolvedValue([]), async client => {
    const { tools } = await client.listTools();
    expect(tools.map(tool => tool.name)).toEqual(['search_notes']);
    const tool = tools[0];
    expect({ name: tool.name, inputSchema: tool.inputSchema, annotations: tool.annotations }).toMatchInlineSnapshot(`
      {
        "annotations": {
          "readOnlyHint": true,
        },
        "inputSchema": {
          "$schema": "http://json-schema.org/draft-07/schema#",
          "additionalProperties": false,
          "properties": {
            "limit": {
              "default": 5,
              "maximum": 10,
              "minimum": 1,
              "type": "integer",
            },
            "query": {
              "maxLength": 100,
              "minLength": 1,
              "type": "string",
            },
          },
          "required": [
            "query",
          ],
          "type": "object",
        },
        "name": "search_notes",
      }
    `);
    expect(tool.outputSchema).toMatchObject({ required: ['query', 'count', 'items'] });
  });
});

test('空白を整え、既定limitを依存先へ渡し、本文を応答から除く', async () => {
  const search = vi.fn<SearchNotes>().mockResolvedValue([{ id: 'n-1', title: 'MCP', body: 'PRIVATE_FIXTURE_BODY' }]);
  await withClient(search, async client => {
    const result = await client.callTool({ name: 'search_notes', arguments: { query: '  MCP  ' } });
    expect(result.isError).not.toBe(true);
    expect(search).toHaveBeenCalledExactlyOnceWith('MCP', 5);
    const expected = { query: 'MCP', count: 1, items: [{ id: 'n-1', title: 'MCP' }] };
    expect(result.structuredContent).toEqual(expected);
    expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(expected) }]);
    expect(JSON.stringify(result)).not.toContain('PRIVATE_FIXTURE_BODY');
  });
});

test('依存先が多く返しても指定件数を超えない', async () => {
  const search = vi.fn<SearchNotes>().mockResolvedValue([
    { id: 'n-1', title: 'A', body: '' }, { id: 'n-2', title: 'B', body: '' },
  ]);
  await withClient(search, async client => {
    const result = await client.callTool({ name: 'search_notes', arguments: { query: 'MCP', limit: 1 } });
    expect(result.isError).not.toBe(true);
    expect(result.structuredContent).toEqual({ query: 'MCP', count: 1, items: [{ id: 'n-1', title: 'A' }] });
    expect(search).toHaveBeenCalledExactlyOnceWith('MCP', 1);
  });
});

test('0件は正常な検索結果として返す', async () => {
  await withClient(vi.fn<SearchNotes>().mockResolvedValue([]), async client => {
    const result = await client.callTool({ name: 'search_notes', arguments: { query: '該当なし' } });
    expect(result.isError).not.toBe(true);
    expect(result.structuredContent).toEqual({ query: '該当なし', count: 0, items: [] });
  });
});

test.each([
  {}, { query: '   ' }, { query: 'x'.repeat(101) },
  { query: 'MCP', limit: 0 }, { query: 'MCP', limit: 11 },
  { query: 'MCP', limit: 1.5 }, { query: 'MCP', limit: '2' },
  { query: 'MCP', extra: 'unknown field' },
])('不正入力を処理前に拒否する: %j', async arguments_ => {
  const search = vi.fn<SearchNotes>().mockResolvedValue([]);
  await withClient(search, async client => {
    const result = await client.callTool({ name: 'search_notes', arguments: arguments_ });
    expect(result.isError).toBe(true);
    expect(search).not.toHaveBeenCalled();
  });
});

test('外部依存の失敗を固定メッセージへ変換する', async () => {
  const search = vi.fn<SearchNotes>().mockRejectedValue(new Error('DUMMY_PRIVATE_DETAIL'));
  await withClient(search, async client => {
    const result = await client.callTool({ name: 'search_notes', arguments: { query: 'MCP' } });
    expect(result.isError).toBe(true);
    expect(result.content).toEqual([{ type: 'text', text: 'ノートを検索できませんでした。時間を置いて再実行してください。' }]);
    expect(JSON.stringify(result)).not.toContain('DUMMY_PRIVATE_DETAIL');
  });
});

test('このSDKのMcpServerでは未知ToolもisErrorで返る', async () => {
  const search = vi.fn<SearchNotes>().mockResolvedValue([]);
  await withClient(search, async client => {
    const result = await client.callTool({ name: 'missing_tool', arguments: {} });
    expect(result.isError).toBe(true);
    expect(search).not.toHaveBeenCalled();
  });
});

test('未実装のJSON-RPCメソッドはrequestの失敗になる', async () => {
  await withClient(vi.fn<SearchNotes>().mockResolvedValue([]), async client => {
    await expect(client.request({ method: 'example/unsupported' }, ResultSchema))
      .rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
  });
});

各テストで外部依存をvi.fn()にしている一方、client.callTool()そのものは偽物にしていません。空白を整えた検索語と既定の5が検索関数へ渡ること、未知の入力項目や不正な件数では検索関数が一度も呼ばれないことを確認できます。

検索結果には架空の本文PRIVATE_FIXTURE_BODYを入れ、応答から除かれることも検査しています。0件は正常な結果です。外部依存の失敗を表すisError: trueと、0件の検索成功を混ぜないようにします。

callToolのresolveと、Toolの成功は同じではない

このSDK版のMcpServerでは、入力検証の失敗と未知のTool名も、isError: trueを持つ結果として返りました。したがって、そのケースをrejectsだけで検査すると、期待する失敗の形と合いません。一方、未実装のJSON-RPCメソッドを送る最後のテストでは、requestがMethodNotFoundで失敗します。

この構成で送ったもの 観測した返り方 検査
正常なsearch_notes 結果としてresolve isErrorがtrueでないこと+内容
空文字・型違いなどの入力 isError: trueの結果 isError+依存先未呼出し
外部検索の例外 自分で変換したisError: trueの結果 固定文言+生の詳細がないこと
存在しないTool名 このSDKのMcpServerではisError: true isError+依存先未呼出し
未実装JSON-RPCメソッド requestがreject エラーコード

MCPにはTool結果として伝える失敗と、プロトコル側のエラーがあります。Tools仕様のError Handlingと、利用中SDKの返り方を両方見てください。上の表は固定したSDK・McpServerでの観測結果であり、未知Toolが全実装で同じ形になるという意味ではありません。

テストを実行し、意図しない変更で失敗するかを見る

npm run typecheck
npm test

ローカルでは型チェックと15テストが成功しました。さらに、テストが変更を見つけるかを確かめるため、次の2箇所をそれぞれ一時的に変更しました。

一時的な変更 テストが見つける問題
queryのmin(1)をmin(0)にする 公開スキーマの変化と、空白だけの検索語が受理されること
notes.slice(0, limit)を外す 依存先が多く返した場合に、指定した件数を超えること

どちらもテストは失敗し、元へ戻すと15テストが再び成功しました。既存プロジェクトへ取り込むときも、守りたい条件を一つ変えて検出できるかを見ると、成功結果だけを返すモックにしてしまっていないか確認できます。

スナップショットの更新は、変更内容を読んでから行う

Tool名、min/max、required、未知項目の扱いが変わったら、まず意図した変更かを確認します。仕様として変更する場合は、不正入力のケースや依存先へ渡す値も揃えてから、npm test -- --updateでスナップショットを更新できます。更新後は差分を読み、通常のnpm testを実行します。

スナップショットが通るように更新するだけでは、意図しない仕様変更まで受け入れてしまいます。CIには更新オプションを付けず、確定した期待値と比較させます。更新方法と差分の扱いはVitestのSnapshotガイドも参照できます。

CIへ置くときも、同じコマンドを実行する

次は、この例のpackage.jsonとlockfileをリポジトリのルートに置く場合のGitHub Actions設定例です。サブフォルダーへ置く場合は、runの作業ディレクトリとsetup-nodeのcache-dependency-pathをその場所へ合わせます。

.github/workflows/test.yml

name: MCP tool tests
on:
  push:
  pull_request:
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          persist-credentials: false
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
        with:
          node-version: '24.13.0'
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - run: npm test

checkoutとsetup-nodeは、確認時のv7タグが指すcommitへ固定しています。詳細はcheckoutとsetup-nodeの公式READMEで確認できます。このWorkflowは設定例として用意したもので、今回GitHub Actions上では実行していません。ローカルで確認したのは型チェックと15テストです。

自分のToolへ広げるときは、まず「これが変わったら困る入力・出力」を決め、Client経由の検査を追加します。次に、採用したstdioやHTTPで実際に接続する確認を別に残します。Toolの契約と接続環境を分けておくと、テストが落ちたときに見る場所も絞れます。

スポンサーリンク