公開日 2026-04-28

PHPでMCPサーバー実践編(複数Tool + 入力スキーマ + ログ出力)

公式PHP SDKで複数 Tool を持つ MCP サーバーを作り、入力スキーマ・失敗レスポンス・STDERR ログを実装する流れを理解できる。

目次

  1. 1. ゴールと前提
  2. 2. デモ環境を作って SDK を入れる
  3. 3. 設定値・データ・logger を分ける
  4. 設定ファイル
  5. サンプルデータ
  6. 設定を型付きで扱う
  7. STDERR ログ
  8. コードのポイント
  9. 4. 3本の Tool と共有 Repository を実装する
  10. TaskRepository
  11. TaskBoardElements(骨組み)
  12. コードのポイント
  13. 5. 入力スキーマと失敗時レスポンスを整える
  14. PHP 型ヒントと #[Schema] の使い分け
  15. TaskBoardElements(最終版)
  16. server.php
  17. コードのポイント
  18. 6. STDIO server として起動し、STDERR ログを見る
  19. 7. Inspector と Claude Desktop で確認する
  20. Inspector で確認する
  21. Tool ごとの確認観点
  22. Claude Desktop で確認する
  23. 8. 詰まりどころと次の一歩
  24. よくある詰まりどころ
  25. 次に進む方向

公式 PHP SDK を使って、複数 Tool を持つ MCP サーバーを作り、入力スキーマ・失敗レスポンス・STDERR ログまで実装します。入門記事の Tool 1本 から、実務寄りの構成へ広げる記事です。

到達点:

  • list_tasks create_task complete_task の Tool 3本を持つ local MCP server を作る
  • #[Schema]enum min/max format: date uniqueItems などの制約を付ける
  • 業務エラーを ToolCallException で返す
  • STDERR に JSON Lines 風のログを出す
  • 設定値・サンプルデータ・ログを Tool 実装から分離する

transport は入門記事に続き STDIO を使います。 Tool 側の構造を固めておくと、後で transport を増やすときに見直す範囲を絞れます。

この記事で扱わないもの:

  • Streamable HTTP transport
  • ResourcePrompt
  • 外部 API 連携や API キー管理
  • DB への永続化
  • RequestContext を使った client logging / sampling / progress の深掘り
  • リモート公開

執筆時点の公式 README では、PHP SDK は active development、初回 major 前は experimental 扱いです。API が変わる可能性がある前提で読んでください。

1. ゴールと前提

到達する状態:

  • Tool 3本を持つ MCP server を STDIO で起動できる
  • #[Schema] を使って入力制約を Tool に反映できる
  • 業務エラーを ToolCallException で返せる
  • STDERR にログを出し、STDOUT と分離できる
  • 設定・データ・ログの責務を Tool 実装から切り離せる

環境前提は入門記事と同じです。

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop
  • Node.js(Windows 側、Inspector を使う章だけ)

入門記事との違いを整理しておきます。

観点入門記事本記事
Tool 数1本(hello3本(list_tasks create_task complete_task
入力制約PHP 型ヒントのみ#[Schema]enum min/max format 等を追加
エラー処理なしToolCallException で業務エラーを返す
ログなしSTDERR に JSON Lines 風で出力
設定分離なしconfig/app.php + AppConfig で管理
データなしstorage/tasks.json を共有ストアとして使用

「複数 Tool」といっても、数を増やすこと自体が目的ではありません。共有設定・共有データ・入力制約をどう持つかが主題です。

2. デモ環境を作って SDK を入れる

WSL 側のターミナルで、デモ用ディレクトリを作ります。入門記事とは別のディレクトリを使います。

mkdir -p ~/projects/php-mcp-server-practical-demo
cd ~/projects/php-mcp-server-practical-demo
mkdir -p docker/php src config storage
code .
初期ディレクトリ構成

compose.yml を作成します。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    command: ["sleep", "infinity"]

docker/php/Dockerfile を作成します。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

今回も php:8.5-cli を使います。SDK 自体は php ^8.1 を要求するので、PHP の新機能は不要です。

composer.json を作成します。

{
  "name": "example/php-mcp-server-practical-demo",
  "type": "project",
  "require": {},
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

起動して SDK を入れます。

docker compose up -d --build
docker compose exec app composer require mcp/sdk
docker compose exec app composer show mcp/sdk --latest

執筆時点では v0.4.0 が解決されます。オフライン環境では --latest を外して docker compose exec app composer show mcp/sdk で確認してください。

Dockerfile を修正した場合は docker compose up -d --build を再実行してください。 起動に問題があるときは docker compose logs app で確認できます。

3. 設定値・データ・logger を分ける

Tool を増やすとき、パスや定数を Tool 実装に直書きすると、あとで同じ値が複数箇所に散らばります。先に設定・データ・ログの置き場を決めておきます。

設定ファイル

config/app.php を作成します。

<?php

declare(strict_types=1);

return [
    'server' => [
        'name' => 'Task Board Server',
        'version' => '0.1.0',
        'instructions' => 'まず list_tasks で現在の一覧を確認し、必要に応じて create_task と complete_task を使ってください。',
    ],
    'storage' => [
        'tasks_file' => dirname(__DIR__) . '/storage/tasks.json',
    ],
];

サンプルデータ

storage/tasks.json を作成します。

[
  {
    "id": "task-001",
    "title": "README を書く",
    "status": "pending",
    "priority": "high",
    "dueDate": "2026-03-15",
    "tags": ["docs"]
  },
  {
    "id": "task-002",
    "title": "ユニットテストを追加する",
    "status": "pending",
    "priority": "medium",
    "dueDate": "2026-03-20",
    "tags": ["testing", "quality"]
  },
  {
    "id": "task-003",
    "title": "CI パイプラインを設定する",
    "status": "completed",
    "priority": "high",
    "dueDate": "2026-03-10",
    "tags": ["infra"]
  }
]

JSON ファイルへ分けておくと、Inspector と Claude Desktop の両方で同じ状態を再現しやすくなります。状態を初期値へ戻したいときも、このファイルを元に戻すだけです。

設定を型付きで扱う

src/AppConfig.php を作成します。

<?php

declare(strict_types=1);

namespace App;

final class AppConfig
{
    public function __construct(
        public readonly string $serverName,
        public readonly string $serverVersion,
        public readonly string $instructions,
        public readonly string $tasksFile,
    ) {
    }

    public static function fromArray(array $config): self
    {
        return new self(
            serverName: $config['server']['name'],
            serverVersion: $config['server']['version'],
            instructions: $config['server']['instructions'],
            tasksFile: $config['storage']['tasks_file'],
        );
    }
}

設定は server.php と Tool 実装の両方で使います。配列のまま散らさず、薄い value object に寄せておくと型で守れます。

SDK の簡易コンテナはオブジェクト型の依存のみ自動解決でき、built-in 型の必須引数は自動注入できません。AppConfig を object として登録しておけば、Repository 側は AppConfig を受けるだけで済みます。

STDERR ログ

src/JsonStderrLogger.php を作成します。

<?php

declare(strict_types=1);

namespace App;

use Psr\Log\AbstractLogger;
use Stringable;

final class JsonStderrLogger extends AbstractLogger
{
    public function log($level, string|Stringable $message, array $context = []): void
    {
        $entry = [
            'timestamp' => date('c'),
            'level' => (string) $level,
            'message' => (string) $message,
        ];

        if ($context !== []) {
            $entry['context'] = $context;
        }

        $json = json_encode($entry, JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            $json = json_encode([
                'timestamp' => date('c'),
                'level' => 'error',
                'message' => 'Failed to encode log entry',
            ], JSON_UNESCAPED_UNICODE) ?: '{"level":"error","message":"Failed to encode log entry"}';
        }

        fwrite(STDERR, $json . PHP_EOL);
    }
}

STDIO transport では STDOUT が JSON-RPC プロトコル用なので、ログは STDERR へ出します。echovar_dump を混ぜるとプロトコルが壊れます。

logger は PSR-3 の AbstractLogger を継承しています。STDERR に JSON Lines 風で出すと、あとで jqgrep で読み返しやすくなります。

相対パスではなく __DIR__dirname(__DIR__) 基準でファイル位置を組んでください。コンテナ内の /workspace とホスト側のパスがマウントで対応していることを前提にしています。

コードのポイント

fromArray() で配列を型付きオブジェクトへ変換する

    public static function fromArray(array $config): self
    {
        return new self(
            serverName: $config['server']['name'],
            serverVersion: $config['server']['version'],
            instructions: $config['server']['instructions'],
            tasksFile: $config['storage']['tasks_file'],
        );
    }

fromArray()config/app.php の配列を受け取り、named argument で各プロパティに割り当てる。return new self( の行で named argument を使うと、配列キーとプロパティ名の対応が一目でわかり、順番の間違いも防げる。

fwrite(STDERR, ...) でログを JSON Lines 形式で書く

    $json = json_encode($entry, JSON_UNESCAPED_UNICODE);
    if ($json === false) {
        $json = json_encode([
            'timestamp' => date('c'),
            'level' => 'error',
            'message' => 'Failed to encode log entry',
        ], JSON_UNESCAPED_UNICODE) ?: '{"level":"error","message":"Failed to encode log entry"}';
    }

    fwrite(STDERR, $json . PHP_EOL);

json_encodefalse を返すケース(非 UTF-8 文字列など)に備えてフォールバックを持たせている。出力先は STDOUT ではなく STDERR で、STDIO transport の JSON-RPC 通信を汚染しない。

4. 3本の Tool と共有 Repository を実装する

Tool を増やすときは、それぞれの Tool を書くことより、共有データをどこで読むか・更新するかを先に決めておくほうが重要です。Repository を 1つ噛ませると、Tool 側は MCP の入出力に集中できます。

TaskRepository

src/TaskRepository.php を作成します。

<?php

declare(strict_types=1);

namespace App;

use Psr\Log\LoggerInterface;

final class TaskRepository
{
    /** @var list<array<string, mixed>> */
    private array $tasks;

    public function __construct(
        private readonly AppConfig $config,
        private readonly LoggerInterface $logger,
    ) {
        $this->tasks = $this->load();
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function findAll(string $status = 'all', int $limit = 10): array
    {
        $tasks = $this->tasks;

        if ($status !== 'all') {
            $tasks = array_values(
                array_filter(
                    $tasks,
                    static fn (array $task): bool => $task['status'] === $status
                )
            );
        }

        $tasks = array_slice($tasks, 0, $limit);

        $this->logger->info('Repository findAll', [
            'status' => $status,
            'limit' => $limit,
            'count' => count($tasks),
        ]);

        return $tasks;
    }

    /**
     * @param list<string> $tags
     * @return array<string, mixed>
     */
    public function create(string $title, string $priority = 'medium', ?string $dueDate = null, array $tags = []): array
    {
        foreach ($this->tasks as $task) {
            if ($task['status'] === 'pending' && trim($task['title']) === trim($title)) {
                throw new \RuntimeException('同名の未完了タスクが既に存在します');
            }
        }

        $task = [
            'id' => $this->nextTaskId(),
            'title' => $title,
            'status' => 'pending',
            'priority' => $priority,
            'dueDate' => $dueDate,
            'tags' => array_values($tags),
        ];

        $this->tasks[] = $task;
        $this->save();

        $this->logger->info('Repository create', ['task_id' => $task['id']]);

        return $task;
    }

    /**
     * @return array<string, mixed>
     */
    public function complete(string $taskId): array
    {
        foreach ($this->tasks as $index => $task) {
            if ($task['id'] !== $taskId) {
                continue;
            }

            if ($task['status'] === 'completed') {
                throw new \RuntimeException('このタスクは既に完了済みです');
            }

            $this->tasks[$index]['status'] = 'completed';
            $this->save();

            $this->logger->info('Repository complete', ['task_id' => $taskId]);

            return $this->tasks[$index];
        }

        throw new \RuntimeException("Task not found: {$taskId}");
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function load(): array
    {
        $json = file_get_contents($this->config->tasksFile);
        if ($json === false) {
            throw new \RuntimeException("Cannot read: {$this->config->tasksFile}");
        }

        /** @var list<array<string, mixed>> $tasks */
        $tasks = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

        return $tasks;
    }

    private function save(): void
    {
        $json = json_encode($this->tasks, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            throw new \RuntimeException('Failed to encode tasks.json');
        }

        file_put_contents($this->config->tasksFile, $json . PHP_EOL);
    }

    private function nextTaskId(): string
    {
        $max = 0;
        foreach ($this->tasks as $task) {
            if (preg_match('/^task-(\d{3})$/', (string) $task['id'], $matches) === 1) {
                $max = max($max, (int) $matches[1]);
            }
        }

        return sprintf('task-%03d', $max + 1);
    }
}

findAll() create() complete() が、それぞれ Tool から呼ばれるメソッドです。 JSON ファイルの読み書きはすべて Repository に閉じています。

TaskBoardElements(骨組み)

src/TaskBoardElements.php を作成します。 #[Schema]ToolCallException を入れる前の骨組みです。

<?php

declare(strict_types=1);

namespace App;

use Mcp\Capability\Attribute\McpTool;
use Psr\Log\LoggerInterface;

final class TaskBoardElements
{
    public function __construct(
        private readonly TaskRepository $repository,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * @return array{status: string, count: int, tasks: list<array<string, mixed>>}
     */
    #[McpTool(name: 'list_tasks', description: 'タスク一覧を取得する')]
    public function listTasks(string $status = 'all', int $limit = 10): array
    {
        $this->logger->info('Tool list_tasks called', ['status' => $status, 'limit' => $limit]);

        $tasks = $this->repository->findAll($status, $limit);

        return [
            'status' => $status,
            'count' => count($tasks),
            'tasks' => $tasks,
        ];
    }

    /**
     * @param list<string> $tags
     * @return array{message: string, task: array<string, mixed>}
     */
    #[McpTool(name: 'create_task', description: '新しいタスクを作成する')]
    public function createTask(
        string $title,
        string $priority = 'medium',
        ?string $dueDate = null,
        array $tags = [],
    ): array {
        $this->logger->info('Tool create_task called', ['title' => $title, 'priority' => $priority]);

        $task = $this->repository->create($title, $priority, $dueDate, $tags);

        return [
            'message' => 'タスクを作成しました',
            'task' => $task,
        ];
    }

    /**
     * @return array{message: string, task: array<string, mixed>}
     */
    #[McpTool(name: 'complete_task', description: '指定したタスクを完了にする')]
    public function completeTask(string $taskId): array
    {
        $this->logger->info('Tool complete_task called', ['task_id' => $taskId]);

        $task = $this->repository->complete($taskId);

        return [
            'message' => 'タスクを完了にしました',
            'task' => $task,
        ];
    }
}

3 つの Tool は constructor injection で同じ TaskRepository を受けます。この時点では schema 制約を入れず、まず Tool の責務を固めています。

ここでは戻り値を json_encode() していません。SDK は配列を返すと、自動的に text content と structuredContent へ整形してくれます。あとで Inspector で見ると、この違いが分かります。

#[McpTool]namedescription を渡すと、Inspector や Claude Desktop でその名前と説明が表示されます。name を省略すると method 名がそのまま Tool 名になりますが、MCP の慣例では snake_case が多いため明示しています。

composer dump-autoload を実行しておいてください。

docker compose exec app composer dump-autoload
docker compose exec app php -l /workspace/server.php
docker compose exec app php -l /workspace/src/TaskRepository.php
docker compose exec app php -l /workspace/src/TaskBoardElements.php

コードのポイント

① constructor で tasks を一括ロードする

    public function __construct(
        private readonly AppConfig $config,
        private readonly LoggerInterface $logger,
    ) {
        $this->tasks = $this->load();
    }

$this->tasks = $this->load() を constructor 内で呼ぶことで、インスタンス化と同時に JSON ファイルをメモリへ読み込む。Tool から複数回 findAll() を呼んでも、ファイル I/O は起動時の 1 回だけで済む。

② 見つかったタスクをその場で更新して即時保存する

            $this->tasks[$index]['status'] = 'completed';
            $this->save();

            $this->logger->info('Repository complete', ['task_id' => $taskId]);

            return $this->tasks[$index];

$this->tasks[$index]['status'] = 'completed' でインメモリの配列を直接書き換え、直後の $this->save() で JSON ファイルへ反映する。save より先に return すると変更がファイルに残らないため、この順番は変えない。

5. 入力スキーマと失敗時レスポンスを整える

骨組みができたので、#[Schema]ToolCallException を入れて Tool を仕上げます。

PHP 型ヒントと #[Schema] の使い分け

PHP の型ヒントだけで足りる箇所と、#[Schema] で補強が必要な箇所があります。

制約PHP 型ヒントで足りるか#[Schema] が必要か
型(string int bool足りる不要
null 許容(?string足りる不要
デフォルト値足りる不要
enum(選択肢の制限)足りない必要
数値の範囲(minimum / maximum足りない必要
文字列の長さ(minLength / maxLength足りない必要
日付形式(format: date足りない必要
配列の一意性(uniqueItems足りない必要
正規表現パターン(pattern足りない必要

#[Schema] を付けすぎると Tool 定義が読みにくくなるので、client 側の入力フォームに反映される制約だけへ絞ります。

TaskBoardElements(最終版)

src/TaskBoardElements.php を次の内容に置き換えます。

<?php

declare(strict_types=1);

namespace App;

use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Psr\Log\LoggerInterface;

final class TaskBoardElements
{
    public function __construct(
        private readonly TaskRepository $repository,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * @return array{status: string, count: int, tasks: list<array<string, mixed>>}
     */
    #[McpTool(name: 'list_tasks', description: 'タスク一覧を取得する')]
    public function listTasks(
        #[Schema(enum: ['all', 'pending', 'completed'], description: '絞り込むステータス')]
        string $status = 'all',
        #[Schema(minimum: 1, maximum: 50, description: '取得件数の上限(既定値: 10)')]
        int $limit = 10,
    ): array {
        $this->logger->info('Tool list_tasks called', ['status' => $status, 'limit' => $limit]);

        $tasks = $this->repository->findAll($status, $limit);

        return [
            'status' => $status,
            'count' => count($tasks),
            'tasks' => $tasks,
        ];
    }

    /**
     * @param list<string> $tags
     * @return array{message: string, task: array<string, mixed>}
     */
    #[McpTool(name: 'create_task', description: '新しいタスクを作成する')]
    public function createTask(
        #[Schema(minLength: 1, maxLength: 100, description: 'タスクのタイトル')]
        string $title,
        #[Schema(enum: ['high', 'medium', 'low'], description: '優先度')]
        string $priority = 'medium',
        #[Schema(format: 'date', description: '期限(YYYY-MM-DD)')]
        ?string $dueDate = null,
        #[Schema(
            type: 'array',
            items: ['type' => 'string', 'minLength' => 1, 'maxLength' => 20],
            uniqueItems: true,
            maxItems: 5,
            description: 'タグ(最大5件、重複不可)'
        )]
        array $tags = [],
    ): array {
        $this->logger->info('Tool create_task called', ['title' => $title, 'priority' => $priority]);

        if ($dueDate !== null && !$this->isValidDate($dueDate)) {
            throw new ToolCallException("無効な日付形式です: {$dueDate}");
        }

        try {
            $task = $this->repository->create($title, $priority, $dueDate, $tags);
        } catch (\RuntimeException $e) {
            throw new ToolCallException($e->getMessage(), previous: $e);
        }

        return [
            'message' => 'タスクを作成しました',
            'task' => $task,
        ];
    }

    /**
     * @return array{message: string, task: array<string, mixed>}
     */
    #[McpTool(name: 'complete_task', description: '指定したタスクを完了にする')]
    public function completeTask(
        #[Schema(pattern: '^task-\\d{3}$', description: 'タスクID(例: task-001)')]
        string $taskId,
    ): array {
        $this->logger->info('Tool complete_task called', ['task_id' => $taskId]);

        try {
            $task = $this->repository->complete($taskId);
        } catch (\RuntimeException $e) {
            throw new ToolCallException($e->getMessage(), previous: $e);
        }

        return [
            'message' => 'タスクを完了にしました',
            'task' => $task,
        ];
    }

    private function isValidDate(string $value): bool
    {
        $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);

        return $date !== false && $date->format('Y-m-d') === $value;
    }
}

骨組みからの変更点は次の 3 つです。

1. #[Schema] で入力制約を追加

  • list_tasks: statusenum で 3 択に絞り、limitminimum: 1 / maximum: 50 を設定
  • create_task: title に長さ制約、priorityenumdueDateformat: datetagsuniqueItemsmaxItems
  • complete_task: taskIdpatterntask-001 形式を強制

Inspector で Tool を開くと、enum はドロップダウンに反映されます。format: date は client によって見え方が異なり、Inspector v0.21.1 では null 切り替え付きの文字列入力欄として表示されました。

2. ToolCallException で業務エラーを返す

ToolCallException は「入力型が違う」より、「その操作は受け付けない」を返す用途に向いています。ここでは次のようなケースで使っています。

  • create_task: 同名の未完了タスクが既にある
  • complete_task: 指定したタスクが存在しない
  • complete_task: 既に完了済みのタスクをもう一度完了しようとした

通常の \RuntimeException をそのまま投げると、client 側では汎用的な JSON-RPC error になります。ToolCallException に変換すると、client はそのメッセージを isError: true の Tool 結果として扱えます。

3. schema 制約があっても domain validation を消さない

#[Schema(format: 'date')] を付けても、それは JSON Schema 上のヒントです。server 側で DateTimeImmutable::createFromFormat() による検証を残しておくと、schema を通過した入力でも安全に処理できます。

server.php

プロジェクトのルートに server.php を作成します。

<?php

declare(strict_types=1);

require_once __DIR__ . '/vendor/autoload.php';

use App\AppConfig;
use App\JsonStderrLogger;
use Mcp\Capability\Registry\Container;
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
use Psr\Log\LoggerInterface;

$config = AppConfig::fromArray(require __DIR__ . '/config/app.php');
$logger = new JsonStderrLogger();

$container = new Container();
$container->set(AppConfig::class, $config);
$container->set(LoggerInterface::class, $logger);

$server = Server::builder()
    ->setServerInfo($config->serverName, $config->serverVersion)
    ->setInstructions($config->instructions)
    ->setContainer($container)
    ->setLogger($logger)
    ->setDiscovery(__DIR__, ['src'])
    ->build();

$logger->info('Server starting', [
    'name' => $config->serverName,
    'version' => $config->serverVersion,
]);

$status = $server->run(new StdioTransport(logger: $logger));

$logger->info('Server stopped', ['status' => $status]);

exit($status);
実装ファイルがそろった状態

ポイントを整理します。

setContainer($container)

attribute discovery で見つかった TaskBoardElements をインスタンス化するとき、constructor dependency の解決にコンテナを使います。今回の簡易コンテナは auto-wire できるので、AppConfigLoggerInterface を登録しておけば TaskRepositoryTaskBoardElements までたどれます。

setLogger($logger)

Server builder に PSR-3 logger を渡します。SDK 内部の discovery や handler 実行ログも、この logger に流れます。

StdioTransport(logger: $logger)

Transport にも同じ logger を渡します。こうすると、server 内部と transport 層のログが同じ出力先(STDERR)へそろいます。

setInstructions(...)

client に対して、Tool の使い方ヒントを短く伝えます。Claude Desktop ではこのテキストが Tool 選択の判断材料になります。長い説明を入れる場所ではなく、短いガイド程度に留めてください。

setDiscovery(__DIR__, ['src'])

src/ ディレクトリを走査して、#[McpTool] が付いたメソッドを自動で Tool として登録します。attribute discovery を使うなら、この設定は必須です。

コードのポイント

#[Schema] でパラメーターに制約を付ける

    #[McpTool(name: 'list_tasks', description: 'タスク一覧を取得する')]
    public function listTasks(
        #[Schema(enum: ['all', 'pending', 'completed'], description: '絞り込むステータス')]
        string $status = 'all',
        #[Schema(minimum: 1, maximum: 50, description: '取得件数の上限(既定値: 10)')]
        int $limit = 10,
    ): array {

#[Schema] はパラメーターごとに付ける。enum を指定すると Inspector のドロップダウンに反映され、minimum / maximum は数値入力の上下限として機能する。PHP 型ヒントでは表現できない「値の範囲」を補う用途に使う。

ToolCallException で業務エラーを client へ伝える

        try {
            $task = $this->repository->create($title, $priority, $dueDate, $tags);
        } catch (\RuntimeException $e) {
            throw new ToolCallException($e->getMessage(), previous: $e);
        }

\RuntimeException をそのまま投げると汎用 JSON-RPC error になる。ToolCallException に変換すると client 側で isError: true の Tool 結果として扱われ、エラーメッセージがそのまま返る。

#[Schema(format: 'date')] があっても server 側の検証を残す

        $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);

        return $date !== false && $date->format('Y-m-d') === $value;

createFromFormat('!Y-m-d', $value)! は時分秒をゼロにリセットするフラグ。$date->format('Y-m-d') === $value の比較で 2026-02-31 のような溢れ日付(PHP が自動繰り上げする)を弾く。#[Schema(format: 'date')] は JSON Schema 上のヒントであり、server 側の入力検証を代替しない。

④ コンテナへの登録と builder への渡し方

$config = AppConfig::fromArray(require __DIR__ . '/config/app.php');
$logger = new JsonStderrLogger();

$container = new Container();
$container->set(AppConfig::class, $config);
$container->set(LoggerInterface::class, $logger);

$server = Server::builder()
    ->setServerInfo($config->serverName, $config->serverVersion)
    ->setInstructions($config->instructions)
    ->setContainer($container)
    ->setLogger($logger)
    ->setDiscovery(__DIR__, ['src'])
    ->build();

AppConfig::classLoggerInterface::class の 2 つを登録しておくと、SDK の簡易コンテナが TaskRepositoryTaskBoardElements の constructor を自動解決する。setContainer($container) が discovery 後のインスタンス化に使われ、setLogger($logger) は SDK 内部ログの出力先になる。

6. STDIO server として起動し、STDERR ログを見る

実装が終わったので、Inspector へ渡す前にまず素で起動します。boot error がないことを先に確認しておくと、client 側の問題と切り分けやすくなります。

docker compose exec -T app php /workspace/server.php

-T は疑似 TTY を割り当てないオプションです。 STDIO 連携では TTY が入ると protocol に干渉するため、付けておきます。

起動すると、server は待受に入り、目立った出力なしで止まって見えます。それで正常です。

STDERR へのログ出力を確認するには、shell 側でリダイレクトします。

ターミナル A:

docker compose exec -T app php /workspace/server.php 2>storage/mcp-server.log

ホスト側の storage/ にログを書き出すので、WSL 側の作業ディレクトリで実行し、storage/ に書き込みできることを先に確認してください。

ターミナル B:

tail -f storage/mcp-server.log

次のような起動ログが出ていれば、logger は動いています。

{"timestamp":"2026-03-09T00:31:41+00:00","level":"info","message":"Attribute discovery finished.","context":{"duration_sec":0.007,"tools":3,"resources":0,"prompts":0,"resourceTemplates":0}}
{"timestamp":"2026-03-09T00:31:41+00:00","level":"info","message":"Server starting","context":{"name":"Task Board Server","version":"0.1.0"}}
{"timestamp":"2026-03-09T00:31:41+00:00","level":"info","message":"StdioTransport is listening for messages on STDIN..."}

Processing fileProtocol connected to transport のような SDK 内部ログが間に入ることもあります。Tool create_task called のようなアプリ側ログは、Inspector などから Tool を実行した後に追加されます。

タイムスタンプはコンテナ側の timezone に従うので、+00:00 でも問題ありません。

STDERR ログ出力の例

STDOUT は JSON-RPC のプロトコル通信用、STDERR は server 側の診断用です。この役割分担を崩さないようにしてください。

止めるときは Ctrl+C です。

もしここで即座に終了するなら、次を見直してください。

  • server.phpsetDiscovery(__DIR__, ['src'])src を指定しているか
  • echo var_dump print_r が残っていないか
  • composer dump-autoload を実行したか

7. Inspector と Claude Desktop で確認する

Inspector で確認する

ここからは Windows 側の PowerShell で実行します。

# <your-user> は自身の WSL ユーザー名に置換してください
npx -y @modelcontextprotocol/inspector wsl.exe -d Ubuntu -- docker compose --project-directory /home/<your-user>/projects/php-mcp-server-practical-demo -f /home/<your-user>/projects/php-mcp-server-practical-demo/compose.yml exec -T app php /workspace/server.php

--project-directory-f には WSL 側の絶対パスを指定してください。

Inspector を npx -y @modelcontextprotocol/inspector だけで起動して、あとから UI で入力する場合は次のように設定します。

  • Transport Type: STDIO
  • Command: wsl.exe
  • Arguments: -d Ubuntu -- docker compose --project-directory /home/<your-user>/projects/php-mcp-server-practical-demo -f /home/<your-user>/projects/php-mcp-server-practical-demo/compose.yml exec -T app php /workspace/server.php
Inspector の Tools タブ

接続後、Tools タブを開くと list_tasks create_task complete_task の 3つが見えるはずです。

Tool ごとの確認観点

Tool確認すること
list_tasksstatus がドロップダウン(all / pending / completed)になっている。limit に上下限がある。戻り値に counttasks が入る。
create_taskpriority がドロップダウン(high / medium / low)になっている。dueDatenull 切り替え付き入力欄として表示される。tags が配列として送れる。
complete_tasktaskId の入力欄がある。task-001 を入れると完了になる。task-003 のような既完了タスクでは ToolCallException のメッセージが返る。
Inspector の create_task フォーム

list_tasksstatus=pending limit=2 で呼ぶと、結果には content に加えて structuredContent も付きます。配列を返したとき、SDK が自動でその形に整えてくれるためです。

Inspector の structuredContent 表示

complete_tasktask-003 を入れると、result.isError: true で「このタスクは既に完了済みです」が返ります。ここが generic JSON-RPC error と違うところです。

Inspector の complete_task エラー表示

Claude Desktop で確認する

Inspector で動作を確認したら、同じ起動コマンドを Claude Desktop の mcpServers 設定に移せます。 Claude Desktop は Inspector のように GUI で細かく設定するのではなく、claude_desktop_config.json を直接編集する形です。 Windows では C:\Users\<your-user>\AppData\Roaming\Claude\claude_desktop_config.json を確認してください。 MSIX 版では C:\Users\<your-user>\AppData\Local\Packages\<Claude の PackageFamilyName>\LocalCache\Roaming\Claude\claude_desktop_config.json を使います。PackageFamilyName は PowerShell で Get-AppxPackage Claude | Select-Object PackageFamilyName を実行すると確認できます。 保存したら Claude Desktop を再起動してください。

{
  "mcpServers": {
    "task-board": {
      "type": "stdio",
      "command": "wsl.exe",
      "args": [
        "-d", "Ubuntu", "--",
        "docker", "compose",
        "--project-directory", "/home/<your-user>/projects/php-mcp-server-practical-demo",
        "-f", "/home/<your-user>/projects/php-mcp-server-practical-demo/compose.yml",
        "exec", "-T", "app", "php", "/workspace/server.php"
      ]
    }
  }
}

再起動後は カスタマイズ -> コネクタ を開き、task-boardLOCAL DEV として表示されること、必要なら Tool ごとの権限を設定できることを確認します。 Claude Desktop のコネクタ一覧

Claude Desktop では、Inspector とは違う観点で確認します。

  • task-board の list_tasks を使って のように明示したとき、意図した Tool が確実に選ばれるか
  • 自然言語で「タスク一覧を見せて」と言ったとき、ほかのコネクタではなく task-board が選ばれるか
  • 「新しいタスクを作って」と言ったとき、create_task に適切な引数が渡されるか
  • 3 つの Tool が並んでいるとき、setInstructions() のヒントが Tool 選択に影響しているか
Claude Desktop で list_tasks を使った例

Inspector は schema の見え方と失敗レスポンスの確認に使い、Claude Desktop は「複数 Tool が並んだときの使われ方」を見る、という使い分けです。

client から接続できない場合は、6章の手動起動に戻って boot error がないか確認してください。 wsl.exe の distro 名やプロジェクトパスは、自分の環境に合わせて読み替えてください。

8. 詰まりどころと次の一歩

よくある詰まりどころ

症状まず見る場所
Tool が 1つも見つからないsetDiscovery(__DIR__, ['src'])server.php に書いているか。composer dump-autoload を実行したか。
STDOUT に余計な出力が出るecho var_dump print_r が Tool 実装や server.php に残っていないか。
schema を入れたのに client で反映されないuse Mcp\Capability\Attribute\Schema が漏れていないか。Inspector を再接続したか。
JSON ファイルが読めないstorage/tasks.json のパスが config/app.php と一致しているか。コンテナ内のファイル権限を確認。
ToolCallException が汎用エラーになる\RuntimeException を直接投げていないか。ToolCallException に変換しているか。
constructor injection が解決されないsetContainer($container) を入れたか。AppConfigLoggerInterface をコンテナへ登録したか。
Inspector で接続できない6章の手動起動でまず boot error を切り分ける。

schema 制約を入れたからといって、domain validation を削ってはいけません。#[Schema(format: 'date')] は JSON Schema 上のヒントであって、server 側の入力検証を代替するものではありません。

Inspector と Claude Desktop で使う起動コマンドは、同じものをそのまま流用できます。環境ごとの差分は distro 名とプロジェクトパスだけです。

次に進む方向

この記事で作った構成をもとに、次のように広げられます。

  • HTTP transport: STDIO から Streamable HTTP に切り替えて、リモートからアクセスできる server にする
  • Resource / Prompt: Tool だけでなく、データソースやプロンプトテンプレートを MCP の仕様に沿って提供する
  • client logging: RequestContext を使って、server 側ではなく client 側にログを送る
  • DB 置き換え: JSON ファイルを MySQL や SQLite に置き換えて、永続化層を入れ替える

Tool が増えても進めやすくするには、設定をどこに置くか、共有データをどこで扱うか、失敗をどう返すかを先にそろえておくことが大切です。 設定分離・共有データの Repository 集約・入力制約と業務エラーの明示は、transport や永続化層を変えてもそのまま残ります。

入門記事では Tool 1本を動かすところまでを扱いました。 本記事ではそこに schema・エラー・ログ・設定分離を加えています。 ここまで押さえておくと、PHP で MCP server を広げていくときに、どこから手を付ければよいか見通しを持ちやすくなります。

シリーズ 2/2

このシリーズ

PHPでMCPサーバーを作る

  1. 1. PHPでMCPサーバー入門(公式PHP SDK + STDIO)
  2. 2. PHPでMCPサーバー実践編(複数Tool + 入力スキーマ + ログ出力) 現在の記事