MCPサーバをPHPで実装する

# 初めに
昨今話題の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`からツール一覧取得用のリクエストであることがわかります。

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

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

https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle

https://modelcontextprotocol.io/specification/2025-03-26/server/tools


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

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

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



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

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

https://github.com/nnhkrnk/flow-mcp

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

* 疎通ができる**最小限の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サーバのエンドポイント実装です。

```php: 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に紐づけます。

```yaml: 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に以下のように記述しました。
```json: settings.json
"mcp": {
    "inputs": [],
    "servers": {
        "naming-mcp": {
            "type": "sse",
            "url": "http://localhost:8081/sse"
        }
    }
}
```

設定後に出てくる「start」を押すと、MCPサーバへの初回疎通確認リクエストが飛びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430841/626d6fe8-2e60-4035-a3d2-609eacdfed5b.png)

疎通確認ができると、以下のような表記に変わります。ツールもうまく認識されてそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430841/fad99738-d0b1-4877-8516-f3f5ab165fc9.png)


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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430841/f93e8fdc-2fb1-42ba-b306-ec59a292af5a.png)

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

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

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