MCPサーバー開発ガイド——仕組みの理解から実装まで

AIエージェントの記事では、Claude APIにツールを持たせ、実行ループを自前で実装しました。あの構成には1つ弱点があります。ツールの定義と実行ロジックが、エージェントのコードに直接埋め込まれているため、別のアプリケーションから同じツールを再利用しようとすると、ツール定義ごとコピーする必要が出てきます。MCP(Model Context Protocol)は、この「ツールをどう提供し、どう呼び出すか」の部分を標準化するオープンプロトコルです。この記事では、MCPの構造を整理した上で、実際にツールを持つMCPサーバーを実装します。

MCPの全体構造

MCPには3つの登場人物がいます。

  • ホスト(Host): Claude Desktop・Claude CodeのようなAIを使うアプリケーション本体
  • クライアント(Client): ホストの内部でMCPサーバーと1対1の接続を管理する部分
  • サーバー(Server): ツールやデータを提供する側。今回実装する対象

ここで整理しておきたいのが、「MCPサーバーはAIに機能を提供する側であり、MCP自体にAIモデルは含まれていない」という点です。AIモデルが存在するのはホスト側だけで、サーバーは「どのモデルが呼んでいるか」を一切意識せず、自分が提供できる機能を宣言するだけの存在です。実際にモデルにいつ・どう見せるかはホスト側の責務であり、これによって1つのMCPサーバーを複数のホスト(Claude Desktop、自社のエージェント基盤など)から使い回せます。

ただし、これは「サーバーの実装の中でAIモデルを使ってはいけない」という意味ではありません。例えばツールのハンドラ内部で、受け取ったテキストを要約するためにClaude APIを呼び出す、といった実装は普通にあり得ます。その場合、そのツールから見ればAIモデルは「自分が呼び出す先」の1つに過ぎず、プロトコルとしてのMCPが規定する構造(ホストにモデルがあり、サーバーはそれを意識しない)とは矛盾しません。MCPというプロトコル自体はAIモデルの有無を前提にしていない、と理解しておくと整理しやすいです。

3つのプリミティブ

MCPサーバーが提供できるものは、次の3種類に整理されています。

プリミティブ 役割 主導権
Tools モデルが呼び出せる関数(副作用あり) モデルが判断して呼ぶ
Resources モデルに読ませるデータ(ファイル・DB内容など) ホスト/ユーザーが選んで渡す
Prompts 定型的な指示テンプレート ユーザーが明示的に選ぶ

AIエージェントの記事で作ったsearch_inventoryツールは、MCPの語彙で言えば「Tools」に相当します。実務で単に「MCPツール」と言うときは、このTools——サーバーがtools/listで宣言し、モデルの判断でtools/callから実行される関数——を指しています。ResourcesやPromptsも含めた総称ではない点に注意してください。実務でMCPサーバーを作る場合、まず着手することが多いのもToolsです。この記事でもToolsの実装を中心に扱います。

AIがMCPツールを呼び出すまでの流れ

実装に入る前に、ユーザーの入力からMCPツールの実行に至るまで、実際に何が起きているかを順番に見ておきます。

  1. ホストが起動時に、設定済みの各MCPサーバーへ接続する(クライアントがトランスポート経由で接続を確立し、初期化ハンドシェイクでプロトコルバージョンとcapabilitiesを確認する)
  2. ホストが各サーバーに対してtools/listを呼び、ツールの名前・description・入力スキーマの一覧を取得する
  3. ユーザーがホストにメッセージを送ると、ホストは接続中の全MCPサーバーから集めたツール一覧を、モデルへのAPI呼び出しのtoolsパラメータとして渡す——これはAIエージェントの記事で書いたtools配列と、モデルから見て全く同じものです
  4. モデルは、システムプロンプト・ユーザーの入力・各ツールのname/description/入力スキーマだけを手がかりに、どのツールを呼ぶか(あるいは呼ばないか)を判断する
  5. モデルがツール呼び出しを選んだら、ホストのクライアントが該当サーバーにtools/callリクエストを送る
  6. サーバー側のハンドラが実行され、結果を返す
  7. ホストがその結果をtool_resultとしてモデルに渡し、モデルが最終回答を返すか、次のツール呼び出しを判断する(6→4のループ)

つまりMCPは、AIエージェントの記事で自前実装した「ツール定義とツール実行ループ」の、ツール定義・実行部分をプロトコルとして標準化したものだと捉えると理解しやすいです。ループの構造自体は変わりません。

ここで気になるのが、「プロンプトを渡せば毎回正しいMCPツールを使ってくれるのか」という点です。結論としては、プロトコルレベルで正しい選択が保証されているわけではありません。モデルがツールを選ぶ根拠は、あくまでname・description・入力スキーマというテキスト情報だけです。以下のような理由で、意図しないツールが呼ばれたり、必要な場面で呼ばれなかったりすることは実務上普通に起こります。

  • ツールのdescriptionが曖昧、または似た機能のツールが複数存在する(例: search_inventoryget_stockが両方登録されていて、モデルがどちらを使うべきか判断しづらい)
  • 同時に接続しているMCPサーバーの数・ツール数が多く、モデルに提示される選択肢自体が肥大化している
  • ユーザーの指示が曖昧で、ツールを使うべきかどうかの判断そのものが割れる

対策としては、descriptionに「いつ使うべきか/使うべきでないか」まで書き込む、同時に有効化するツールの数を絞る、実際の想定プロンプトで動作確認しながら誤動作をログで拾う、といった地道な調整が中心になります。「特定のツールを必ず使わせたい」という要求がある場合、MCP自体にそれを強制する仕組みはなく、Claude APIのtool_choiceパラメータのような、モデルAPI側・ホスト側の機能で対応することになります。

サーバーを実装する

TypeScript向けの公式SDK(@modelcontextprotocol/sdk)を使います。今回はスキーマ検証ライブラリに依存しない、プレーンな低レベルAPI(Serverクラス)で実装します。

bun add @modelcontextprotocol/sdk

まずはサーバーの器を作り、標準入出力(stdio)を通信経路として起動します。ローカルで動かすMCPサーバーの多くは、この stdio トランスポートを使います。

// src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new Server(
  { name: 'inventory-mcp-server', version: '1.0.0' },
  { capabilities: { tools: {} } },
);

const transport = new StdioServerTransport();
await server.connect(transport);

capabilities.toolsを宣言しておくことで、このサーバーがToolsを提供することをホストに伝えます。この時点ではまだ何のツールも持たないため、接続はできても機能はありません。ここにツールを登録していきます。

ツールを1つ実装する

Toolsの実装は、「tools/listが来たら何を返すか」と「tools/callが来たら何を実行するか」という2つのリクエストハンドラを登録するだけです。入力スキーマはzodのような専用ライブラリを使わず、AIエージェントの記事のinput_schemaと同じ、素のJSON Schemaで宣言します。

import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'search_inventory',
      description: '商品名またはSKUで在庫データベースを検索し、在庫数と保管場所を返す',
      inputSchema: {
        type: 'object',
        properties: {
          query: { type: 'string', description: '検索したい商品名またはSKU' },
          limit: { type: 'number', description: '返却する最大件数(デフォルト10、最大50)' },
        },
        required: ['query'],
      },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name !== 'search_inventory') {
    throw new Error(`未知のツールです: ${request.params.name}`);
  }

  const args = request.params.arguments as { query?: unknown; limit?: unknown };

  if (typeof args.query !== 'string' || args.query.trim() === '') {
    return {
      isError: true,
      content: [{ type: 'text', text: 'queryは必須です。検索したい商品名またはSKUを文字列で指定してください。' }],
    };
  }

  const limit = typeof args.limit === 'number' ? Math.min(Math.max(args.limit, 1), 50) : 10;
  const rows = await searchInventoryDb(args.query, limit);

  if (rows.length === 0) {
    return {
      content: [{ type: 'text', text: `「${args.query}」に一致する在庫が見つかりませんでした。` }],
    };
  }

  return {
    content: [
      {
        type: 'text',
        text: rows.map((r) => `${r.name}(${r.sku}): ${r.stock}個 / ${r.location}`).join('\n'),
      },
    ],
  };
});

inputSchemaはあくまでモデルに渡される「ヒント」であり、SDKが自動で型検証をしてくれるわけではありません。そのためCallToolRequestSchemaのハンドラ内部で、受け取ったargumentsが期待通りの形かを自分のコードで確認する必要があります。ここではtypeofによる最低限のチェックと、limitの範囲をMath.min/Math.maxで丸める処理を手で書いています。zodのような宣言的なライブラリを使えば同じ検証をスキーマ側にまとめられますが、依存ライブラリなしでも「必須項目の型チェック」と「範囲の丸め込み」さえ書けば実用上は十分です。

Resourcesも組み合わせる場合

在庫データそのものをモデルに参照させたい場合は、Resourceとして公開する選択肢もあります。Toolsが「モデルの判断で実行される処理」であるのに対し、Resourcesは「ユーザーやホストが明示的に選んでコンテキストに含めるデータ」という性格を持ちます。実装方法もToolsと対称的で、resources/listresources/readに対応するハンドラを登録します(capabilitiesresources: {}を追加するのを忘れないでください)。

import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    { uri: 'inventory://summary', name: '在庫サマリ', mimeType: 'text/plain' },
  ],
}));

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  if (request.params.uri !== 'inventory://summary') {
    throw new Error(`未知のリソースです: ${request.params.uri}`);
  }

  const summary = await getInventorySummary();
  return {
    contents: [{ uri: request.params.uri, mimeType: 'text/plain', text: summary }],
  };
});

在庫全体のサマリのように、「毎回モデルに判断させて取得するまでもなく、常に渡しておきたい情報」はResourceに向いています。逆に、検索条件によって結果が変わるようなものはToolとして実装するのが自然です。

動作確認

MCP Inspector(@modelcontextprotocol/inspector)を使うと、Claude Desktopなどのホストを用意しなくても、ブラウザ上でサーバーの動作を確認できます。

bunx @modelcontextprotocol/inspector bun src/server.ts

Inspectorを開くと、登録したツール一覧・入力スキーマ・実行結果がそのまま確認できます。inputSchema内のdescriptionの文言がモデルにどう見えるかも、この画面から検証できます。

実装時に注意すべきこと

  • 入力バリデーションはサーバー側の責任として実装する: inputSchemaはモデルへの説明文(ヒント)であり、SDKが実行時の型安全性まで保証してくれるわけではありません。必須項目の型・値の範囲・業務ロジック上の妥当性(存在しないSKUの扱いなど)は、ハンドラ内部で通常のAPIエンドポイントと同じように検証します。
  • 副作用のあるツールは権限スコープを絞る: 在庫を更新するupdate_inventoryのようなツールを追加する場合、サーバーが接続するDBの認証情報自体を、そのツールが必要とする最小権限(例えば特定テーブルのみ更新可能なロール)に絞っておきます。モデル側の判断ミスがあっても、実行環境側の権限で被害範囲を制限できるようにする、という考え方です。
  • エラーはテキストとして返し、例外は投げすぎない: ハンドラ内で例外をthrowすると接続自体がエラーになりやすいため、業務上のエラー(該当データなし、権限不足など)はcontentにエラーメッセージを含めた通常のレスポンスとして返し、モデルがユーザーに説明できるようにします。
  • ツールの数を無闇に増やさない: モデルに渡されるツール一覧はプロンプトの一部としてコンテキストを消費します。似た処理は引数で分岐させて1つのツールにまとめるなど、粒度の設計も実務では重要になります。

本番運用における注意点

ローカルのstdio接続で動作確認ができたら終わり、ではなく、実際に業務で使うサーバーには以下のような観点が必要になります。

認証・アクセス制御

  • stdioとリモート公開で前提が変わる: stdioはローカルプロセス同士の通信であり、OS側のプロセス分離が事実上の境界になるため、通常は追加の認証を必要としません。一方、複数人・複数ホストから使えるようにHTTP経由(Streamable HTTPトランスポート)で公開する場合は話が別で、必ずトークンベースの認証を挟みます。MCPの認可はOAuth 2.1をベースにした仕様が整備されているため、簡易的なAPIキー方式で済ませるのではなく、可能な限りこの仕様に沿った実装にしておくと、後からホスト・クライアントが増えても認証まわりを作り直さずに済みます。
  • 最小権限の原則を徹底する: サーバーが持つDB認証情報やAPIキーは、そのサーバーが提供するツールの範囲に閉じたロールに絞ります。「AIが暴走したら最悪何が起きるか」を、サーバーが握っている権限の大きさで評価する、という考え方です。
  • 人間の確認を挟む: 更新・削除のような破壊的操作は、ホスト側の実行前確認ダイアログに任せきりにせず、重要な操作については確認用のパラメータを別途要求する、あるいは一度「プレビュー」を返してから実行専用の別呼び出しを要求するといった、2段階の設計も検討に値します。

セキュリティ

  • ツールの実行結果を介した間接プロンプトインジェクションに注意する: DBやWeb、外部ファイルなど、信頼できない出どころのデータをそのままcontentとして返すと、そのデータの中に紛れ込んだ指示文をモデルがユーザーからの指示と誤認して実行してしまうリスクがあります。外部由来のテキストは「これはデータであり指示ではない」ことが伝わるように整形する、あるいは実行結果に含める情報を必要最小限に絞るといった対策が有効です。
  • 監査ログを残す: どのホスト・ユーザーが、いつ、どのツールを、どんな引数で呼び出したかを記録します。特に書き込み系のツールでは、後から挙動を追跡できることが前提になります。
  • シークレットをResourcesやツールの出力に混入させない: DB接続文字列やAPIキーをデバッグ目的で返り値に含めたまま本番に出す、といったミスは意外と起こりやすいので、レスポンスを組み立てる箇所を共通化してレビューしやすくしておきます。

パフォーマンス

  • レスポンスサイズを制限する: ツールの返り値が大きすぎると、モデルのコンテキストを圧迫し、後続のやり取りの精度や応答速度に影響します。検索系のツールは既定の件数上限とページネーションを設け、全件を無条件に返さないようにします。
  • タイムアウトを必ず設定する: 下流のDB・外部APIへの呼び出しにタイムアウトを設け、失敗時は例外を投げっぱなしにせず、テキストのエラーとしてcontentに含めて返します。ハンドラが応答しないまま固まると、ホスト側のやり取り全体が止まってしまいます。
  • 同時接続を想定したリソース管理をする: 複数のホスト・セッションから同時に呼ばれる可能性があるため、DBコネクションプールの上限や、同時実行数の制御をサーバー側で持っておきます。
  • 重い処理はキャッシュを検討する: 在庫サマリのように短時間で頻繁に参照されるが更新頻度は低いデータは、Resource側で一定時間キャッシュするなど、毎回フルスキャンしない工夫が有効です。

MCPサーバーの実装自体は、ツール1つであれば数十行程度で完結します。難しいのはプロトコルの実装ではなく、「どの粒度でツールを切るか」「副作用のある処理にどこまで権限を持たせるか」「本番でどこまで認証・監視を作り込むか」という設計判断の部分です。まずはローカルのstdio・読み取り専用のツールから着手し、動作を確認しながら書き込み系のツールや本番向けの認証・監視を段階的に広げていく進め方をおすすめします。