なんとか頑張ってます!カッツプロダクション代表のカッツ(菅原)です!
さて今回はガッツリAI×Web制作の話です。MCP(Model Context Protocol)をふわっと使ってたのですが、それだけじゃなく「自分でサーバー書いて仕組みから理解する」というのをやってみたので、その学習記録をまとめておきます。
目次
MCPを「なんとなく使えてる」状態から卒業したかった
以前の記事でMCPでマネーフォワードの請求書作成を自動化した話を書いたんですが、あれは既製のMCPサーバーをつないで使っただけなんですよね。便利は便利なんですが、正直「中で何が起きてるか」はブラックボックスのままでした。
で、Web制作の仕事柄PHPを書く機会が多いので、「じゃあ自分でPHPだけでMCPサーバーを一から書いてみたらどうなるんだろう」と思い立ちまして。GeminiとClaude Codeを行ったり来たりしながら、ローカルの学習用リポジトリ(study-mcp)で試行錯誤した記録です。
MCPサーバーの正体を「難しい新技術」から「stdioかHTTPでJSON-RPCをやり取りするだけの、割とシンプルな仕組み」まで解像度を上げること。実際にPHPで動くものを作りながら確認しました!
まずはGeminiさんに教わりながらピュアPHPで組んでみた
最初はNode.jsでechoで”hello”を返すだけの最小構成から
まずはNode.jsと最小構成(echoツールを呼ぶと"hello"が返ってくるだけ)から教えてもらって、MCPサーバーの型を掴みました。ツール一覧を返す処理と、ツールが呼ばれたときの処理を用意して、標準入出力(stdio)でクライアントとつなぐ、というのが基本形ですね。
Node.js版はこんな感じ。
index.js(echoツールを呼ぶとhelloを返すだけ) // ※package.jsonに "type": "module" を追加しておくこと import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; // 1. サーバーの初期化 const server = new McpServer({ name: "simple-echo-mcp", version: "1.0.0" }); // 2. echoツールを登録(呼ばれたらhelloを返す) server.registerTool( "echo", { description: "Returns hello string" }, async () => ({ content: [{ type: "text", text: "hello" }] }) ); // 3. 標準入出力(stdio)で起動 const transport = new StdioServerTransport(); await server.connect(transport);
ツールを1個登録してstdioでつなぐだけ。ツール一覧の返答や初期化まわりの面倒なやり取りは、全部SDKが肩代わりしてくれてます。
PHPでも組み込み関数だけでこれはいけるな!
季節のフルーツを返すサンプルまで進めたところで、「これいろんな言語でいけるよなって思いPHPで書くとどうなるの?」と聞いてみたら、PHPに最初から入ってる関数だけで普通に書けちゃうんですよ(笑)。
MCPのstdio版の正体って、突き詰めると
- 標準入力から1行ずつJSONを読む
- 中身を見て処理する
- 結果をJSONにして標準出力に書き出す
この3つだけなんですよね。PHPでいえばfgets(STDIN)で読んで、json_decodeで配列にして、json_encodeしてechoするだけ。どれも組み込み関数なので、フレームワークもライブラリも一切不要。php-cliさえ入っていれば1ファイルの.phpで完結します。
test.php(ピュアPHPの最小構成イメージ)
<?php
while ($line = fgets(STDIN)) {
$request = json_decode($line, true);
if (!$request) continue;
if ($request['method'] === 'initialize') {
$result = ['protocolVersion' => '2025-06-18',
'capabilities' => ['tools' => new stdClass()],
'serverInfo' => ['name' => 'pure-php', 'version' => '1.0.0']];
} elseif ($request['method'] === 'tools/list') {
$result = ['tools' => [['name' => 'hello', 'description' => 'あいさつを返す',
'inputSchema' => ['type' => 'object', 'properties' => new stdClass()]]]];
} elseif ($request['method'] === 'tools/call') {
$result = ['content' => [['type' => 'text', 'text' => 'Hello from Pure PHP!']]];
} else {
continue; // 通知(notifications/~)などは返事をしない
}
$response = ['jsonrpc' => '2.0', 'id' => $request['id'], 'result' => $result];
echo json_encode($response) . "\n";
}
これ、ターミナルでphp test.phpとだけ打つと対話モード(キーボード入力待ち)になるので、そのまま{"jsonrpc":"2.0","id":1,"method":"tools/list"}みたいなJSONを1行貼り付けてEnterを押せば、同じ画面上でその場で結果が返ってくるんですよ。もちろんecho '...' | php test.phpと1行のコマンドとして流し込んでも同じことができます。Claudeを経由しなくても単体で動作確認できるのが地味に感動しました(=手動でMCPクライアントを再現)。
普段のPHPみたいに$_POSTでリクエストを受け取るわけじゃなく、HTTPを通さず標準入出力で直接やり取りしているというのがポイントですね。
stdioとHTTP、2種類の通信方式の謎がスッキリ!
MCPの公式仕様を見ると、通信方式(トランスポート)は現状stdioとStreamable HTTPの2種類が定義されています。さっきまで触っていたのがstdio方式で、たまに見かけるhttps://...から始まるMCPサーバーはHTTP方式(Streamable HTTP)ということですね。
| 比較項目 | stdio方式(ローカル) | Streamable HTTP方式(リモート) |
|---|---|---|
| 動作場所 | 手元のPC内でプロセスを起動 | クラウドや外部サーバー上 |
| ユーザーの手間 | Node/PHP等の実行環境が必要 | URLを設定に貼るだけ |
| スマホアプリ対応 | プロセス起動できないため不可 | HTTP通信なので可能 |
| 得意な用途 | ローカルファイル操作・CLI実行 | SaaS連携・Web API呼び出し |
なんでわざわざHTTP型が存在するのか、という話ですが、要はユーザー側に環境構築を一切させずに済むのが最大のメリットなんですよね。NotionやSlackみたいなSaaS屋が公式MCPを提供するとき、URLを1本置いておけば世界中の人が環境構築なしでつなげられる。スマホアプリのClaudeからローカルプロセスを起動できないのも、HTTP型が必要とされる理由のひとつです。
Claude Codeに舞台を移して、実際に動くものを作った
Geminiとの壁打ちで仕組みが頭に入ったところで、実際に手を動かすフェーズはClaude Codeに。季節のフルーツ・野菜を返すサンプルを、stdio版(mcp-stdin.php)とHTTP(POST式)版(mcp-post.php)の2本立てにして、学習用リポジトリに持ち込みました。
stdio版(mcp-stdin.php)
<?php
/**
* 季節のフルーツ・野菜を返す PHP MCP サーバー (STDIO版)
*/
// エラー出力が stdout に混ざると JSON-RPC 通信が壊れるため画面表示をオフ
ini_set('display_errors', '0');
error_reporting(E_ALL);
// データ定義
$fruits = [
'はる' => 'さくらんぼ',
'なつ' => 'スイカ',
'あき' => 'なし',
'ふゆ' => 'みかん'
];
$vegetables = [
'はる' => 'なのはな',
'なつ' => 'きゅうり',
'あき' => 'なす',
'ふゆ' => '大根'
];
/**
* 表記の揺れ(「秋」「あき」等)を吸収するための共通変換関数
*/
function normalizeSeason($season) {
$map = [
'春' => 'はる', 'はる' => 'はる',
'夏' => 'なつ', 'なつ' => 'なつ',
'秋' => 'あき', 'あき' => 'あき',
'冬' => 'ふゆ', 'ふゆ' => 'ふゆ',
];
return $map[$season] ?? $season;
}
/**
* JSON-RPC レスポンス送信関数
*/
function sendResponse($response) {
echo json_encode($response, JSON_UNESCAPED_UNICODE) . "\n";
fflush(STDOUT);
}
// 標準入力 (stdin) から JSON-RPC リクエストを 1 行ずつ読み込むループ
while ($line = fgets(STDIN)) {
$line = trim($line);
if (empty($line)) continue;
$request = json_decode($line, true);
if (!$request || !isset($request['method'])) continue;
$method = $request['method'];
$id = $request['id'] ?? null;
// 1. 初期化処理 (initialize)
if ($method === 'initialize') {
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'protocolVersion' => '2024-11-05',
'capabilities' => [
'tools' => new stdClass()
],
'serverInfo' => [
'name' => 'php-seasonal-food-mcp',
'version' => '1.0.0'
]
]
]);
continue;
}
// 2. 初期化完了通知 (notifications/initialized)
if ($method === 'notifications/initialized') {
continue;
}
// 3. 利用可能なツール一覧の返却 (tools/list)
if ($method === 'tools/list') {
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'tools' => [
[
'name' => 'get_seasonal_fruit',
'description' => '指定された季節(はる、なつ、あき、ふゆ)のおすすめフルーツを返します。',
'inputSchema' => [
'type' => 'object',
'properties' => [
'season' => [
'type' => 'string',
'description' => '季節(例:はる、なつ、あき、ふゆ、春、夏、秋、冬)'
]
],
'required' => ['season']
]
],
[
'name' => 'get_seasonal_vegetable',
'description' => '指定された季節(はる、なつ、あき、ふゆ)のおすすめ野菜を返します。',
'inputSchema' => [
'type' => 'object',
'properties' => [
'season' => [
'type' => 'string',
'description' => '季節(例:はる、なつ、あき、ふゆ、春、夏、秋、冬)'
]
],
'required' => ['season']
]
]
]
]
]);
continue;
}
// 4. ツール実行処理 (tools/call)
if ($method === 'tools/call') {
$toolName = $request['params']['name'] ?? '';
$arguments = $request['params']['arguments'] ?? [];
$rawSeason = $arguments['season'] ?? '';
$season = normalizeSeason($rawSeason);
if ($toolName === 'get_seasonal_fruit') {
$resultText = $fruits[$season] ?? '「はる」「なつ」「あき」「ふゆ」のいずれかを指定してください。';
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'content' => [
['type' => 'text', 'text' => $resultText]
]
]
]);
} elseif ($toolName === 'get_seasonal_vegetable') {
$resultText = $vegetables[$season] ?? '「はる」「なつ」「あき」「ふゆ」のいずれかを指定してください。';
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'content' => [
['type' => 'text', 'text' => $resultText]
]
]
]);
} else {
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'error' => [
'code' => -32601,
'message' => 'Tool not found'
]
]);
}
continue;
}
}
HTTP(POST式)版(mcp-post.php)
<?php
/**
* 季節のフルーツ・野菜を返す PHP MCP サーバー (HTTP/POST版)
*/
// エラー出力がレスポンスに混ざると JSON-RPC 通信が壊れるため画面表示をオフ
ini_set('display_errors', '0');
error_reporting(E_ALL);
header('Content-Type: application/json');
// MCP (Streamable HTTP) は POST のみ受け付ける
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
// データ定義
$fruits = [
'はる' => 'さくらんぼ',
'なつ' => 'スイカ',
'あき' => 'なし',
'ふゆ' => 'みかん'
];
$vegetables = [
'はる' => 'なのはな',
'なつ' => 'きゅうり',
'あき' => 'なす',
'ふゆ' => '大根'
];
/**
* 表記の揺れ(「秋」「あき」等)を吸収するための共通変換関数
*/
function normalizeSeason($season) {
$map = [
'春' => 'はる', 'はる' => 'はる',
'夏' => 'なつ', 'なつ' => 'なつ',
'秋' => 'あき', 'あき' => 'あき',
'冬' => 'ふゆ', 'ふゆ' => 'ふゆ',
];
return $map[$season] ?? $season;
}
/**
* JSON-RPC レスポンス送信関数
*/
function sendResponse($response) {
echo json_encode($response, JSON_UNESCAPED_UNICODE);
exit;
}
// リクエストボディ (JSON-RPC) を 1 回だけ読み込んで処理する
{
$body = trim(file_get_contents('php://input'));
$request = json_decode($body, true);
if (!$request || !isset($request['method'])) {
http_response_code(400);
sendResponse([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32700, 'message' => 'Parse error']
]);
}
$method = $request['method'];
$id = $request['id'] ?? null;
// 1. 初期化処理 (initialize)
if ($method === 'initialize') {
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'protocolVersion' => '2024-11-05',
'capabilities' => [
'tools' => new stdClass()
],
'serverInfo' => [
'name' => 'php-seasonal-food-mcp',
'version' => '1.0.0'
]
]
]);
}
// 2. 初期化完了通知 (notifications/initialized)
if ($method === 'notifications/initialized') {
http_response_code(202);
exit;
}
// 3. 利用可能なツール一覧の返却 (tools/list)
if ($method === 'tools/list') {
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'tools' => [
[
'name' => 'get_seasonal_fruit',
'description' => '指定された季節(はる、なつ、あき、ふゆ)のおすすめフルーツを返します。',
'inputSchema' => [
'type' => 'object',
'properties' => [
'season' => [
'type' => 'string',
'description' => '季節(例:はる、なつ、あき、ふゆ、春、夏、秋、冬)'
]
],
'required' => ['season']
]
],
[
'name' => 'get_seasonal_vegetable',
'description' => '指定された季節(はる、なつ、あき、ふゆ)のおすすめ野菜を返します。',
'inputSchema' => [
'type' => 'object',
'properties' => [
'season' => [
'type' => 'string',
'description' => '季節(例:はる、なつ、あき、ふゆ、春、夏、秋、冬)'
]
],
'required' => ['season']
]
]
]
]
]);
}
// 4. ツール実行処理 (tools/call)
if ($method === 'tools/call') {
$toolName = $request['params']['name'] ?? '';
$arguments = $request['params']['arguments'] ?? [];
$rawSeason = $arguments['season'] ?? '';
$season = normalizeSeason($rawSeason);
if ($toolName === 'get_seasonal_fruit') {
$resultText = $fruits[$season] ?? '「はる」「なつ」「あき」「ふゆ」のいずれかを指定してください。';
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'content' => [
['type' => 'text', 'text' => $resultText]
]
]
]);
} elseif ($toolName === 'get_seasonal_vegetable') {
$resultText = $vegetables[$season] ?? '「はる」「なつ」「あき」「ふゆ」のいずれかを指定してください。';
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'content' => [
['type' => 'text', 'text' => $resultText]
]
]
]);
} else {
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'error' => [
'code' => -32601,
'message' => 'Tool not found'
]
]);
}
}
// 未対応メソッド
sendResponse([
'jsonrpc' => '2.0',
'id' => $id,
'error' => ['code' => -32601, 'message' => 'Method not found']
]);
}
※HTTP(POST)版はCURLでこんなコードを貼り付ければテストできます
curl -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' https://localhost[サーバーアドレス]
まず脆弱性チェックをお願いした
「一応チェックしといて」とお願いしたところ、ユーザー入力(季節の文字列)は連想配列のキー参照にしか使っておらずevalやSQL、シェル実行も無いのでその点は問題なし、という結果でした。こういうのをサラッと拾ってもらえるのはAIさまさまですね〜
デバッグの手段をいくつか教えてもらった
ちなみに「楽にMCPデバッグできるアイディアありますか?」と聞いたら、いくつか方法を提示してもらいました。
- MCP Inspector(公式ツール):
npx @modelcontextprotocol/inspector php php/mcp-stdin.phpでWeb UIからtools/listやtools/callを実行できる - stdio版はパイプで直接叩く:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | php php/mcp-stdin.php - HTTP版はcurlで叩く:
php -S localhost:8080で組み込みサーバーを立ててcurl- consoleでajax通信をしてみるアイディアもありますがおすすめしません
- Claude Code自体に接続して確認:
claude mcp addで登録して/mcpから動作確認
実際にstdio版・HTTP版どちらも動作確認できた
stdio版はinitialize→tools/list→tools/call(フルーツ「夏」→スイカ、野菜「あき」→なす)まで一通り正常に動作。存在しないツールを呼んだときはちゃんと-32601のエラーが返ってくることも確認できました。POST版もPHP組み込みサーバーを起動してcurlで叩き、正常にレスポンスが返ってくることを確認済みです。
.mcp.jsonの記述例とプロジェクトディレクト構成
study-mcp ディレクトリ構成 php/ mcp-stdin.php ←stdio版 mcp-post.php ←HTTP(POST)版 .mcp.json ←プロジェクトDIR内mcp設定 //.mcp.jsonへの記述例 STDIOの場合 { "mcpServers": { "seasonal-food": { "command": "php", "args": ["mcp-stdin.php"] } } }
ここのディレクトに入ってClaudeCodeで”seasonal-food 動く?”って質問すると良いと思います
メンンディッシュWordPressの記事・ユーザー一覧を返すMCPサーバーを自作
ここまでで仕組みは理解できたので、本命の「WordPressの記事一覧・ユーザー一覧を返すMCPサーバー」(wp-blog-usr)を作ることにしました。要件はシンプルに、
- 記事リスト・ユーザーリストをJSONで返すツールを持つ
- WPのURL・ユーザー・認証情報は
.env的なもので管理 - HTTP(POST)版とstdio版の両方に対応
という感じで進めました。
認証はApplication Password+Basic認証でステートレスに
「WordPress REST APIってセッション保持しなくても認証だけでいけるの?」と聞いたところ、いけるとのこと。WordPress 5.6から入っているApplication Passwordsという機能を使うと、各リクエストのAuthorization: Basic base64(user:app_password)ヘッダーだけで認証が完結し、Cookieもセッションも発行されません。ただし平文に近い形で認証情報が飛ぶ仕組みなので、本番運用では必ずHTTPS経由にするのが大前提です。
実装としてはwp-mcp-common.phpに.env読み込みとWordPress REST APIの呼び出し処理、get_posts/get_usersのツール定義、JSON-RPCの振り分け処理をまとめて、stdio版とPOST版それぞれのエントリーポイントから呼び出す構成にしました。
ちなみに後から気づいたんですが、/wp/v2/postsは公開記事であればそもそも未認証でも取れる公開APIなんですよね(笑)。ユーザー一覧も、記事を公開したことのあるユーザーの基本情報くらいなら未認証で見えちゃいます。なので「ログインしないと取れない」わけではなく、認証しておけば下書きなどの非公開情報や、権限が必要なユーザー情報まで取りにいける余地がある、というのが正確なところです。
ディレクトリを整理して.mcp.jsonに登録
ファイルが増えてきてphp/直下が煩雑になったので、「季節のフルーツ・野菜サンプル」と「WordPressブログ・ユーザー」で用途別にディレクトリを分割。
study-mcp ディレクトリ構成 \n php/ seasonal-food/ ← 季節フルーツ・野菜サンプル mcp-stdin.php mcp-post.php wp-blog-usr/ ← WordPress記事・ユーザー一覧 wp-mcp-stdin.php wp-mcp-post.php wp-mcp-common.php .env.example
https://github.com/sugawarakatsufumi/study-mcp-public
途中から実際のAIエージェントに組み込みテストなどが混ざってて機密情報が多くなたのでstudy-mcp-publicと分離しました。
Claude Codeのプロジェクト設定である.mcp.jsonにstdio型として両サーバーを登録し、.claude/settings.jsonでenableAllProjectMcpServers: trueにしてプロジェクトMCPサーバーを自動許可。HTTP版(POST)はまだどこのサーバーにも設置していないので、今回は.mcp.jsonには登録していません(設置すればURLで登録できます)。
ここ要注意ポイント
.envには実際のWordPress認証情報が入るので、.gitignoreで確実に除外すること。うっかりコミットしてリモートにpushしてしまう事故、AI活用の現場だと意外とやりがちなので気をつけたいところです(自戒)。
実際にClaude Codeから記事一覧を取ってこれた瞬間
ここまで組んで、実際にClaude Codeのセッションで試したのがこちら。

「記事だせますか?」とだけ聞いたら、get_postsツールが呼ばれて日付付きの記事タイトル一覧がズラッと返ってきました。さつま町のジビエ工房を訪問した記事とかNゲージコントローラー第2弾の記事とか、この記事を書く前段階までの過去記事がちゃんと取れてるのを見て、まさしく動いてるんじゃん!となりました(笑)。
ここで気になったのが、「これってwp-blog-usrって名前を明示的に指定しなくても取得できるんですか?」という点。聞いてみたら、Claude Codeは利用可能なMCPツールの説明文(description)を見て、リクエスト内容に合うものを自動的に選んでくれているとのこと。「記事だせますか?」という質問とget_posts(WordPressサイトの記事一覧を取得します)の説明が一致したので自動選択された、という理屈でした。
ただし、似たような機能のツールが複数のMCPサーバーにまたがって存在する場合はどれを使うか曖昧になることがあるので、そういうときはサーバー名やツール名を明示した方が確実、というアドバイスも添えてもらいました。地味に実用的な知見です。
余談でnpx(JS)ベースのMCPサーバーが多いのはなぜか
ついでに「MCPサーバーってnpx(Node.js)で配布されてるものが多いですよね、なんでですか?」と聞いてみたところ、
- MCPはAnthropicが公開した当初からTypeScript SDKを中心に整備されていて、公式リファレンス実装の多くがnpmパッケージで配布されている
npxの「グローバルインストール不要・一時取得してすぐ実行」というモデルがCLIツールの起動に向いている- Node.jsはOS差分を吸収しやすくクロスプラットフォームで安定しやすい
あたりが理由とのこと。ちなみにPythonにも公式SDK(mcpライブラリ、FastMCPスタイル)があり、@mcp.tool()デコレータを使うと今回PHPで手書きしたJSON-RPCのハンドリングをほぼ肩代わりしてくれるらしく、コードの簡潔さで選ぶならPythonが有利、という結論でした。配布の主流はnpx寄りだけど、書きやすさは別軸、という感じですね。PHPは、、、まあ、車輪の再発明をあえてやる勉強素材としては最高でした!シェルスクリプトでMCPも楽しそう(笑)。
まとめ
今回手を動かしてみて分かったのは、
- MCPサーバーの正体は「stdioかHTTPで、決められた形式(JSON-RPC)のやり取りをするだけ」の割とシンプルな仕組み
- 言語は何でもよく、PHPだけでもライブラリなしで普通に動く
- stdio方式はローカル向き、HTTP方式はリモート向き・環境構築不要というすみ分けがある
- WordPressのApplication Passwordsを使えば、セッション管理なしのBasic認証でREST APIとつなげられる(もちろんHTTPS前提)
- ツールの説明文(description)次第でAI側が自動的に使うツールを選んでくれる
という点です。自分の手でイチから組んでみると、なんとなく使ってた頃とは理解の解像度が全然違いますね。次はこのwp-blog-usrを実際の記事投稿・更新フローに組み込むところまでやってみたいと思ってます。
- BASE(ECのね)のMCPを自作して対話で画像と文章なげて商品一括登録
- wordpressにAIエージェント組み込みでクライアントからの修正依頼メール丸投げで自動修正対応
参考になれば幸いです!ツッコミどころあれば連絡くださいね。AIの困りごとあれば相談にのれますよ!