2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPサーバをPHPで実装する

Posted at

初めに

昨今話題のMCPサーバですが、PHP用のSDKはまだ提供されていません。
PHPで作りたかったので、普段使用しているFlowというWebフレームワークでMCPサーバを実装してみました。

設計

実装前に、MCPサーバの理解も含め、設計をしていきましょう。

MCPサーバの種類

MCPサーバには標準入出力で実行を行うSTDIOと、HTTPで通信を行うSSEの二種類があります。今回はSSEで実装しました。

エンドポイント

MCPサーバは様々な用途のリクエストが飛んできますが、URL自体は全て同じです。
今回はhttp://localhost:8081/sseとなるように実装しました。

リクエスト

MCPクライアントから送られてくるリクエストは大きく分けて5つあります。

  1. MCPクライアントとMCPサーバとの初回疎通確認 (POST)
  2. 疎通確認完了通知 (POST)
  3. MCPサーバのツール一覧取得 (POST)
  4. MCPサーバのツール実行 (POST)
  5. 定期的なヘルスチェック用リクエスト (GET)

POSTは「MCPのライフサイクル処理 & ツール実行」

前述の通り、MCPサーバのURLは一つだけですが、POSTでは様々なリクエストが送られてきます。
そこで、MCPサーバは、リクエストパラメータのmethodの値を見て、どの用途でリクエストされているのかを判定します。

以下はリクエストBodyの例です。tools/listからツール一覧取得用のリクエストであることがわかります。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "cursor": "optional-cursor-value"
  }
}

各リクエストでどんなパラメータが送られてくるかはドキュメントをご覧ください。

GETは「定期的なヘルスチェック用」

MCPサーバには、ヘルスチェックのようなリクエストが定期的に届きます。
このリクエストはGETで来ます。レスポンスの形式は何でもよく、200応答であれば良さそうです。

$ curl -X GET 'http://localhost:8081/sse'

{"status":"OK","message":"MCP server is running"}

実装

実際にMCPサーバを作成していきましょう。
今回作成するのは、「名前からあだ名を生成するMCPサーバ」です。
※MCPクライアントとの疎通確認がメインなので、あだ名は固定値で返します。

今回作成したコードはGitHubに上げましたので、よければご覧ください。

スコープ

今回はプロトコルの理解を目的とし、以下の方針で実装しました。

  • 疎通ができる最小限のMCPサーバを実装する
  • バリデーションやエラーハンドリングなど、詳細な機能は実装しない

環境

・言語:PHP 8.2.17
・フレームワーク:Flow 8.3系

ディレクトリ構成

ディレクトリ構成は以下です。

ディレクトリ構成
flow-mcp
    └ Packages/
         ├ Application/
         |    └ NNHKRNK.MCP/
         |         ├ Classes/
         |         |    └ Controller/
         |         |         └ McpController.php  (1)
         |         └ Configuration/
         |              └ Routes.yaml             (2)
         ├ Framework/
         └ Libraries/

今回は以下のファイルを作成しました。

  • McpController.php
  • Routes.yaml

McpController.php

MCPサーバのエンドポイント実装です。

McpController.php
<?php

namespace NNHKRNK\MCP\Controller;

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\Controller\ActionController;
use Neos\Flow\Mvc\View\JsonView;

class McpController extends ActionController
{

    /**
     * レスポンスをJSON形式で返すためのビュー
     * @Flow\Inject
     * @var \Neos\Flow\Mvc\View\JsonView
     */
    protected $view;

    /**
     * MCPサーバがサポートするメディアタイプ
     */
    protected $supportedMediaTypes = ['text/html', 'application/json', 'application/xml', 'application/yaml', 'text/event-stream'];

    /**
     * MCPサーバが使用するJSON-RPCのバージョン
     */
    const JSON_RPC_VERSION = '2.0';

    /**
     * MCPサーバのメソッド名定数
     */
    const MCP_METHOD_INITIALIZE = 'initialize';
    const MCP_METHOD_NOTIFICATIONS_INITIALIZED = 'notifications/initialized';
    const MCP_METHOD_TOOLS_LIST = 'tools/list';
    const MCP_METHOD_TOOLS_CALL = 'tools/call';
    
    /**
     * ヘルスチェック用エンドポイント
     * MCPクライアントからのヘルスチェックを受け付けるアクション
     * 
     * @return void
     */
    public function healthCheckAction(): void
    {
        $this->view->assign('value', [
            'status' => 'OK',
            'message' => 'MCP server is running',
        ]);
    }

    /**
     * MCPサーバ用エンドポイント.
     *
     * @return void
     */
    public function handleMcpAction(): void
    {
        /**
         * リクエストパラメータを取得
         *
         * @var array{
         *     jsonrpc: string,
         *     id?: int,
         *     method: string,
         *     params?: array<mixed>
         * } $arguments
         */
        $arguments = $this->request->getArguments();

        $response['jsonrpc'] = self::JSON_RPC_VERSION;
        if (array_key_exists('id', $arguments)) {
            $response['id'] = $arguments['id'];
        }

        try {
            $response['result'] = match ($arguments['method']) {
                self::MCP_METHOD_INITIALIZE => $this->initMcp(),
                self::MCP_METHOD_NOTIFICATIONS_INITIALIZED => [],
                self::MCP_METHOD_TOOLS_LIST => $this->listMcpTools(),
                self::MCP_METHOD_TOOLS_CALL => $this->callMcpTool($arguments['params']),
            };
        } catch (\Exception $e) {
            $response['error'] = [
                'code' => $e->getCode(),
                'message' => $e->getMessage(),
            ];
        } finally {
            $this->view->assign('value', $response);
        }
    }

    /**
     * MCPサーバの初期化
     */
    public function initMcp(): array
    {
        return [
            'protocolVersion' => '2025-03-26',
            'capabilities' => [
                'logging' => [],
                'tools' => [
                    'listChanged' => false,
                ],
                'resources' => [],
                'prompts' => [],   
            ],
            'serverInfo' => [
                'name' => 'Naming Server',
                'version' => '1.0.0',
            ],
        ];
    }

    /**
     * MCPツールのリストを返す
     *
     * @return array List of tools
     */
    public function listMcpTools(): array
    {
        return [
            'tools' => [
                [
                    'name' => 'Naming Tool',
                    'description' => '本名からあだ名を生成します.',
                    'inputSchema' => [
                        'type' => 'object',
                        'properties' => [
                            'yourname' => [
                                'type' => 'string',
                                'description' => 'あなたの名前.'
                            ]
                        ],
                        'required' => ['yourname']
                    ],
                ],
            ]
        ];
    }

    /**
     * MCPツールを呼び出す
     *
     * @param array{
     *     name: string,
     *     arguments: array<mixed>,
     * } $params Parameters for the tool call
     * @return array Response from the tool call
     */
    public function callMcpTool(array $params): array
    {
        if ($params['name'] === 'Naming Tool') {
            return [
                'content' => [
                    [
                        'type' => 'text',
                        'text' => 'Your name is アドマイヤベガ.'
                    ],
                ],
                'isError' => false,
            ];
        }

        // ツールがない場合は例外を投げる
        throw new \Exception('Unknown tool: ' . $params['name'], -32602);
    }
}

Routes.yaml

ルーティングの設定です。
作成したActionメソッドをURLに紐づけます。

Routes.yaml
- 
  name: 'Flow welcome screen'
  uriPattern: 'sse'
  defaults:
    '@package':    'NNHKRNK.MCP'
    '@controller': 'MCP'
    '@action':     'healthCheck'
  httpMethods: ['GET']

- 
  name: 'Flow welcome screen'
  uriPattern: 'sse'
  defaults:
    '@package':    'NNHKRNK.MCP'
    '@controller': 'MCP'
    '@action':     'handleMcp'
  httpMethods: ['POST']

動作確認

実際に動作確認していきましょう。まずはFlowを起動させます。

$ ./flow server:run
Server running. Please go to http://127.0.0.1:8081 to browse the application.
[Sun Jun  1 12:26:26 2025] PHP 8.2.17 Development Server (http://127.0.0.1:8081) started

続いて、VScodeでMCPサーバを登録します。settings.jsonに以下のように記述しました。

settings.json
"mcp": {
    "inputs": [],
    "servers": {
        "naming-mcp": {
            "type": "sse",
            "url": "http://localhost:8081/sse"
        }
    }
}

設定後に出てくる「start」を押すと、MCPサーバへの初回疎通確認リクエストが飛びます。
image.png

疎通確認ができると、以下のような表記に変わります。ツールもうまく認識されてそうです。
image.png

最後に、プロンプトから実行できるか試してみます。

プロンプト
あだ名MCPサーバを使って、私「koya」のあだ名をつけてください。

image.png

問題なく実行できました!
あだ名の由来はLLMがよしなに考えてくれるんですね、面白い。

終わりに

今回はPHPでMCPサーバを実装してみました。
MCP自体がシンプルで分かりやすいプロトコルなので、実装もスムーズに進み面白かったです。
今後は細部などの実装もやっていこうと思います。

ここまでご覧いただきありがとうございました!

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?