はじめに
現在、PHP用のMCPサーバSDKの開発が進められており、Exampleもいくつかできているようです。
以前以下の記事で動作確認を行ってみました。
今回の記事は、MCPサーバの作りについて調べてみたのでまとめてみようと思います。
MCPサーバの基礎知識
まずはMCPサーバの基礎知識についてです。
MCPサーバは、JSON形式のリクエストを受け取り、内部に登録されたツール(Tool)をLLMが利用できる形で提供します。
MCPクライアントは、サーバからツールの一覧を取得し、必要に応じて特定のツールを実行します。
たとえば、以下のJSONはツール greet を呼び出すリクエストの例です。
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "greet"
}
}
以前以下の記事で少しまとめたので、よければご覧ください。
(おさらい)PHPのMCPサーバSDKの利用方法
前回の記事ではMCPサーバSDKを利用して実際にMCPサーバを作成してみました。
MCPサーバを作成するためには、以下の二つのファイルを用意しました。
- MCPサーバのツールを定義するクラス
- MCPサーバのエントリーポイント
Project
├ src
| └ GreetingMcpElements.php ← 利用可能なツールを定義
├ vendor
| └ mcp/sdk
├ composer.json
└ server.php ← MCPサーバのエントリーポイント
<?php
declare(strict_types=1);
namespace Koya\McpSdk;
use Mcp\Capability\Attribute\McpTool;
class GreetingMcpElements
{
#[McpTool(name: 'greet', description: 'Greets a user in Japanese')]
public function greet(string $name): string
{
return "こんにちは!, {$name}! 私は挨拶をするMCPです!.";
}
}
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
$server = Server::builder()
->setServerInfo('Greeting MCP Server', '1.0.0')
->setDiscovery(__DIR__, ['.', 'src'])
->build();
$transport = new StdioTransport();
$server->run($transport);
そして、MCPクライアント側では作成したserver.phpを実行するように指定しています。
{
"servers": {
"greeting-php-mcp": {
"command": "php",
"args": [
"C:\\Path\\to\\your\\project\\mcp-sdk\\server.php"
]
}
},
"inputs": []
}
このことから、MCPサーバのSDKは
-
server.phpが実行される - 定義したツール一覧が読み込まる
- 通信方法(STDIO、SSE)やリクエストで指定された処理(ツール実行、一覧取得)に応じて実行される
という流れで実装されていそうです。
これをもとに、server.phpから順番に処理を追ってみましょう。
PHPの公式MCPサーバSDKの実装を見てみよう
ということで、MCPサーバSDKの内部実装を追ってみましょう。
この記事では、「STDIOで任意のツールが実行される」までの流れを簡単に追ってみようと思います。
この記事で出てくるコードはSDKのコードから必要な部分のみを抽出したものになります。バリデーションやエラー処理など、説明に不要な箇所は割愛している点にご注意ください。
MCPサーバSDKのGitHub
MCPサーバの全体像
mcp/sdk
└ src
├ Capability
| ├ Registory
| | ├ ReferenceProviderInterface.php
| | └ ReferenceHandlerInterface.php
| └ Registory.php
├ JsonRpc
| └ MessageFactory.php
├ Schema
| └ Request
| └ CallToolRequest
├ Server
| ├ Handler
| | └ Request
| | ├ CallToolHandler.php
| | └ RequestHandlerInterface.php
| ├ Transport
| | ├ StdioTransport.php
| | └ TransportInterface.php
| ├ Builder.php
| └ Protocol.php
└ Server.php
用語
- Server.php:MCPサーバSDKのエントリーポイント。MCP全体を統括するクラス。
- Builder:Serverを構築するためのクラスで、ツールの登録やProtocol初期化を行う。
- Transport:STDIOやSSEなど、通信方式を抽象化したインターフェース。
- Protocol:MCPサーバのメッセージ処理ルール(JSON-RPCなど)を定義する。
- MessageFactory:JSON文字列をRequestクラスへ変換する。
- RequestHandler:各Requestの処理ロジックを担うクラス。
- ReferenceProviderInterface / ReferenceHandlerInterface:ツールやリソースを登録・実行するためのインターフェース。
※Referenceとは、ToolやResource、Promptなど、MCPサーバが提供している機能の総称のようです。
エントリーポイント
ユーザのほうで作成するエントリーポイントをもう一度見てみましょう。
このファイルでは、
-
build()でMCPサーバの実行準備 - STDIOで通信するために
StdioTransportインスタンスを作成 -
$server->runで実行
という流れです。
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
$server = Server::builder()
->setServerInfo('Greeting MCP Server', '1.0.0')
->setDiscovery(__DIR__, ['.', 'src'])
->build();
$transport = new StdioTransport();
$server->run($transport);
build()ではMCPサーバの実行の準備のために様々なことを行っているようです。準備したツールの読み込みなどもここで行います。
本記事ではbuilder内部の詳細は割愛し、$server->run以降の処理を見ていくことにしましょう。
Server
※分かりづらいですが、ユーザ側で定義したserver.phpとは別のものです。
ServerクラスはMCPサーバSDKの中で一番外側の処理です。
このクラスを始まりとして、最終的にはツールの実行まで行われます。
run()では、引数で受け取ったTransportを初期化(initialize)⇒実行(listen)⇒終了(close)という流れが実装されています。
$this->protocol->connect($transport);については後述します。
final class Server
{
public function __construct(
private readonly Protocol $protocol,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
public static function builder(): Builder
{
return new Builder();
}
/**
* @template TResult
*
* @param TransportInterface<TResult> $transport
*
* @return TResult
*/
public function run(TransportInterface $transport): mixed
{
$this->logger->info('Running server...');
$transport->initialize();
$this->protocol->connect($transport);
try {
return $transport->listen();
} finally {
$transport->close();
}
}
}
Transport
Transportは接続方式のインターフェースです。
このInterfaceを継承したSTDIOやSSEなどのTransportが用意されています。
-
initialize():初期化処理 -
onMessage():MCPサーバの処理の実態を利用側から登録できるメソッド -
listen():指定されたMCPサーバの処理 -
close():処理終了時の処理
interface TransportInterface
{
/**
* Initializes the transport.
*/
public function initialize(): void;
/**
* Register callback for ALL incoming messages.
*
* The transport calls this whenever ANY message arrives, regardless of source.
*
* @param callable(string $message, ?Uuid $sessionId): void $listener
*/
public function onMessage(callable $listener): void;
/**
* Starts the transport's execution process.
*
* - For a blocking transport like STDIO, this method will run a continuous loop.
* - For a single-request transport like HTTP, this will process the request
* and return a result (e.g., a PSR-7 Response) to be sent to the client.
*
* @return TResult the result of the transport's execution, if any
*/
public function listen(): mixed;
/**
* Closes the transport and cleans up any resources.
*
* This method should be called when the transport is no longer needed.
* It should clean up any resources and close any connections.
*/
public function close(): void;
}
こちらがSTDIO用のTransportクラスです。
コンストラクタで指定されている$inputと$outputは標準入出力となっていますね。読み込みと書き込みはここから行うようです。
listen()では標準入出力読み込んでリクエストを取得し、messageListenerの処理を呼び出しています。messageListenerはonMessage()で登録です。Transportは通信方式を扱うのみで、MCPサーバで実際にどんな処理を行うかは利用する側で決めることができます
つまり、Transport層はアプリケーションロジックから完全に独立しており、異なる通信手段(STDIO, SSE, HTTP)を容易に差し替えできるよう設計されています。
class StdioTransport implements TransportInterface
{
/** @var callable(string, ?Uuid): void */
private $messageListener;
/**
* @param resource $input
* @param resource $output
*/
public function __construct(
private $input = \STDIN,
private $output = \STDOUT,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
public function initialize(): void
{
}
public function onMessage(callable $listener): void
{
$this->messageListener = $listener;
}
public function listen(): int
{
$status = 0;
while (!feof($this->input)) {
$line = fgets($this->input);
$trimmedLine = trim($line);
\call_user_func($this->messageListener, $trimmedLine, $this->sessionId);
}
return $status;
}
public function close(): void
{
fclose($this->input);
fclose($this->output);
}
}
Protocol
前述したTransportのonMessage()はProtocolクラスから呼び出されています。
これは、Serverクラスの$this->protocol->connect($transport);の処理の箇所のことです。
class Protocol
{
/**
* Connect this protocol to a transport.
*
* The protocol takes ownership of the transport and sets up all callbacks.
*
* @param TransportInterface<mixed> $transport
*/
public function connect(TransportInterface $transport): void
{
$this->transport = $transport;
$this->transport->onMessage([$this, 'processInput']);
}
}
Transportに渡されるのはprocessInput()という処理です。
processInput()では、
- MessageFactoryの
create()で、json形式のリクエストをRequestクラスに変換 - 変換したRequestクラスをhandleRequest()で処理
という流れで処理をします。
Requestは実行の種類によって決まります。Tool実行であればCallToolRequest、Toolの一覧取得であればCallToolsRequestといった具合です。
handleRequest()では
- 各Requestを処理できるRequestHandlerを探す
- 見つけたら実行する
という流れです。
RequestHandlerとは、リクエストでMCPのリクエストで指定される各処理のロジックが定義されているクラスです(ツールを呼び出すCallToolHandlerやツールの一覧を取得するListToolsHandlerなどがあります)。
ProtocolはMCPサーバで処理可能なRequestHandlerの一覧をbuild()時に持っておき、その中から条件に当てはまるRequestHandlerを見つけて実行を行います。
/**
* Handle an incoming message from the transport.
*
* This is called by the transport whenever ANY message arrives.
*/
public function processInput(string $input, ?Uuid $sessionId): void
{
$messages = $this->messageFactory->create($input);
$session = $this->resolveSession($sessionId, $messages); // 解説しない
$this->handleRequest($message, $session);
}
private function handleRequest(Request $request, SessionInterface $session): void
{
foreach ($this->requestHandlers as $handler) {
if (!$handler->supports($request)) {
continue;
}
try {
$response = $handler->handle($request, $session);
$this->sendResponse($response, ['session_id' => $session->getId()]);
} catch (\Throwable $e) {
// ~エラー処理~
}
break;
}
}
MessageFactory
MessageFactoryはjson形式のリクエストをRequestクラスに変換するためのクラスです。
create()で$inputをjson_decode()し、その後、createMessage()でリクエストパラメータのmethodの値を見て、どのクラスに変換するかを決めています。
その後、fromArray()で変換されるようです。
final class MessageFactory
{
/**
* Registry of all known message classes that have methods.
*
* @var array<int, class-string<Request|Notification>>
*/
private const REGISTERED_MESSAGES = [
Schema\Request\CallToolRequest::class,
Schema\Request\ListToolsRequest::class,
];
public function __construct(
private readonly array $registeredMessages,
) {
foreach ($this->registeredMessages as $messageClass) {
if (!is_subclass_of($messageClass, Request::class) && !is_subclass_of($messageClass, Notification::class)) {
throw new InvalidArgumentException(\sprintf('Message classes must extend %s or %s.', Request::class, Notification::class));
}
}
}
/**
* Creates a new Factory instance with all the protocol's default messages.
*/
public static function make(): self
{
return new self(self::REGISTERED_MESSAGES);
}
/**
* Creates message objects from JSON input.
*
* Supports both single messages and batch requests. Returns an array containing
* MessageInterface objects or InvalidInputMessageException instances for invalid messages.
*
* @return array<MessageInterface|InvalidInputMessageException>
*
* @throws \JsonException When the input string is not valid JSON
*/
public function create(string $input): array
{
$data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR);
$messages = [];
foreach ($data as $message) {
$messages[] = $this->createMessage($message);
}
return $messages;
}
/**
* Creates a single message object from parsed JSON data.
*
* @param array<string, mixed> $data
*
* @throws InvalidInputMessageException
*/
private function createMessage(array $data): MessageInterface
{
$messageClass = $this->findMessageClassByMethod($data['method']);
return $messageClass::fromArray($data);
}
/**
* Finds the registered message class for a given method name.
*
* @return class-string<Request|Notification>
*
* @throws InvalidInputMessageException
*/
private function findMessageClassByMethod(string $method): string
{
foreach ($this->registeredMessages as $messageClass) {
if ($messageClass::getMethod() === $method) {
return $messageClass;
}
}
}
}
ツールを呼び出す場合、methodにはcallToolsが指定されます。その場合、以下のCallToolRequestクラスに変換されるというわけです。
final class CallToolRequest extends Request
{
/**
* @param string $name the name of the tool to invoke
* @param array<string, mixed> $arguments the arguments to pass to the tool
*/
public function __construct(
public readonly string $name,
public readonly array $arguments,
) {
}
public static function getMethod(): string
{
return 'tools/call';
}
}
RequestHandler
続いて、リクエストハンドラーです。
-
supports():そのリクエストを処理できるかどうか判定する
*handle():各リクエストの処理を定義する
interface RequestHandlerInterface
{
public function supports(Request $request): bool;
public function handle(Request $request, SessionInterface $session): Response|Error;
}
こちらがTool実行のクラスです。
$this->referenceProvider->getTool($toolName);では、リクエストで指定されたツールが存在するかを確認しています。
ツールが取得できた場合は実行してレスポンスを返します。
final class CallToolHandler implements RequestHandlerInterface
{
public function __construct(
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
public function handle(Request $request, SessionInterface $session): Response|Error
{
$toolName = $request->name;
$arguments = $request->arguments ?? [];
try {
$reference = $this->referenceProvider->getTool($toolName);
$result = $this->referenceHandler->handle($reference, $arguments);
$formatted = $reference->formatResult($result);
return new Response($request->getId(), new CallToolResult($formatted));
} catch (\Throwable $e) {
// ~エラー処理~
}
}
}
ReferenceProviderInterface, ReferenceHandlerInterface
ReferenceProviderInterfaceはリファレンスの一覧を管理するためのInterfaceです。
Referenceとは、ToolやResource、Promptなど、MCPサーバが提供している機能の総称のようです。
ReferenceHandlerInterfaceはReferenceを利用するためのインターフェースです。
interface ReferenceProviderInterface
{
/**
* Gets a tool reference by name.
*/
public function getTool(string $name): ?ToolReference;
}
interface ReferenceHandlerInterface
{
/**
* Handles execution of an MCP element reference.
*
* @param ElementReference $reference the element reference to execute
* @param array<string, mixed> $arguments arguments to pass to the handler
*
* @return mixed the result of the element execution
*
* @throws \Mcp\Exception\InvalidArgumentException if the handler is invalid
* @throws \Mcp\Exception\RegistryException if execution fails
*/
public function handle(ElementReference $reference, array $arguments): mixed;
}
ReferenceProviderInterfaceはRegistryというクラスで実装されています。
toolの登録はregisterTool()で行い、$toolsに格納しておくことで、利用時にそこから探すというフローになっています。
final class Registry implements ReferenceProviderInterface, ReferenceRegistryInterface
{
/**
* @var array<string, ToolReference>
*/
private array $tools = [];
public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
{
$toolName = $tool->name;
$this->tools[$toolName] = new ToolReference($tool, $handler, $isManual);
}
public function getTool(string $name): ?ToolReference
{
return $this->tools[$name] ?? null;
}
}
一方で、ReferenceHandlerInterfaceはReferenceHandlerクラスによって実装されています。
単純にツールを呼び出す処理だけです。
final class ReferenceHandler implements ReferenceHandlerInterface
{
/**
* @param array<string, mixed> $arguments
*/
public function handle(ElementReference $reference, array $arguments): mixed
{
return \call_user_func($reference->handler, ...$arguments);
}
}
まとめ
Tool実行の流れは以下になります。
- MCPクライアントからエントリーポイントが呼び出される
- StdioTransportの定義
- serverをbuild
- Server.phpのrun()を実行
- StdioTransportの初期化
- StdioTransportがリクエストを受け取った時に実行される処理を設定
- StdioTransportのlisten()を実行し、MCPサーバの処理を実行
- 標準入力からリクエストを取得
- 1-3-2で設定した処理が呼び出される
- json形式のリクエストを、CallToolRequestクラスに変換
- CallToolRequestHandlerでツール実行
- リクエストで指定されたツールが存在するか確認
- ツールを実行
- StdioTransportのclose()を実行し、MCPサーバの処理を終了
- 標準出力に結果を書き込み
おわりに
今回はPHPのMCPサーバSDKの内部構造を簡単に追ってみました。
開発初期段階ということもあり、コードベースがコンパクトで理解しやすく、全体像を把握するのにちょうど良いタイミングでした。
OSSのコードを読んで実装の意図を理解するのは非常に勉強になりますし、知らないメソッドを知る機会にもなって大変面白いです。今後は、SSE通信や複数ツールの登録処理など、より実践的な部分も調べてみたいと思います。
ここまでご覧いただきありがとうございました!