作りながら学ぶ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にはモードの詳細を事前に教えない。実際のフィードバックから各モードの特性を推測させ、適切な戦略を自律的に選択させる。
観察できるポイント
-
自動リトライ能力: 明示的なループ処理の指示なしに、LLMが自律的に「推測→フィードバック→次の推測」のサイクルを繰り返す
-
適応的な戦略変更: フィードバックの種類によって、総当たりから二分探索へと戦略を切り替える
-
学習プロセスの可視化: どのように推論し、どのような順序で数字を試すかを追跡可能
-
推測能力: 限られた情報から規則性を見出し、最適な戦略を発見する過程
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など)
プロジェクトのセットアップ
- プロジェクトディレクトリを作成:
mkdir mcp-adaptive-game
cd mcp-adaptive-game
- package.jsonを作成:
{
"name": "number-guessing-game",
"version": "1.0.0",
"type": "module",
"main": "number-guessing-game-server.mjs",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
}
}
- 依存関係をインストール:
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/call
とtools/list
リクエストの JSON Schema。
受信データのバリデーションと型補完を提供する。
設定ファイルに書くだけでサーバーが起動・認識される仕組み
-
設定読み込み
Claude Desktop は起動時にclaude_desktop_config.json
のmcpServers
セクションを走査し、各エントリのcommand
とargs
を取得する。 -
子プロセスの起動
取得したコマンド(例:node /path/to/server.mjs
)を子プロセスとして spawn する。
この時点で Node スクリプトが実行され、コード内のconst transport = new StdioServerTransport(); server.connect(transport);
が動き始める。
-
接続確立
Desktop 側は子プロセスの stdin/stdout をパイプとして保持し、起動直後にtools/list
を送信。
サーバーがツール定義を返すと、Desktop はそのセッションを「MCP サーバー」として登録する。 -
以後のやり取り
チャットごとに LLM が必要と判断したとき、Desktop が同じパイプにtools/call
リクエストを送り、サーバーはレスポンスを返す。
子プロセスを終了すればセッションも切れる。
要点は「Desktop が自動で子プロセスを起動し、その標準入出力を MCP 通信路として使う」という一点に集約される。設定ファイルは単にその起動コマンドを宣言しているだけ、という構造。
2. サーバーインスタンスの作成
const server = new Server(
{
name: 'number-guessing-game',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
MCP統合ポイント: Server
クラスのインスタンスを作成することで、MCPプロトコルに準拠したサーバーが構築される。capabilities
でtools
を宣言することで、このサーバーがツール機能を提供することをクライアントに伝える。
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サーバーがどのように通信するか理解しよう:
-
初期化フェーズ
- LLMがMCPサーバーに接続
-
tools/list
メソッドで利用可能なツールを問い合わせ - ツールの名前、説明、入力スキーマを受け取る
-
実行フェーズ
- ユーザーの指示をLLMが解釈
- 適切なツールを選択し、引数を構築
-
tools/call
メソッドでツールを実行 - 結果のテキストを受け取り、次の行動を決定
-
自律的ループ
- LLMが目的達成まで自動的に2を繰り返す
- フィードバックから学習し、戦略を調整
実験の準備
- MCPサーバーの設定を完了させる
- Claude Desktopを再起動して設定を反映
- 新しいチャットセッションを開始
実験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はより複雑な問題を解決できる。例えば:
- データベースから情報を取得
- 外部APIで追加情報を収集
- 結果をファイルに保存
- レポートを生成
このような一連の作業を、LLMが自律的に計画・実行できるのである。
まとめ:MCPが可能にする新しいインタラクション
このプロジェクトを通じて体験できること:
- 柔軟な入力解釈: 日本語の自然な表現をAPIコールに変換
- 自律的なループ処理: 明示的なループ命令なしに繰り返し実行
- 動的な戦略変更: 状況に応じた最適なアプローチの選択
- 経験からの学習: 実行結果から規則性を発見
- 複雑なタスク分解: 高レベルの指示を具体的な行動に変換
従来のif文による分岐処理では実現困難だった、真に知的で柔軟なシステムインタラクションがMCPにより可能になる。これはAIアプリケーション開発の新しいパラダイムを示している。
Discussion