🉐

作りながら学ぶMCPの活用法

に公開

はじめに

MCP(Model Context Protocol)について「LLMがAPIを叩けるようになるプロトコル」という理解で止まっていないだろうか。確かに技術的にはその通りだが、それはMCPの表層に過ぎない。
従来のシステムでAPIを叩く場合、開発者が「このコマンドでこのAPIを呼ぶ」という固定的なロジックを実装する。しかしMCPを使ったLLMは違う。与えられたツールの使い方を自ら考え、試行錯誤し、失敗から学んで戦略を変更し、最終的に目的を達成する。これは単なるAPI連携ではなく、真の意味での「問題解決」である。
この記事は、よくある「MCPサーバーの作り方」といったHelloWorld的な解説ではない。シンプルな数当てゲームを題材に、LLMがどのようにフィードバックから学習して戦略を変えるか、明示的な指示なしに自律的にリトライを繰り返すか、これらを実際に動くコードで目撃してもらうことが目的だ。

Mode1とMode2という2つの異なるフィードバックモードを用意し、LLMがその違いを自ら発見し、適応していく様子を観察することで、MCPが可能にする新しいインタラクションの形を体感してほしい。

そもそもMCPとは

MCP(Model Context Protocol)は、LLMが外部ツールと通信するための標準化されたプロトコルである。2024年11月にAnthropicが発表したこの仕組みは、単なる「AIからAPIを叩ける」という以上の意味を持っている。

MCPの3つの特徴

1. プロセス分離と永続性

  • MCPサーバーは独立したプロセスとして常駐
  • 状態を保持できる(今回の数当てゲームの「秘密の数字」のように)
  • LLMアプリとは標準入出力で通信

2. 標準化された対話

3. 自律的な問題解決

  • LLMは単に指示されたAPIを呼ぶのではない
  • 目的達成のために試行錯誤し、戦略を変更する
  • エラーから学習し、代替手段を模索する

この「自律的な問題解決」こそ、今回の数当てゲームで観察したいポイントだ。

今回作るもの

AIの学習適応能力を検証する数当てゲームを実装する。1〜10の数字を当てるシンプルなゲームだが、2つの異なるフィードバックモードを持つ:

  • mode1: 「正解」か「不正解」のみを返すシンプルなモード
  • mode2: 「それより大きい」「それより小さい」といったヒントを返すモード

実験の鍵: LLMにはモードの詳細を事前に教えない。実際のフィードバックから各モードの特性を推測させ、適切な戦略を自律的に選択させる。

観察できるポイント

  1. 自動リトライ能力: 明示的なループ処理の指示なしに、LLMが自律的に「推測→フィードバック→次の推測」のサイクルを繰り返す

  2. 適応的な戦略変更: フィードバックの種類によって、総当たりから二分探索へと戦略を切り替える

  3. 学習プロセスの可視化: どのように推論し、どのような順序で数字を試すかを追跡可能

  4. 推測能力: 限られた情報から規則性を見出し、最適な戦略を発見する過程

MCPの基礎理解 - 通信の仕組みを知る

通信の全体像

Claude Desktop ←→ MCPサーバー
    │              │
  JSON-RPCで通信    ゲームロジック
    │              │
  stdin/stdout    数字の判定

この構造により、Claude DesktopはMCPサーバーの内部実装を知らなくても、ツールを使用できる。

JSON-RPCプロトコル

MCPは標準入出力を通じてJSON-RPCメッセージを交換する。各メッセージは改行文字で区切られ、以下の形式を持つ:

リクエスト例(ツール一覧取得)

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

レスポンス例(利用可能なツール)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "guess_number",
        "description": "数当てゲームをしましょう...",
        "inputSchema": {...}
      }
    ]
  }
}

ツール実行リクエスト

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "guess_number",
    "arguments": {"number": 5}
  }
}

ツール実行レスポンス

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "不正解"
      }
    ]
  }
}

環境構築

必要なもの

  • Node.js (v16以上)
  • npm
  • MCPクライアント(Claude Desktop AppやClineなど)

プロジェクトのセットアップ

  1. プロジェクトディレクトリを作成:
mkdir mcp-adaptive-game
cd mcp-adaptive-game
  1. package.jsonを作成:
{
  "name": "number-guessing-game",
  "version": "1.0.0",
  "type": "module",
  "main": "number-guessing-game-server.mjs",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0"
  }
}
  1. 依存関係をインストール:
npm install

実装:完成版コード

それでは実際にMCPサーバーを実装していく。まず完成版のコードを示し、その後で各部分を詳しく解説する。

number-guessing-game-server.mjs

#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';

// ゲーム設定(動的に変更可能)
let secretNumber = Math.floor(Math.random() * 10) + 1;  // 1〜10のランダムな数字
let gameMode = 'mode1';  // 'mode1' または 'mode2' (詳細はプレイして推測)
let attemptCount = 0;

// サーバー作成
const server = new Server(
  {
    name: 'number-guessing-game',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

// ツール一覧の処理
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: 'guess_number',
        description: '数当てゲームをしましょう。推測した 1〜10 の整数(秘密の数字)が正しいかどうかをテキストで返します。フィードバック表現は状況によって変化することがあります。',
        inputSchema: {
          type: 'object',
          properties: {
            number: {
              type: 'number',
              description: '1から10の範囲の整数',
              minimum: 1,
              maximum: 10
            }
          },
          required: ['number']
        }
      },
      {
        name: 'reset_game',
        description: 'ゲームの設定をリセットします。数字はランダムに設定されます。',
        inputSchema: {
          type: 'object',
          properties: {
            mode: {
              type: 'string',
              description: 'ゲームモードを選択(mode1 または mode2)。詳細はプレイして推測してください。',
              enum: ['mode1', 'mode2']
            }
          }
        }
      }
    ]
  };
});

// ツール実行の処理
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  // reset_gameツールの処理
  if (request.params.name === 'reset_game') {
    const args = request.params.arguments || {};
    
    // 秘密の数字を常にランダムに設定
    secretNumber = Math.floor(Math.random() * 10) + 1;
    
    // ゲームモードを設定(指定されていない場合は現在のモードを維持)
    if (args.mode) {
      gameMode = args.mode;
    }
    
    // 試行回数をリセット
    attemptCount = 0;
    
    return {
      content: [{
        type: 'text',
        text: `新しいゲームが開始されました。\nモード: ${gameMode}\nモードの詳細はプレイしながら推測してください。`
      }]
    };
  }
  
  if (request.params.name === 'guess_number') {
    const args = request.params.arguments;
    const guessed = args?.number;
    attemptCount++;
    
    // 入力チェック
    if (!guessed || typeof guessed !== 'number') {
      return {
        content: [{ type: 'text', text: '数値を入力してください!' }]
      };
    }
    
    // 境界チェック
    if (guessed < 1 || guessed > 10) {
      return {
        content: [{ type: 'text', text: '1〜10で入力してください!' }]
      };
    }
    
    let response;
    
    // モードによって応答を切り替え(実験の核心)
    if (gameMode === 'mode1') {
      response = guessed === secretNumber ? '正解!' : '不正解';
    } else {
      if (guessed === secretNumber) {
        response = '正解!';
      } else {
        response = guessed > secretNumber ? 'それより小さい' : 'それより大きい';
      }
    }
    
    return {
      content: [{ type: 'text', text: response }]
    };
  }
  
  throw new Error(`Unknown tool: ${request.params.name}`);
});

// サーバー起動
async function run() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

run().catch(() => {});

コード詳細解説

それでは、各部分がどのようにMCPプロトコルと連携しているか、詳しく見ていこう。

1. インポートと初期設定

import 文の機能

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
  • Server
    MCP サーバーのインスタンスを生成するクラス。
    リクエスト種別ごとにハンドラを登録し、受信した JSON-RPC メッセージを対応するハンドラへ振り分ける。

  • StdioServerTransport
    標準入力 / 標準出力経由でメッセージを送受信するトランスポート実装。
    ローカル統合(Claude Desktop など)ではこの経路がデフォルト。

  • CallToolRequestSchema, ListToolsRequestSchema
    tools/calltools/list リクエストの JSON Schema。
    受信データのバリデーションと型補完を提供する。


設定ファイルに書くだけでサーバーが起動・認識される仕組み

  1. 設定読み込み
    Claude Desktop は起動時に claude_desktop_config.jsonmcpServers セクションを走査し、各エントリの commandargs を取得する。

  2. 子プロセスの起動
    取得したコマンド(例: node /path/to/server.mjs)を子プロセスとして spawn する。
    この時点で Node スクリプトが実行され、コード内の

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

    が動き始める。

  3. 接続確立
    Desktop 側は子プロセスの stdin/stdout をパイプとして保持し、起動直後に tools/list を送信。
    サーバーがツール定義を返すと、Desktop はそのセッションを「MCP サーバー」として登録する。

  4. 以後のやり取り
    チャットごとに LLM が必要と判断したとき、Desktop が同じパイプに tools/call リクエストを送り、サーバーはレスポンスを返す。
    子プロセスを終了すればセッションも切れる。

要点は「Desktop が自動で子プロセスを起動し、その標準入出力を MCP 通信路として使う」という一点に集約される。設定ファイルは単にその起動コマンドを宣言しているだけ、という構造。

2. サーバーインスタンスの作成

const server = new Server(
  {
    name: 'number-guessing-game',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

MCP統合ポイント: Serverクラスのインスタンスを作成することで、MCPプロトコルに準拠したサーバーが構築される。capabilitiestoolsを宣言することで、このサーバーがツール機能を提供することをクライアントに伝える。

3. ツール定義(ListToolsRequestSchema)

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: 'guess_number',
        description: '数当てゲームをしましょう。推測した 1〜10 の整数(秘密の数字)が正しいかどうかをテキストで返します。フィードバック表現は状況によって変化することがあります。',
        inputSchema: {
          type: 'object',
          properties: {
            number: {
              type: 'number',
              description: '1から10の範囲の整数',
              minimum: 1,
              maximum: 10
            }
          },
          required: ['number']
        }
      },
      {
        name: 'reset_game',
        description: 'ゲームの設定をリセットします。数字はランダムに設定されます。',
        inputSchema: {
          type: 'object',
          properties: {
            mode: {
              type: 'string',
              description: 'ゲームモードを選択(mode1 または mode2)。詳細はプレイして推測してください。',
              enum: ['mode1', 'mode2']
            }
          }
        }
      }
    ]
  };
});

重要なMCP実装詳細:

  • ListToolsRequestSchemaハンドラーは、LLMがどんなツールが利用可能かを問い合わせた時に応答する
  • inputSchemaはJSON Schemaで定義され、LLMがツールを正しく呼び出すための型情報を提供
  • descriptionの内容がLLMのツール選択と使用方法の判断に直接影響する
  • 「詳細はプレイして推測してください」という記述により、LLMに探索的な行動を促す

LLMが見ているもの:
LLMには上記のツール定義がJSON形式で提供される。特に重要なのは:

  • name: LLMがツールを呼び出す際の識別子
  • description: LLMがツールの用途を理解するための説明文
  • inputSchema: LLMが正しい引数を構築するための型情報

4. ツール実行処理(CallToolRequestSchema)

reset_gameツールの処理

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === 'reset_game') {
    const args = request.params.arguments || {};
    
    // 秘密の数字を常にランダムに設定
    secretNumber = Math.floor(Math.random() * 10) + 1;
    
    // ゲームモードを設定(指定されていない場合は現在のモードを維持)
    if (args.mode) {
      gameMode = args.mode;
    }
    
    // 試行回数をリセット
    attemptCount = 0;
    
    return {
      content: [{
        type: 'text',
        text: `新しいゲームが開始されました。\nモード: ${gameMode}\nモードの詳細はプレイしながら推測してください。`
      }]
    };
  }

MCPとLLMの相互作用:

  • CallToolRequestSchemaハンドラーは実際のツール実行を処理
  • 返答のcontent配列にテキストメッセージを含めることで、LLMに情報を伝達
  • モードの詳細を意図的に説明しないことで、LLMの推測能力を引き出す

LLMが受け取るレスポンス:

{
  "content": [{
    "type": "text",
    "text": "新しいゲームが開始されました。\nモード: mode1\nモードの詳細はプレイしながら推測してください。"
  }]
}

guess_numberツールの処理

  if (request.params.name === 'guess_number') {
    const args = request.params.arguments;
    const guessed = args?.number;
    attemptCount++;
    
    // 入力チェック
    if (!guessed || typeof guessed !== 'number') {
      return {
        content: [{ type: 'text', text: '数値を入力してください!' }]
      };
    }
    
    // 境界チェック
    if (guessed < 1 || guessed > 10) {
      return {
        content: [{ type: 'text', text: '1〜10で入力してください!' }]
      };
    }
    
    let response;
    
    // モードによって応答を切り替え(実験の核心)
    if (gameMode === 'mode1') {
      response = guessed === secretNumber ? '正解!' : '不正解';
    } else {
      if (guessed === secretNumber) {
        response = '正解!';
      } else {
        response = guessed > secretNumber ? 'それより小さい' : 'それより大きい';
      }
    }
    
    return {
      content: [{ type: 'text', text: response }]
    };
  }
  
  throw new Error(`Unknown tool: ${request.params.name}`);
});

実験の核心部分:

  • mode1では単純な「正解/不正解」のフィードバック
  • mode2では「それより大きい/小さい」という方向性を示すフィードバック
  • この違いがLLMの戦略選択に大きく影響する
  • 従来のif文による制御と異なり、LLMは返答の内容を解釈して自律的に次の行動を決定

LLMが受け取るレスポンスの違い:

Mode1の場合:

{
  "content": [{
    "type": "text",
    "text": "不正解"  // または "正解!"
  }]
}

Mode2の場合:

{
  "content": [{
    "type": "text",
    "text": "それより小さい"  // または "それより大きい" または "正解!"
  }]
}

この違いにより、LLMは:

  • Mode1: 総当たり的な探索戦略を採用
  • Mode2: 二分探索的な効率的戦略を採用

5. サーバー起動処理

async function run() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

run().catch(() => {});

MCPトランスポート層:

  • StdioServerTransportは標準入出力を使用してJSON-RPC通信を行う
  • Claude DesktopなどのMCPクライアントはこのトランスポート経由でサーバーと通信

MCPクライアントへの設定

Claude Desktop Appの場合

~/Library/Application Support/Claude/claude_desktop_config.jsonを編集:

{
  "mcpServers": {
    "number-guessing-game": {
      "command": "node",
      "args": ["/absolute/path/to/number-guessing-game-server.mjs"]
    }
  }
}

注意: パスは絶対パスで指定してください。

Clineの場合

VSCodeの設定から、MCP Serversに以下を追加:

{
  "number-guessing-game": {
    "command": "node",
    "args": ["/absolute/path/to/number-guessing-game-server.mjs"]
  }
}

実験方法:MCPの柔軟性を体感する

MCPの動作フローを理解する

実験を始める前に、LLMとMCPサーバーがどのように通信するか理解しよう:

  1. 初期化フェーズ

    • LLMがMCPサーバーに接続
    • tools/listメソッドで利用可能なツールを問い合わせ
    • ツールの名前、説明、入力スキーマを受け取る
  2. 実行フェーズ

    • ユーザーの指示をLLMが解釈
    • 適切なツールを選択し、引数を構築
    • tools/callメソッドでツールを実行
    • 結果のテキストを受け取り、次の行動を決定
  3. 自律的ループ

    • LLMが目的達成まで自動的に2を繰り返す
    • フィードバックから学習し、戦略を調整

実験の準備

  1. MCPサーバーの設定を完了させる
  2. Claude Desktopを再起動して設定を反映
  3. 新しいチャットセッションを開始

実験1:自然言語での初期化

まず、以下のプロンプトを送信する:

数当てゲームのMCPを設定したからやってみよう。まずはゲームを初期化しよう。モード1に設定だ

観察ポイント:

  • 日本語で「モード1」と表現したが、Claudeがmode1として正しく解釈するか
  • reset_gameツールを適切に呼び出すか
  • ゲームの初期化が成功するか

この実験により、MCPが自然言語の指示を柔軟に解釈し、適切なツール呼び出しに変換する様子が観察できる。

実験2:Mode1での自律的な問題解決

初期化後、以下を指示:

じゃあ数当てを試して、正解を導き出してくれ

期待される動作:

  • Claudeが自動的にguess_numberツールを繰り返し呼び出す
  • 「正解」または「不正解」のフィードバックのみで探索
  • 総当たり的なアプローチで1から10まで試行
  • 正解を見つけるまで自律的にリトライ

観察ポイント:

  • 人間が「もう一度試して」と指示しなくても継続するか
  • どのような順序で数字を試すか
  • 何回で正解にたどり着くか


実験3:Mode2への切り替えと戦略変更

Mode1での成功後、以下を指示:

モード2でゲームをリセットしろ


その後:

またプレイしてみよう

期待される動作:

  • reset_gameツールでmode2に切り替え
  • 新しいゲームで「それより大きい/小さい」のフィードバックを受け取る
  • フィードバックの変化に気づき、二分探索的な戦略に切り替える
  • より少ない試行回数で正解を導出


観察ポイント:

  • フィードバックの変化にどれくらい早く気づくか
  • 戦略をどのように変更するか
  • Mode1と比べて試行回数がどれくらい減るか
  • 「このモードは方向性を教えてくれるようだ」といった推測を行うか

実験4:MCPの柔軟性の確認

さらに以下のような実験も可能:

各モードの違いを説明して
どちらのモードが効率的か教えて
10回連続でゲームをプレイして、平均試行回数を比較して

これらの指示により、LLMが:

  • 実際のプレイ経験から学習した内容を整理
  • モードの特性を正確に理解しているか確認
  • 統計的な分析まで自律的に実行

MCPの本質 - なぜこの仕組みが革新的なのか

従来システムとの決定的な違い

1. API設計思想の転換

従来のシステム(REST API、固定コマンド)

# 固定的なエンドポイント
def handle_command(command):
    if command == "mode1":
        return mode1_response()
    elif command == "mode2":
        return mode2_response()
    else:
        return "不明なコマンド"

# または
GET /api/guess?number=5&mode=mode1

問題点

  • 事前定義されたコマンド/エンドポイントのみ対応
  • 「モード1」「最初のモード」といった自然な表現は理解不可
  • 複雑な指示の解釈が困難
  • 文脈を考慮した動作が不可能

MCPのアプローチ

  • 「何をするか」が固定 → 「どう使うか」をLLMが判断
  • 自然言語の多様な表現を理解(「モード1」「簡単な方」「最初のやつ」)
  • 文脈に応じた適切なツール選択
  • 複合的なタスク実行(「10回プレイして統計を出して」)

2. 実行モデルの革新

従来:リクエスト・レスポンスの単発処理
MCP:LLMが自律的に目的達成まで試行錯誤

この数当てゲームでも、「正解を導き出して」という一言で:

  • 自動的にリトライを繰り返す
  • フィードバックから戦略を学習
  • 最適な探索方法を自ら選択

標準化の意味

JSON-RPCプロトコルの採用により、異なる開発者が作ったMCPサーバーでも、同じ方法で利用できる。これがエコシステムの成長を促進する。

応用展開 - MCPの可能性を探る

現在のMCPエコシステムの実態

現在公開されているMCPサーバーの多くは、既存のツールやAPIのラッパーとして実装されている:

  • ファイルシステム操作: OSのファイルAPIをMCP経由で提供
  • データベース接続: SQLクライアントをMCPでラップ
  • Web検索: 検索APIをMCP形式に変換
  • Git操作: gitコマンドをMCPツールとして公開

これがMCPの大きな強みである。既存のサービスやツールをLLMが使えるように「橋渡し」することで、膨大な既存資産を活用できる。開発者は既存のAPIをMCP形式でラップするだけで、LLMがそのサービスを自然言語で操作できるようになる。

今回の数当てゲームは、このような一般的な使い方とは異なり、MCPサーバー自体が独自のロジックと状態を持つ例として実装した。

実用的なMCPサーバーの例

今回の数当てゲームの仕組みを応用すれば、以下のようなツールが作成できる:

ファイル操作ツール

{
  name: 'file_operations',
  description: '指定されたファイルの読み書きを行う',
  inputSchema: {
    type: 'object',
    properties: {
      operation: { enum: ['read', 'write', 'append'] },
      path: { type: 'string' },
      content: { type: 'string' }
    }
  }
}

データベース検索ツール

{
  name: 'database_query',
  description: 'SQLクエリを実行してデータを取得する',
  inputSchema: {
    type: 'object',
    properties: {
      query: { type: 'string' },
      parameters: { type: 'array' }
    }
  }
}

複雑な問題解決への応用

複数のMCPツールを組み合わせることで、LLMはより複雑な問題を解決できる。例えば:

  1. データベースから情報を取得
  2. 外部APIで追加情報を収集
  3. 結果をファイルに保存
  4. レポートを生成

このような一連の作業を、LLMが自律的に計画・実行できるのである。

まとめ:MCPが可能にする新しいインタラクション

このプロジェクトを通じて体験できること:

  1. 柔軟な入力解釈: 日本語の自然な表現をAPIコールに変換
  2. 自律的なループ処理: 明示的なループ命令なしに繰り返し実行
  3. 動的な戦略変更: 状況に応じた最適なアプローチの選択
  4. 経験からの学習: 実行結果から規則性を発見
  5. 複雑なタスク分解: 高レベルの指示を具体的な行動に変換

従来のif文による分岐処理では実現困難だった、真に知的で柔軟なシステムインタラクションがMCPにより可能になる。これはAIアプリケーション開発の新しいパラダイムを示している。

Discussion