From 7f44d22079fb68eab14183c8c0fd9ea5ddc43daa Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 7 Sep 2025 23:23:28 +0100 Subject: [PATCH 01/11] feat(server): Rework transport architecture and add StreamableHttpTransport - `Server::connect()` no longer contains a processing loop. - `TransportInterface` is updated with `setMessageHandler()` and `listen()`. - `StdioTransport` is updated to implement the new interface. - A new, minimal `StreamableHttpTransport` is added for stateless HTTP. --- composer.json | 2 + .../01-discovery-stdio-calculator/server.php | 21 ++-- examples/10-simple-http-transport/.gitignore | 55 +++++++++ examples/10-simple-http-transport/README.md | 49 ++++++++ .../10-simple-http-transport/composer.json | 31 +++++ examples/10-simple-http-transport/index.php | 27 +++++ .../src/McpElements.php | 101 +++++++++++++++++ src/Server.php | 42 +++---- src/Server/Transport/StdioTransport.php | 57 ++++++---- .../Transport/StreamableHttpTransport.php | 106 ++++++++++++++++++ src/Server/TransportInterface.php | 29 ++++- 11 files changed, 462 insertions(+), 58 deletions(-) create mode 100644 examples/10-simple-http-transport/.gitignore create mode 100644 examples/10-simple-http-transport/README.md create mode 100644 examples/10-simple-http-transport/composer.json create mode 100644 examples/10-simple-http-transport/index.php create mode 100644 examples/10-simple-http-transport/src/McpElements.php create mode 100644 src/Server/Transport/StreamableHttpTransport.php diff --git a/composer.json b/composer.json index 4d94c1a2..5dfa4756 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,8 @@ "phpdocumentor/reflection-docblock": "^5.6", "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/finder": "^6.4 || ^7.3", "symfony/uid": "^6.4 || ^7.3" diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php index ce19dc0e..ddd251dd 100644 --- a/examples/01-discovery-stdio-calculator/server.php +++ b/examples/01-discovery-stdio-calculator/server.php @@ -10,7 +10,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__).'/bootstrap.php'; +require_once dirname(__DIR__) . '/bootstrap.php'; chdir(__DIR__); use Mcp\Server; @@ -18,12 +18,17 @@ logger()->info('Starting MCP Stdio Calculator Server...'); -Server::make() - ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') - ->setContainer(container()) - ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); +$server = Server::make() + ->withServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->withContainer(container()) + ->withLogger(logger()) + ->withDiscovery(__DIR__, ['.']) + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/10-simple-http-transport/.gitignore b/examples/10-simple-http-transport/.gitignore new file mode 100644 index 00000000..bdf5ce79 --- /dev/null +++ b/examples/10-simple-http-transport/.gitignore @@ -0,0 +1,55 @@ +# Composer dependencies +/vendor/ + +# Composer lock file (can be regenerated with composer install) +/composer.lock + +# PHP error logs and cache files +*.log +/error_log +/cache/ +/tmp/ + +# macOS system files +.DS_Store +.AppleDouble +.LSOverride +Icon + +# Windows system files +Thumbs.db +ehthumbs.db +Desktop.ini + +# Linux system files +*~ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment files (may contain sensitive data) +.env +.env.local +.env.*.local + +# Temporary files +*.tmp +*.temp + +# Build and distribution files +/build/ +/dist/ + +# Node modules (if any frontend assets are added later) +/node_modules/ + +# Test coverage reports +/coverage/ +/phpunit.xml + +# PHPStan cache +/.phpstan.cache diff --git a/examples/10-simple-http-transport/README.md b/examples/10-simple-http-transport/README.md new file mode 100644 index 00000000..af2508b1 --- /dev/null +++ b/examples/10-simple-http-transport/README.md @@ -0,0 +1,49 @@ +# HTTP MCP Server Example + +This example demonstrates how to use the MCP SDK with HTTP transport using the StreamableHttpTransport. It provides a complete HTTP-based MCP server that can handle JSON-RPC requests over HTTP POST. + +## Installation + +```bash +cd /path/to/your/project/examples/10-simple-http-transport +composer update +``` + +## Usage + +### As HTTP Server + +You can use this with any HTTP server or framework that can proxy requests to PHP: + +```bash +# Using PHP built-in server (for testing) +php -S localhost:8000 + +# Or with Apache/Nginx, point your web server to serve this directory +``` + +### With MCP Inspector + +Run with the MCP Inspector for testing: + +```bash +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +## API + +The server accepts JSON-RPC 2.0 requests via HTTP POST. + +### Available Endpoints + +- **Tools**: `current_time`, `calculate` +- **Resources**: `info://server/status`, `config://app/settings` +- **Prompts**: `greet` + +### Example Request + +```bash +curl -X POST http://localhost:8000 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "current_time"}}' +``` diff --git a/examples/10-simple-http-transport/composer.json b/examples/10-simple-http-transport/composer.json new file mode 100644 index 00000000..99c1a9d8 --- /dev/null +++ b/examples/10-simple-http-transport/composer.json @@ -0,0 +1,31 @@ +{ + "name": "mcp/http-server-example", + "description": "An example application for HTTP", + "license": "MIT", + "type": "project", + "authors": [ + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "require": { + "php": ">=8.1", + "mcp/sdk": "@dev", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "laminas/laminas-httphandlerrunner": "^2.12" + }, + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "repositories": [ + { + "type": "path", + "url": "../../" + } + ] +} diff --git a/examples/10-simple-http-transport/index.php b/examples/10-simple-http-transport/index.php new file mode 100644 index 00000000..f55ff8df --- /dev/null +++ b/examples/10-simple-http-transport/index.php @@ -0,0 +1,27 @@ +fromGlobals(); + +$server = Server::make() + ->withServerInfo('HTTP MCP Server', '1.0.0') + ->withDiscovery(__DIR__, ['src']) + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); + +(new SapiEmitter())->emit($response); diff --git a/examples/10-simple-http-transport/src/McpElements.php b/examples/10-simple-http-transport/src/McpElements.php new file mode 100644 index 00000000..1857347c --- /dev/null +++ b/examples/10-simple-http-transport/src/McpElements.php @@ -0,0 +1,101 @@ + $a + $b, + 'subtract', '-' => $a - $b, + 'multiply', '*' => $a * $b, + 'divide', '/' => $b != 0 ? $a / $b : 'Error: Division by zero', + default => 'Error: Unknown operation. Use: add, subtract, multiply, divide' + }; + } + + /** + * Server information resource + */ + #[McpResource( + uri: 'info://server/status', + name: 'server_status', + description: 'Current server status and information', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'running', + 'timestamp' => time(), + 'version' => '1.0.0', + 'transport' => 'HTTP', + 'uptime' => time() - $_SERVER['REQUEST_TIME'] + ]; + } + + /** + * Configuration resource + */ + #[McpResource( + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration settings', + mimeType: 'application/json' + )] + public function getAppConfig(): array + { + return [ + 'debug' => $_SERVER['DEBUG'] ?? false, + 'environment' => $_SERVER['APP_ENV'] ?? 'production', + 'timezone' => date_default_timezone_get(), + 'locale' => 'en_US' + ]; + } + + /** + * Greeting prompt + */ + #[McpPrompt( + name: 'greet', + description: 'Generate a personalized greeting message' + )] + public function greetPrompt(string $firstName = 'World', string $timeOfDay = 'day'): array + { + $greeting = match (strtolower($timeOfDay)) { + 'morning' => 'Good morning', + 'afternoon' => 'Good afternoon', + 'evening', 'night' => 'Good evening', + default => 'Hello' + }; + + return [ + 'role' => 'user', + 'content' => "# {$greeting}, {$firstName}!\n\nWelcome to our MCP HTTP Server example. This demonstrates how to use the Model Context Protocol over HTTP transport." + ]; + } +} diff --git a/src/Server.php b/src/Server.php index fc81382d..a891389e 100644 --- a/src/Server.php +++ b/src/Server.php @@ -25,8 +25,7 @@ final class Server public function __construct( private readonly Handler $jsonRpcHandler, private readonly LoggerInterface $logger = new NullLogger(), - ) { - } + ) {} public static function make(): ServerBuilder { @@ -40,33 +39,26 @@ public function connect(TransportInterface $transport): void 'transport' => $transport::class, ]); - while ($transport->isConnected()) { - foreach ($transport->receive() as $message) { - if (null === $message) { - continue; - } - - try { - foreach ($this->jsonRpcHandler->process($message) as $response) { - if (null === $response) { - continue; - } + $transport->setMessageHandler(function (string $rawMessage) use ($transport) { + $this->handleMessage($rawMessage, $transport); + }); + } - $transport->send($response); - } - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message' => $message, - 'exception' => $e, - ]); + private function handleMessage(string $rawMessage, TransportInterface $transport): void + { + try { + foreach ($this->jsonRpcHandler->process($rawMessage) as $response) { + if (null === $response) { continue; } - } - usleep(1000); + $transport->send($response); + } + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message' => $rawMessage, + 'exception' => $e, + ]); } - - $transport->close(); - $this->logger->info('Transport closed'); } } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 309683ab..c6ae45ca 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -21,6 +21,7 @@ class StdioTransport implements TransportInterface { private string $buffer = ''; + private $messageHandler = null; /** * @param resource $input @@ -30,46 +31,56 @@ public function __construct( private $input = \STDIN, private $output = \STDOUT, private readonly LoggerInterface $logger = new NullLogger(), - ) { - } + ) {} + + public function initialize(): void {} - public function initialize(): void + public function setMessageHandler(callable $handler): void { + $this->messageHandler = $handler; } - public function isConnected(): bool + public function send(string $data): void { - return true; + $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + + fwrite($this->output, $data . \PHP_EOL); } - public function receive(): \Generator + public function listen(): mixed { - $line = fgets($this->input); + if ($this->messageHandler === null) { + throw new \LogicException('Cannot listen without a message handler. Did you forget to call Server::connect()?'); + } - $this->logger->debug('Received message on StdioTransport.', [ - 'line' => $line, - ]); + $this->logger->info('StdioTransport is listening for messages on STDIN...'); - if (false === $line) { - return; - } - $this->buffer .= rtrim($line).\PHP_EOL; - if (str_contains($this->buffer, \PHP_EOL)) { - $lines = explode(\PHP_EOL, $this->buffer); - $this->buffer = array_pop($lines); + while (!feof($this->input)) { + $line = fgets($this->input); + if ($line === false) { + break; + } - yield from $lines; + $trimmedLine = trim($line); + if (!empty($trimmedLine)) { + $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); + call_user_func($this->messageHandler, $trimmedLine); + } } - } - public function send(string $data): void - { - $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + $this->logger->info('StdioTransport finished listening.'); - fwrite($this->output, $data.\PHP_EOL); + return null; } public function close(): void { + if (is_resource($this->input)) { + fclose($this->input); + } + + if (is_resource($this->output)) { + fclose($this->output); + } } } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php new file mode 100644 index 00000000..07c3982e --- /dev/null +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -0,0 +1,106 @@ + + */ +class StreamableHttpTransport implements TransportInterface +{ + private $messageHandler = null; + private $outgoingMessages = []; + + public function __construct( + private readonly ServerRequestInterface $request, + private readonly ResponseFactoryInterface $responseFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger = new NullLogger() + ) {} + + public function initialize(): void {} + + public function setMessageHandler(callable $handler): void + { + $this->messageHandler = $handler; + } + + public function send(string $data): void + { + $this->outgoingMessages[] = $data; + } + + public function listen(): mixed + { + if ($this->messageHandler === null) { + $this->logger->error('Cannot listen without a message handler. Did you forget to call Server::connect()?'); + return $this->createErrorResponse(Error::forInternalError('Internal Server Error: Transport not configured.'), 500); + } + + switch ($this->request->getMethod()) { + case 'POST': + $body = $this->request->getBody()->getContents(); + if (empty($body)) { + return $this->createErrorResponse(Error::forInvalidRequest('Bad Request: Empty request body.'), 400); + } + + call_user_func($this->messageHandler, $body); + break; + + case 'GET': + case 'DELETE': + return $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); + + default: + return $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405) + ->withHeader('Allow', 'POST'); + } + + return $this->buildResponse(); + } + + public function close(): void {} + + private function buildResponse(): ResponseInterface + { + $hasRequestsInInput = !empty($this->request->getBody()->getContents()); + $hasResponsesInOutput = !empty($this->outgoingMessages); + + if ($hasRequestsInInput && !$hasResponsesInOutput) { + return $this->responseFactory->createResponse(202); + } + + $responseBody = count($this->outgoingMessages) === 1 + ? $this->outgoingMessages[0] + : '[' . implode(',', $this->outgoingMessages) . ']'; + + return $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($responseBody)); + } + + private function createErrorResponse(Error $jsonRpcErrpr, int $statusCode): ResponseInterface + { + $errorPayload = json_encode($jsonRpcErrpr, \JSON_THROW_ON_ERROR); + return $this->responseFactory->createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode($errorPayload))); + } +} diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 49963a70..67f047af 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -16,13 +16,38 @@ */ interface TransportInterface { + /** + * Initializes the transport. + */ public function initialize(): void; - public function isConnected(): bool; + /** + * Registers the callback that the Server will use to process incoming messages. + * The transport must call this handler whenever a raw JSON-RPC message string is received. + * + * @param callable(string): void $handler The message processing callback. + */ + public function setMessageHandler(callable $handler): void; - public function receive(): \Generator; + /** + * 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 mixed The result of the transport's execution, if any. + */ + public function listen(): mixed; + + /** + * Sends a raw JSON-RPC message string back to the client. + */ public function send(string $data): void; + /** + * Closes the transport and cleans up any resources. + */ public function close(): void; } From eb28fda80021fbe4c591dba5a08fdedfc4863b78 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 9 Sep 2025 09:16:00 +0100 Subject: [PATCH 02/11] refactor(server): use event-driven message handling --- src/Server.php | 5 +- src/Server/Transport/StdioTransport.php | 27 ++-- .../Transport/StreamableHttpTransport.php | 126 +++++++++++++----- src/Server/TransportInterface.php | 21 ++- 4 files changed, 131 insertions(+), 48 deletions(-) diff --git a/src/Server.php b/src/Server.php index a891389e..91fb5799 100644 --- a/src/Server.php +++ b/src/Server.php @@ -35,12 +35,13 @@ public static function make(): ServerBuilder public function connect(TransportInterface $transport): void { $transport->initialize(); + $this->logger->info('Transport initialized.', [ 'transport' => $transport::class, ]); - $transport->setMessageHandler(function (string $rawMessage) use ($transport) { - $this->handleMessage($rawMessage, $transport); + $transport->on('message', function (string $message) use ($transport) { + $this->handleMessage($message, $transport); }); } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index c6ae45ca..fef34f80 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -20,8 +20,8 @@ */ class StdioTransport implements TransportInterface { - private string $buffer = ''; - private $messageHandler = null; + /** @var array> */ + private array $listeners = []; /** * @param resource $input @@ -35,9 +35,22 @@ public function __construct( public function initialize(): void {} - public function setMessageHandler(callable $handler): void + public function on(string $event, callable $listener): void { - $this->messageHandler = $handler; + if (!isset($this->listeners[$event])) { + $this->listeners[$event] = []; + } + $this->listeners[$event][] = $listener; + } + + public function emit(string $event, mixed ...$args): void + { + if (!isset($this->listeners[$event])) { + return; + } + foreach ($this->listeners[$event] as $listener) { + $listener(...$args); + } } public function send(string $data): void @@ -49,10 +62,6 @@ public function send(string $data): void public function listen(): mixed { - if ($this->messageHandler === null) { - throw new \LogicException('Cannot listen without a message handler. Did you forget to call Server::connect()?'); - } - $this->logger->info('StdioTransport is listening for messages on STDIN...'); while (!feof($this->input)) { @@ -64,7 +73,7 @@ public function listen(): mixed $trimmedLine = trim($line); if (!empty($trimmedLine)) { $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); - call_user_func($this->messageHandler, $trimmedLine); + $this->emit('message', $trimmedLine); } } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 07c3982e..757f6d8d 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -25,8 +25,17 @@ */ class StreamableHttpTransport implements TransportInterface { - private $messageHandler = null; - private $outgoingMessages = []; + /** @var array> */ + private array $listeners = []; + + /** @var string[] */ + private array $outgoingMessages = []; + + private array $corsHeaders = [ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization, Accept', + ]; public function __construct( private readonly ServerRequestInterface $request, @@ -37,9 +46,23 @@ public function __construct( public function initialize(): void {} - public function setMessageHandler(callable $handler): void + public function on(string $event, callable $listener): void + { + if (!isset($this->listeners[$event])) { + $this->listeners[$event] = []; + } + $this->listeners[$event][] = $listener; + } + + public function emit(string $event, mixed ...$args): void { - $this->messageHandler = $handler; + if (!isset($this->listeners[$event])) { + return; + } + + foreach ($this->listeners[$event] as $listener) { + $listener(...$args); + } } public function send(string $data): void @@ -49,58 +72,95 @@ public function send(string $data): void public function listen(): mixed { - if ($this->messageHandler === null) { - $this->logger->error('Cannot listen without a message handler. Did you forget to call Server::connect()?'); - return $this->createErrorResponse(Error::forInternalError('Internal Server Error: Transport not configured.'), 500); - } - switch ($this->request->getMethod()) { - case 'POST': - $body = $this->request->getBody()->getContents(); - if (empty($body)) { - return $this->createErrorResponse(Error::forInvalidRequest('Bad Request: Empty request body.'), 400); - } + return match ($this->request->getMethod()) { + 'OPTIONS' => $this->handleOptionsRequest(), + 'GET' => $this->handleGetRequest(), + 'POST' => $this->handlePostRequest(), + 'DELETE' => $this->handleDeleteRequest(), + default => $this->handleUnsupportedRequest(), + }; + } - call_user_func($this->messageHandler, $body); - break; + protected function handleOptionsRequest(): ResponseInterface + { + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } - case 'GET': - case 'DELETE': - return $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); + protected function handlePostRequest(): ResponseInterface + { + $acceptHeader = $this->request->getHeaderLine('Accept'); + if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) { + $error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.'); + return $this->createErrorResponse($error, 406); + } - default: - return $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405) - ->withHeader('Allow', 'POST'); + if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) { + $error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.'); + return $this->createErrorResponse($error, 415); } - return $this->buildResponse(); - } + $body = $this->request->getBody()->getContents(); + if (empty($body)) { + $error = Error::forInvalidRequest('Bad Request: Empty request body.'); + return $this->createErrorResponse($error, 400); + } - public function close(): void {} + $this->emit('message', $body); - private function buildResponse(): ResponseInterface - { - $hasRequestsInInput = !empty($this->request->getBody()->getContents()); + $hasRequestsInInput = str_contains($body, '"id"'); $hasResponsesInOutput = !empty($this->outgoingMessages); if ($hasRequestsInInput && !$hasResponsesInOutput) { - return $this->responseFactory->createResponse(202); + return $this->withCorsHeaders($this->responseFactory->createResponse(202)); } $responseBody = count($this->outgoingMessages) === 1 ? $this->outgoingMessages[0] : '[' . implode(',', $this->outgoingMessages) . ']'; - return $this->responseFactory->createResponse(200) + $response = $this->responseFactory->createResponse(200) ->withHeader('Content-Type', 'application/json') ->withBody($this->streamFactory->createStream($responseBody)); + + return $this->withCorsHeaders($response); + } + + protected function handleGetRequest(): ResponseInterface + { + $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 501); + return $this->withCorsHeaders($response); + } + + protected function handleDeleteRequest(): ResponseInterface + { + $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 501); + return $this->withCorsHeaders($response); + } + + protected function handleUnsupportedRequest(): ResponseInterface + { + $response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); + return $this->withCorsHeaders($response); } - private function createErrorResponse(Error $jsonRpcErrpr, int $statusCode): ResponseInterface + protected function withCorsHeaders(ResponseInterface $response): ResponseInterface { - $errorPayload = json_encode($jsonRpcErrpr, \JSON_THROW_ON_ERROR); + foreach ($this->corsHeaders as $name => $value) { + $response = $response->withHeader($name, $value); + } + + return $response; + } + + protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface + { + $errorPayload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR); + return $this->responseFactory->createResponse($statusCode) ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream(json_encode($errorPayload))); + ->withBody($this->streamFactory->createStream($errorPayload)); } + + public function close(): void {} } diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 67f047af..2900b5db 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -13,6 +13,7 @@ /** * @author Christopher Hertel + * @author Kyrian Obikwelu */ interface TransportInterface { @@ -22,13 +23,20 @@ interface TransportInterface public function initialize(): void; /** - * Registers the callback that the Server will use to process incoming messages. - * The transport must call this handler whenever a raw JSON-RPC message string is received. + * Registers an event listener for the specified event. * - * @param callable(string): void $handler The message processing callback. + * @param string $event The event name to listen for + * @param callable $listener The callback function to execute when the event occurs */ - public function setMessageHandler(callable $handler): void; + public function on(string $event, callable $listener): void; + /** + * Triggers an event and executes all registered listeners. + * + * @param string $event The event name to emit + * @param mixed ...$args Variable number of arguments to pass to the listeners + */ + public function emit(string $event, mixed ...$args): void; /** * Starts the transport's execution process. @@ -43,11 +51,16 @@ public function listen(): mixed; /** * Sends a raw JSON-RPC message string back to the client. + * + * @param string $data The JSON-RPC message string to send */ public function send(string $data): void; /** * 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; } From fdb5e17b1b1453602bcc41a395e3582e076b613c Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 10 Sep 2025 13:30:45 +0100 Subject: [PATCH 03/11] feat(server): Introduce a formal session management system --- composer.json | 1 + src/Server.php | 11 +- src/Server/NativeClock.php | 16 ++ src/Server/ServerBuilder.php | 31 +++- src/Server/Session/InMemorySessionStore.php | 75 +++++++++ src/Server/Session/Session.php | 150 ++++++++++++++++++ src/Server/Session/SessionFactory.php | 25 +++ .../Session/SessionFactoryInterface.php | 26 +++ src/Server/Session/SessionInterface.php | 76 +++++++++ src/Server/Session/SessionStoreInterface.php | 41 +++++ .../Transport/StreamableHttpTransport.php | 4 +- 11 files changed, 445 insertions(+), 11 deletions(-) create mode 100644 src/Server/NativeClock.php create mode 100644 src/Server/Session/InMemorySessionStore.php create mode 100644 src/Server/Session/Session.php create mode 100644 src/Server/Session/SessionFactory.php create mode 100644 src/Server/Session/SessionFactoryInterface.php create mode 100644 src/Server/Session/SessionInterface.php create mode 100644 src/Server/Session/SessionStoreInterface.php diff --git a/composer.json b/composer.json index 5dfa4756..34e6d751 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "ext-fileinfo": "*", "opis/json-schema": "^2.4", "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", "psr/http-factory": "^1.1", diff --git a/src/Server.php b/src/Server.php index 91fb5799..03b182ea 100644 --- a/src/Server.php +++ b/src/Server.php @@ -13,6 +13,8 @@ use Mcp\JsonRpc\Handler; use Mcp\Server\ServerBuilder; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionStoreInterface; use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -24,6 +26,9 @@ final class Server { public function __construct( private readonly Handler $jsonRpcHandler, + private readonly SessionFactoryInterface $sessionFactory, + private readonly SessionStoreInterface $sessionStore, + private readonly int $sessionTtl, private readonly LoggerInterface $logger = new NullLogger(), ) {} @@ -45,10 +50,10 @@ public function connect(TransportInterface $transport): void }); } - private function handleMessage(string $rawMessage, TransportInterface $transport): void + private function handleMessage(string $message, TransportInterface $transport): void { try { - foreach ($this->jsonRpcHandler->process($rawMessage) as $response) { + foreach ($this->jsonRpcHandler->process($message) as $response) { if (null === $response) { continue; } @@ -57,7 +62,7 @@ private function handleMessage(string $rawMessage, TransportInterface $transport } } catch (\JsonException $e) { $this->logger->error('Failed to encode response to JSON.', [ - 'message' => $rawMessage, + 'message' => $message, 'exception' => $e, ]); } diff --git a/src/Server/NativeClock.php b/src/Server/NativeClock.php new file mode 100644 index 00000000..05ab0e47 --- /dev/null +++ b/src/Server/NativeClock.php @@ -0,0 +1,16 @@ + @@ -65,6 +68,10 @@ final class ServerBuilder private ?ContainerInterface $container = null; + private ?SessionFactoryInterface $sessionFactory = null; + private ?SessionStoreInterface $sessionStore = null; + private ?int $sessionTtl = 3600; + private ?int $paginationLimit = 50; private ?string $instructions = null; @@ -193,6 +200,18 @@ public function setContainer(ContainerInterface $container): self return $this; } + public function withSession( + SessionFactoryInterface $sessionFactory, + SessionStoreInterface $sessionStore, + int $ttl = 3600 + ): self { + $this->sessionFactory = $sessionFactory; + $this->sessionStore = $sessionStore; + $this->sessionTtl = $ttl; + + return $this; + } + public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], @@ -327,7 +346,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -362,7 +381,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -400,7 +419,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -438,7 +457,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -461,7 +480,7 @@ private function registerCapabilities( continue; } - $paramTag = $paramTags['$'.$param->getName()] ?? null; + $paramTag = $paramTags['$' . $param->getName()] ?? null; $arguments[] = new PromptArgument( $param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php new file mode 100644 index 00000000..c73f9f68 --- /dev/null +++ b/src/Server/Session/InMemorySessionStore.php @@ -0,0 +1,75 @@ + + */ + protected array $store = []; + + public function __construct( + protected readonly int $ttl = 3600, + protected readonly ClockInterface $clock = new NativeClock(), + ) {} + + public function read(Uuid $sessionId): string|false + { + $session = $this->store[$sessionId->toRfc4122()] ?? ''; + if ($session === '') { + return false; + } + + $currentTimestamp = $this->clock->now()->getTimestamp(); + + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + unset($this->store[$sessionId]); + return false; + } + + return $session['data']; + } + + public function write(Uuid $sessionId, string $data): bool + { + $this->store[$sessionId->toRfc4122()] = [ + 'data' => $data, + 'timestamp' => $this->clock->now()->getTimestamp(), + ]; + + return true; + } + + public function destroy(Uuid $sessionId): bool + { + if (isset($this->store[$sessionId->toRfc4122()])) { + unset($this->store[$sessionId]); + } + + return true; + } + + public function gc(int $maxLifetime): array + { + $currentTimestamp = $this->clock->now()->getTimestamp(); + $deletedSessions = []; + + foreach ($this->store as $sessionId => $session) { + $sessionId = Uuid::fromString($sessionId); + if ($currentTimestamp - $session['timestamp'] > $maxLifetime) { + unset($this->store[$sessionId->toRfc4122()]); + $deletedSessions[] = $sessionId; + } + } + + return $deletedSessions; + } +} diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php new file mode 100644 index 00000000..0ad95f94 --- /dev/null +++ b/src/Server/Session/Session.php @@ -0,0 +1,150 @@ + + */ +class Session implements SessionInterface +{ + /** + * @param array $data Stores all session data. + * Keys are snake_case by convention for MCP-specific data. + * + * Official keys are: + * - initialized: bool + * - client_info: array|null + * - protocol_version: string|null + * - log_level: string|null + */ + public function __construct( + protected SessionStoreInterface $store, + protected Uuid $id = new UuidV4(), + protected array $data = [], + ) { + if ($rawData = $this->store->read($this->id)) { + $this->data = json_decode($rawData, true) ?? []; + } + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getStore(): SessionStoreInterface + { + return $this->store; + } + + public function save(): void + { + $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); + } + + public function get(string $key, mixed $default = null): mixed + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (is_array($data) && array_key_exists($segment, $data)) { + $data = $data[$segment]; + } else { + return $default; + } + } + + return $data; + } + + public function set(string $key, mixed $value, bool $overwrite = true): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if ($overwrite || !isset($data[$lastKey])) { + $data[$lastKey] = $value; + } + } + + public function has(string $key): bool + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (is_array($data) && array_key_exists($segment, $data)) { + $data = $data[$segment]; + } elseif (is_object($data) && isset($data->{$segment})) { + $data = $data->{$segment}; + } else { + return false; + } + } + + return true; + } + + public function forget(string $key): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if (isset($data[$lastKey])) { + unset($data[$lastKey]); + } + } + + public function clear(): void + { + $this->data = []; + } + + public function pull(string $key, mixed $default = null): mixed + { + $value = $this->get($key, $default); + $this->forget($key); + return $value; + } + + public function all(): array + { + return $this->data; + } + + public function hydrate(array $attributes): void + { + $this->data = $attributes; + } + + public function jsonSerialize(): array + { + return $this->all(); + } +} diff --git a/src/Server/Session/SessionFactory.php b/src/Server/Session/SessionFactory.php new file mode 100644 index 00000000..2574a5af --- /dev/null +++ b/src/Server/Session/SessionFactory.php @@ -0,0 +1,25 @@ + + */ +class SessionFactory implements SessionFactoryInterface +{ + public function create(Uuid $id, SessionStoreInterface $store): SessionInterface + { + return new Session($store, $id); + } + + public function createNew(SessionStoreInterface $store): SessionInterface + { + return $this->create(Uuid::v4(), $store); + } +} diff --git a/src/Server/Session/SessionFactoryInterface.php b/src/Server/Session/SessionFactoryInterface.php new file mode 100644 index 00000000..da6fa391 --- /dev/null +++ b/src/Server/Session/SessionFactoryInterface.php @@ -0,0 +1,26 @@ + + */ +interface SessionFactoryInterface +{ + /** + * Create a session with a specific UUID. + */ + public function create(Uuid $id, SessionStoreInterface $store): SessionInterface; + + /** + * Create a new session with a generated UUID. + */ + public function createNew(SessionStoreInterface $store): SessionInterface; +} diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php new file mode 100644 index 00000000..d74da02c --- /dev/null +++ b/src/Server/Session/SessionInterface.php @@ -0,0 +1,76 @@ + + */ +interface SessionInterface extends JsonSerializable +{ + /** + * Get the session ID. + */ + public function getId(): Uuid; + + /** + * Save the session. + */ + public function save(): void; + + /** + * Get a specific attribute from the session. + * Supports dot notation for nested access. + */ + public function get(string $key, mixed $default = null): mixed; + + /** + * Set a specific attribute in the session. + * Supports dot notation for nested access. + */ + public function set(string $key, mixed $value, bool $overwrite = true): void; + + /** + * Check if an attribute exists in the session. + * Supports dot notation for nested access. + */ + public function has(string $key): bool; + + /** + * Remove an attribute from the session. + * Supports dot notation for nested access. + */ + public function forget(string $key): void; + + /** + * Remove all attributes from the session. + */ + public function clear(): void; + + /** + * Get an attribute's value and then remove it from the session. + * Supports dot notation for nested access. + */ + public function pull(string $key, mixed $default = null): mixed; + + /** + * Get all attributes of the session. + */ + public function all(): array; + + /** + * Set all attributes of the session, typically for hydration. + * This will overwrite existing attributes. + */ + public function hydrate(array $attributes): void; + + /** + * Get the session store instance. + * + * @return SessionStoreInterface + */ + public function getStore(): SessionStoreInterface; +} diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php new file mode 100644 index 00000000..b50f7e65 --- /dev/null +++ b/src/Server/Session/SessionStoreInterface.php @@ -0,0 +1,41 @@ + + */ +interface SessionStoreInterface +{ + /** + * Read session data + * + * Returns an encoded string of the read data. + * If nothing was read, it must return false. + * @param Uuid $id The session id to read data for. + */ + public function read(Uuid $id): string|false; + + /** + * Write session data + * @param Uuid $id The session id. + * @param string $data The encoded session data. + */ + public function write(Uuid $id, string $data): bool; + + /** + * Destroy a session + * @param Uuid $id The session ID being destroyed. + * The return value (usually TRUE on success, FALSE on failure). + */ + public function destroy(Uuid $id): bool; + + /** + * Cleanup old sessions + * Sessions that have not updated for + * the last maxlifetime seconds will be removed. + */ + public function gc(int $maxLifetime): array; +} diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 757f6d8d..a530b426 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -128,13 +128,13 @@ protected function handlePostRequest(): ResponseInterface protected function handleGetRequest(): ResponseInterface { - $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 501); + $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); return $this->withCorsHeaders($response); } protected function handleDeleteRequest(): ResponseInterface { - $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 501); + $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); return $this->withCorsHeaders($response); } From 456480d6f6cd5165b25c5deaa9a40d49a3727902 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 14 Sep 2025 22:44:24 +0100 Subject: [PATCH 04/11] fix: ensure session elements are preserved when building server (regression from #46) --- src/Server/ServerBuilder.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index ca3c14a3..fde3390f 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -311,6 +311,10 @@ public function build(): Server $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); } + $sessionTtl = $this->sessionTtl ?? 3600; + $sessionFactory = $this->sessionFactory ?? new SessionFactory(); + $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + return new Server( jsonRpcHandler: Handler::make( registry: $registry, @@ -321,6 +325,9 @@ public function build(): Server promptGetter: $promptGetter, logger: $logger, ), + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, + sessionTtl: $sessionTtl, logger: $logger, ); } From 74e56a20275850eb0a299965230b5868f7bd94ac Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 14 Sep 2025 23:02:50 +0100 Subject: [PATCH 05/11] refactor: consolidate HTTP example to use shared dependencies --- composer.json | 8 ++- examples/10-simple-http-transport/.gitignore | 55 ------------------- .../{src => }/McpElements.php | 2 +- examples/10-simple-http-transport/README.md | 35 ++---------- .../10-simple-http-transport/composer.json | 31 ----------- .../{index.php => server.php} | 8 ++- 6 files changed, 17 insertions(+), 122 deletions(-) delete mode 100644 examples/10-simple-http-transport/.gitignore rename examples/10-simple-http-transport/{src => }/McpElements.php (98%) delete mode 100644 examples/10-simple-http-transport/composer.json rename examples/10-simple-http-transport/{index.php => server.php} (73%) diff --git a/composer.json b/composer.json index 34e6d751..051e49be 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,10 @@ "phpunit/phpunit": "^10.5", "psr/cache": "^3.0", "symfony/console": "^6.4 || ^7.3", - "symfony/process": "^6.4 || ^7.3" + "symfony/process": "^6.4 || ^7.3", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "laminas/laminas-httphandlerrunner": "^2.12" }, "autoload": { "psr-4": { @@ -54,10 +57,11 @@ "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", + "Mcp\\Example\\HttpTransportExample\\": "examples/10-simple-http-transport/", "Mcp\\Tests\\": "tests/" } }, "config": { "sort-packages": true } -} +} \ No newline at end of file diff --git a/examples/10-simple-http-transport/.gitignore b/examples/10-simple-http-transport/.gitignore deleted file mode 100644 index bdf5ce79..00000000 --- a/examples/10-simple-http-transport/.gitignore +++ /dev/null @@ -1,55 +0,0 @@ -# Composer dependencies -/vendor/ - -# Composer lock file (can be regenerated with composer install) -/composer.lock - -# PHP error logs and cache files -*.log -/error_log -/cache/ -/tmp/ - -# macOS system files -.DS_Store -.AppleDouble -.LSOverride -Icon - -# Windows system files -Thumbs.db -ehthumbs.db -Desktop.ini - -# Linux system files -*~ - -# IDE and editor files -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Environment files (may contain sensitive data) -.env -.env.local -.env.*.local - -# Temporary files -*.tmp -*.temp - -# Build and distribution files -/build/ -/dist/ - -# Node modules (if any frontend assets are added later) -/node_modules/ - -# Test coverage reports -/coverage/ -/phpunit.xml - -# PHPStan cache -/.phpstan.cache diff --git a/examples/10-simple-http-transport/src/McpElements.php b/examples/10-simple-http-transport/McpElements.php similarity index 98% rename from examples/10-simple-http-transport/src/McpElements.php rename to examples/10-simple-http-transport/McpElements.php index 1857347c..cc6c3b21 100644 --- a/examples/10-simple-http-transport/src/McpElements.php +++ b/examples/10-simple-http-transport/McpElements.php @@ -1,6 +1,6 @@ =8.1", - "mcp/sdk": "@dev", - "nyholm/psr7": "^1.8", - "nyholm/psr7-server": "^1.1", - "laminas/laminas-httphandlerrunner": "^2.12" - }, - "minimum-stability": "stable", - "autoload": { - "psr-4": { - "App\\": "src/" - } - }, - "repositories": [ - { - "type": "path", - "url": "../../" - } - ] -} diff --git a/examples/10-simple-http-transport/index.php b/examples/10-simple-http-transport/server.php similarity index 73% rename from examples/10-simple-http-transport/index.php rename to examples/10-simple-http-transport/server.php index f55ff8df..1a694e0f 100644 --- a/examples/10-simple-http-transport/index.php +++ b/examples/10-simple-http-transport/server.php @@ -1,6 +1,7 @@ fromGlobals(); $server = Server::make() - ->withServerInfo('HTTP MCP Server', '1.0.0') - ->withDiscovery(__DIR__, ['src']) + ->withServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport') + ->withContainer(container()) + ->withDiscovery(__DIR__, ['.']) ->build(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); From 3cf1838dc3340f0034899aac5e66ca33a1c97c39 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Mon, 15 Sep 2025 09:10:56 +0100 Subject: [PATCH 06/11] feat(server): Enhance message handling with session support - Updated `TransportInterface` to use `onMessage` for handling incoming messages with session IDs. - Refactored `Server`, `Handler`, and transport classes to accommodate session management using `Uuid`. - Introduced methods for creating sessions with auto-generated and specific UUIDs in `SessionFactory` and `SessionFactoryInterface`. --- src/JsonRpc/Handler.php | 7 ++-- src/Server.php | 9 +++--- src/Server/Session/SessionFactory.php | 8 ++--- .../Session/SessionFactoryInterface.php | 10 +++--- src/Server/Transport/StdioTransport.php | 32 ++++++------------- .../Transport/StreamableHttpTransport.php | 30 ++++++----------- src/Server/TransportInterface.php | 17 +++------- 7 files changed, 44 insertions(+), 69 deletions(-) diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 8699eab2..c7a908de 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -27,6 +27,7 @@ use Mcp\Server\MethodHandlerInterface; use Mcp\Server\NotificationHandler; use Mcp\Server\RequestHandler; +use Symfony\Component\Uid\Uuid; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -87,7 +88,7 @@ public static function make( * @throws ExceptionInterface When a handler throws an exception during message processing * @throws \JsonException When JSON encoding of the response fails */ - public function process(string $input): iterable + public function process(string $input, ?Uuid $sessionId): iterable { $this->logger->info('Received message to process.', ['message' => $input]); @@ -117,7 +118,9 @@ public function process(string $input): iterable } catch (\DomainException) { yield null; } catch (NotFoundExceptionInterface $e) { - $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e], + $this->logger->warning( + \sprintf('Failed to create response: %s', $e->getMessage()), + ['exception' => $e], ); yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage())); diff --git a/src/Server.php b/src/Server.php index 03b182ea..d887354a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -18,6 +18,7 @@ use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @author Christopher Hertel @@ -45,15 +46,15 @@ public function connect(TransportInterface $transport): void 'transport' => $transport::class, ]); - $transport->on('message', function (string $message) use ($transport) { - $this->handleMessage($message, $transport); + $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { + $this->handleMessage($message, $sessionId, $transport); }); } - private function handleMessage(string $message, TransportInterface $transport): void + private function handleMessage(string $message, ?Uuid $sessionId, TransportInterface $transport): void { try { - foreach ($this->jsonRpcHandler->process($message) as $response) { + foreach ($this->jsonRpcHandler->process($message, $sessionId) as $response) { if (null === $response) { continue; } diff --git a/src/Server/Session/SessionFactory.php b/src/Server/Session/SessionFactory.php index 2574a5af..15885b93 100644 --- a/src/Server/Session/SessionFactory.php +++ b/src/Server/Session/SessionFactory.php @@ -13,13 +13,13 @@ */ class SessionFactory implements SessionFactoryInterface { - public function create(Uuid $id, SessionStoreInterface $store): SessionInterface + public function create(SessionStoreInterface $store): SessionInterface { - return new Session($store, $id); + return new Session($store, Uuid::v4()); } - public function createNew(SessionStoreInterface $store): SessionInterface + public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface { - return $this->create(Uuid::v4(), $store); + return new Session($store, $id); } } diff --git a/src/Server/Session/SessionFactoryInterface.php b/src/Server/Session/SessionFactoryInterface.php index da6fa391..4c28d815 100644 --- a/src/Server/Session/SessionFactoryInterface.php +++ b/src/Server/Session/SessionFactoryInterface.php @@ -15,12 +15,14 @@ interface SessionFactoryInterface { /** - * Create a session with a specific UUID. + * Creates a new session with an auto-generated UUID. + * This is the standard factory method for creating sessions. */ - public function create(Uuid $id, SessionStoreInterface $store): SessionInterface; + public function create(SessionStoreInterface $store): SessionInterface; /** - * Create a new session with a generated UUID. + * Creates a session with a specific UUID. + * Use this when you need to reconstruct a session with a known ID. */ - public function createNew(SessionStoreInterface $store): SessionInterface; + public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface; } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index fef34f80..10d85a05 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -12,16 +12,14 @@ namespace Mcp\Server\Transport; use Mcp\Server\TransportInterface; -use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV4; use Psr\Log\NullLogger; +use Psr\Log\LoggerInterface; -/** - * Heavily inspired by https://jolicode.com/blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents. - */ class StdioTransport implements TransportInterface { - /** @var array> */ - private array $listeners = []; + private $messageListener; /** * @param resource $input @@ -30,27 +28,15 @@ class StdioTransport implements TransportInterface public function __construct( private $input = \STDIN, private $output = \STDOUT, - private readonly LoggerInterface $logger = new NullLogger(), + private readonly Uuid $sessionId = new UuidV4(), + private readonly LoggerInterface $logger = new NullLogger() ) {} public function initialize(): void {} - public function on(string $event, callable $listener): void + public function onMessage(callable $listener): void { - if (!isset($this->listeners[$event])) { - $this->listeners[$event] = []; - } - $this->listeners[$event][] = $listener; - } - - public function emit(string $event, mixed ...$args): void - { - if (!isset($this->listeners[$event])) { - return; - } - foreach ($this->listeners[$event] as $listener) { - $listener(...$args); - } + $this->messageListener = $listener; } public function send(string $data): void @@ -73,7 +59,7 @@ public function listen(): mixed $trimmedLine = trim($line); if (!empty($trimmedLine)) { $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); - $this->emit('message', $trimmedLine); + call_user_func($this->messageListener, $trimmedLine, $this->sessionId); } } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index a530b426..4180679e 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -19,14 +19,15 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @author Kyrian Obikwelu */ class StreamableHttpTransport implements TransportInterface { - /** @var array> */ - private array $listeners = []; + private $messageListener; + private ?Uuid $sessionId = null; /** @var string[] */ private array $outgoingMessages = []; @@ -42,27 +43,16 @@ public function __construct( private readonly ResponseFactoryInterface $responseFactory, private readonly StreamFactoryInterface $streamFactory, private readonly LoggerInterface $logger = new NullLogger() - ) {} + ) { + $sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id'); + $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; + } public function initialize(): void {} - public function on(string $event, callable $listener): void - { - if (!isset($this->listeners[$event])) { - $this->listeners[$event] = []; - } - $this->listeners[$event][] = $listener; - } - - public function emit(string $event, mixed ...$args): void + public function onMessage(callable $listener): void { - if (!isset($this->listeners[$event])) { - return; - } - - foreach ($this->listeners[$event] as $listener) { - $listener(...$args); - } + $this->messageListener = $listener; } public function send(string $data): void @@ -106,7 +96,7 @@ protected function handlePostRequest(): ResponseInterface return $this->createErrorResponse($error, 400); } - $this->emit('message', $body); + call_user_func($this->messageListener, $body, $this->sessionId); $hasRequestsInInput = str_contains($body, '"id"'); $hasResponsesInOutput = !empty($this->outgoingMessages); diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 2900b5db..641c69e1 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -11,6 +11,8 @@ namespace Mcp\Server; +use Symfony\Component\Uid\Uuid; + /** * @author Christopher Hertel * @author Kyrian Obikwelu @@ -23,20 +25,11 @@ interface TransportInterface public function initialize(): void; /** - * Registers an event listener for the specified event. - * - * @param string $event The event name to listen for - * @param callable $listener The callback function to execute when the event occurs - */ - public function on(string $event, callable $listener): void; - - /** - * Triggers an event and executes all registered listeners. + * Registers a callback that will be invoked whenever the transport receives an incoming message. * - * @param string $event The event name to emit - * @param mixed ...$args Variable number of arguments to pass to the listeners + * @param callable(string $message, ?Uuid $sessionId): void $listener The callback function to execute when the message occurs */ - public function emit(string $event, mixed ...$args): void; + public function onMessage(callable $listener): void; /** * Starts the transport's execution process. From 25bec57ba42fe4ac52955a96298e61541e0ca8d7 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Sep 2025 14:32:43 +0100 Subject: [PATCH 07/11] feat(server): Integrate session management in message handling - Added session support to the `Server` and `Handler` classes, allowing for session data to be managed during message processing. - Updated `TransportInterface` to include session context in the `send` method. - Refactored various request handlers to utilize session information, ensuring proper session handling for incoming requests. - Introduced a file-based session store for persistent session data management --- examples/10-simple-http-transport/.gitignore | 1 + examples/10-simple-http-transport/server.php | 2 + src/JsonRpc/Handler.php | 137 ++++++++++++++--- src/Server.php | 39 ++--- src/Server/MethodHandlerInterface.php | 3 +- .../InitializedHandler.php | 5 +- src/Server/RequestHandler/CallToolHandler.php | 6 +- .../RequestHandler/GetPromptHandler.php | 6 +- .../RequestHandler/InitializeHandler.php | 11 +- .../RequestHandler/ListPromptsHandler.php | 6 +- .../RequestHandler/ListResourcesHandler.php | 6 +- .../RequestHandler/ListToolsHandler.php | 6 +- src/Server/RequestHandler/PingHandler.php | 3 +- .../RequestHandler/ReadResourceHandler.php | 6 +- src/Server/ServerBuilder.php | 7 +- src/Server/Session/FileSessionStore.php | 145 ++++++++++++++++++ src/Server/Session/InMemorySessionStore.php | 9 +- src/Server/Session/SessionStoreInterface.php | 11 +- src/Server/Transport/StdioTransport.php | 33 +++- .../Transport/StreamableHttpTransport.php | 61 ++++++-- src/Server/TransportInterface.php | 13 +- 21 files changed, 415 insertions(+), 101 deletions(-) create mode 100644 examples/10-simple-http-transport/.gitignore create mode 100644 src/Server/Session/FileSessionStore.php diff --git a/examples/10-simple-http-transport/.gitignore b/examples/10-simple-http-transport/.gitignore new file mode 100644 index 00000000..38bfd77a --- /dev/null +++ b/examples/10-simple-http-transport/.gitignore @@ -0,0 +1 @@ +sessions/ \ No newline at end of file diff --git a/examples/10-simple-http-transport/server.php b/examples/10-simple-http-transport/server.php index 1a694e0f..2490e090 100644 --- a/examples/10-simple-http-transport/server.php +++ b/examples/10-simple-http-transport/server.php @@ -8,6 +8,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7Server\ServerRequestCreator; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Server\Session\FileSessionStore; $psr17Factory = new Psr17Factory(); $creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); @@ -17,6 +18,7 @@ $server = Server::make() ->withServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport') ->withContainer(container()) + ->withSession(new FileSessionStore(__DIR__ . '/sessions')) ->withDiscovery(__DIR__, ['.']) ->build(); diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index c7a908de..a06ce520 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -25,8 +25,12 @@ use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; use Mcp\Server\NotificationHandler; use Mcp\Server\RequestHandler; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Schema\Request\InitializeRequest; use Symfony\Component\Uid\Uuid; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -48,6 +52,8 @@ class Handler */ public function __construct( private readonly MessageFactory $messageFactory, + private readonly SessionFactoryInterface $sessionFactory, + private readonly SessionStoreInterface $sessionStore, iterable $methodHandlers, private readonly LoggerInterface $logger = new NullLogger(), ) { @@ -63,10 +69,14 @@ public static function make( ToolCallerInterface $toolCaller, ResourceReaderInterface $resourceReader, PromptGetterInterface $promptGetter, + SessionStoreInterface $sessionStore, + SessionFactoryInterface $sessionFactory, LoggerInterface $logger = new NullLogger(), ): self { return new self( messageFactory: MessageFactory::make(), + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, methodHandlers: [ new NotificationHandler\InitializedHandler(), new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), @@ -83,7 +93,7 @@ public static function make( } /** - * @return iterable + * @return iterable}> * * @throws ExceptionInterface When a handler throws an exception during message processing * @throws \JsonException When JSON encoding of the response fails @@ -92,20 +102,65 @@ public function process(string $input, ?Uuid $sessionId): iterable { $this->logger->info('Received message to process.', ['message' => $input]); + $this->runGarbageCollection(); + try { - $messages = $this->messageFactory->create($input); + $messages = iterator_to_array($this->messageFactory->create($input)); } catch (\JsonException $e) { $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - - yield $this->encodeResponse(Error::forParseError($e->getMessage())); + $error = Error::forParseError($e->getMessage()); + yield [$this->encodeResponse($error), []]; return; } + $hasInitializeRequest = false; + foreach ($messages as $message) { + if ($message instanceof InitializeRequest) { + $hasInitializeRequest = true; + break; + } + } + + $session = null; + + if ($hasInitializeRequest) { + // Spec: An initialize request must not be part of a batch. + if (count($messages) > 1) { + $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); + yield [$this->encodeResponse($error), []]; + return; + } + + // Spec: An initialize request must not have a session ID. + if ($sessionId) { + $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); + yield [$this->encodeResponse($error), []]; + return; + } + + $session = $this->sessionFactory->create($this->sessionStore); + } else { + if (!$sessionId) { + $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); + yield [$this->encodeResponse($error), ['status_code' => 400]]; + return; + } + + if (!$this->sessionStore->exists($sessionId)) { + $error = Error::forInvalidRequest('Session not found or has expired.'); + yield [$this->encodeResponse($error), ['status_code' => 404]]; + return; + } + + $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + } + foreach ($messages as $message) { if ($message instanceof InvalidInputMessageException) { $this->logger->warning('Failed to create message.', ['exception' => $message]); - yield $this->encodeResponse(Error::forInvalidRequest($message->getMessage(), 0)); + $error = Error::forInvalidRequest($message->getMessage(), 0); + yield [$this->encodeResponse($error), []]; continue; } @@ -114,26 +169,32 @@ public function process(string $input, ?Uuid $sessionId): iterable ]); try { - yield $this->encodeResponse($this->handle($message)); + $response = $this->encodeResponse($this->handle($message, $session)); + yield [$response, ['session_id' => $session->getId()]]; } catch (\DomainException) { - yield null; + yield [null, []]; } catch (NotFoundExceptionInterface $e) { $this->logger->warning( \sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e], ); - yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage())); + $error = Error::forMethodNotFound($e->getMessage()); + yield [$this->encodeResponse($error), []]; } catch (\InvalidArgumentException $e) { $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - yield $this->encodeResponse(Error::forInvalidParams($e->getMessage())); + $error = Error::forInvalidParams($e->getMessage()); + yield [$this->encodeResponse($error), []]; } catch (\Throwable $e) { $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - yield $this->encodeResponse(Error::forInternalError($e->getMessage())); + $error = Error::forInternalError($e->getMessage()); + yield [$this->encodeResponse($error), []]; } } + + $session->save(); } /** @@ -162,7 +223,7 @@ private function encodeResponse(Response|Error|null $response): ?string * @throws NotFoundExceptionInterface When no handler is found for the request method * @throws ExceptionInterface When a request handler throws an exception */ - private function handle(HasMethodInterface $message): Response|Error|null + private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null { $this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [ 'message' => $message, @@ -170,18 +231,20 @@ private function handle(HasMethodInterface $message): Response|Error|null $handled = false; foreach ($this->methodHandlers as $handler) { - if ($handler->supports($message)) { - $return = $handler->handle($message); - $handled = true; - - $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ - 'method' => $message::getMethod(), - 'response' => $return, - ]); - - if (null !== $return) { - return $return; - } + if (!$handler->supports($message)) { + continue; + } + + $return = $handler->handle($message, $session); + $handled = true; + + $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ + 'method' => $message::getMethod(), + 'response' => $return, + ]); + + if (null !== $return) { + return $return; } } @@ -191,4 +254,32 @@ private function handle(HasMethodInterface $message): Response|Error|null throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod())); } + + /** + * Run garbage collection on expired sessions. + * Uses the session store's internal TTL configuration. + */ + private function runGarbageCollection(): void + { + if (random_int(0, 100) > 1) { + return; + } + + $deletedSessions = $this->sessionStore->gc(); + if (!empty($deletedSessions)) { + $this->logger->debug('Garbage collected expired sessions.', [ + 'count' => count($deletedSessions), + 'session_ids' => array_map(fn(Uuid $id) => $id->toRfc4122(), $deletedSessions), + ]); + } + } + + /** + * Destroy a specific session. + */ + public function destroySession(Uuid $sessionId): void + { + $this->sessionStore->destroy($sessionId); + $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); + } } diff --git a/src/Server.php b/src/Server.php index d887354a..d6082df6 100644 --- a/src/Server.php +++ b/src/Server.php @@ -13,8 +13,6 @@ use Mcp\JsonRpc\Handler; use Mcp\Server\ServerBuilder; -use Mcp\Server\Session\SessionFactoryInterface; -use Mcp\Server\Session\SessionStoreInterface; use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -22,14 +20,12 @@ /** * @author Christopher Hertel + * @author Kyrian Obikwelu */ final class Server { public function __construct( private readonly Handler $jsonRpcHandler, - private readonly SessionFactoryInterface $sessionFactory, - private readonly SessionStoreInterface $sessionStore, - private readonly int $sessionTtl, private readonly LoggerInterface $logger = new NullLogger(), ) {} @@ -47,25 +43,24 @@ public function connect(TransportInterface $transport): void ]); $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { - $this->handleMessage($message, $sessionId, $transport); - }); - } + try { + foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { + if (null === $response) { + continue; + } - private function handleMessage(string $message, ?Uuid $sessionId, TransportInterface $transport): void - { - try { - foreach ($this->jsonRpcHandler->process($message, $sessionId) as $response) { - if (null === $response) { - continue; + $transport->send($response, $context); } - - $transport->send($response); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message' => $message, + 'exception' => $e, + ]); } - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message' => $message, - 'exception' => $e, - ]); - } + }); + + $transport->onSessionEnd(function (Uuid $sessionId) { + $this->jsonRpcHandler->destroySession($sessionId); + }); } } diff --git a/src/Server/MethodHandlerInterface.php b/src/Server/MethodHandlerInterface.php index 7f949bb1..4abca854 100644 --- a/src/Server/MethodHandlerInterface.php +++ b/src/Server/MethodHandlerInterface.php @@ -16,6 +16,7 @@ use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -27,5 +28,5 @@ public function supports(HasMethodInterface $message): bool; /** * @throws ExceptionInterface When the handler encounters an error processing the request */ - public function handle(HasMethodInterface $message): Response|Error|null; + public function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null; } diff --git a/src/Server/NotificationHandler/InitializedHandler.php b/src/Server/NotificationHandler/InitializedHandler.php index f04a08a9..55af7759 100644 --- a/src/Server/NotificationHandler/InitializedHandler.php +++ b/src/Server/NotificationHandler/InitializedHandler.php @@ -16,6 +16,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\InitializedNotification; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -27,8 +28,10 @@ public function supports(HasMethodInterface $message): bool return $message instanceof InitializedNotification; } - public function handle(InitializedNotification|HasMethodInterface $message): Response|Error|null + public function handle(InitializedNotification|HasMethodInterface $message, SessionInterface $session): Response|Error|null { + $session->set('initialized', true); + return null; } } diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index 28aab382..5e5157b4 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -30,15 +31,14 @@ final class CallToolHandler implements MethodHandlerInterface public function __construct( private readonly ToolCallerInterface $toolCaller, private readonly LoggerInterface $logger = new NullLogger(), - ) { - } + ) {} public function supports(HasMethodInterface $message): bool { return $message instanceof CallToolRequest; } - public function handle(CallToolRequest|HasMethodInterface $message): Response|Error + public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error { \assert($message instanceof CallToolRequest); diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/RequestHandler/GetPromptHandler.php index 1ac0a3ff..5715949d 100644 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ b/src/Server/RequestHandler/GetPromptHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -26,15 +27,14 @@ final class GetPromptHandler implements MethodHandlerInterface { public function __construct( private readonly PromptGetterInterface $promptGetter, - ) { - } + ) {} public function supports(HasMethodInterface $message): bool { return $message instanceof GetPromptRequest; } - public function handle(GetPromptRequest|HasMethodInterface $message): Response|Error + public function handle(GetPromptRequest|HasMethodInterface $message, SessionInterface $session): Response|Error { \assert($message instanceof GetPromptRequest); diff --git a/src/Server/RequestHandler/InitializeHandler.php b/src/Server/RequestHandler/InitializeHandler.php index 11d9b0ab..28066c22 100644 --- a/src/Server/RequestHandler/InitializeHandler.php +++ b/src/Server/RequestHandler/InitializeHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -27,19 +28,21 @@ final class InitializeHandler implements MethodHandlerInterface public function __construct( public readonly ?ServerCapabilities $capabilities = new ServerCapabilities(), public readonly ?Implementation $serverInfo = new Implementation(), - ) { - } + ) {} public function supports(HasMethodInterface $message): bool { return $message instanceof InitializeRequest; } - public function handle(InitializeRequest|HasMethodInterface $message): Response + public function handle(InitializeRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof InitializeRequest); - return new Response($message->getId(), + $session->set('client_info', $message->clientInfo->jsonSerialize()); + + return new Response( + $message->getId(), new InitializeResult($this->capabilities, $this->serverInfo), ); } diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index 2bf479c9..a01e0167 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -17,6 +17,7 @@ use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -26,15 +27,14 @@ final class ListPromptsHandler implements MethodHandlerInterface public function __construct( private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, - ) { - } + ) {} public function supports(HasMethodInterface $message): bool { return $message instanceof ListPromptsRequest; } - public function handle(ListPromptsRequest|HasMethodInterface $message): Response + public function handle(ListPromptsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListPromptsRequest); diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index 212f4f00..8aab80df 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -17,6 +17,7 @@ use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Result\ListResourcesResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -26,15 +27,14 @@ final class ListResourcesHandler implements MethodHandlerInterface public function __construct( private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, - ) { - } + ) {} public function supports(HasMethodInterface $message): bool { return $message instanceof ListResourcesRequest; } - public function handle(ListResourcesRequest|HasMethodInterface $message): Response + public function handle(ListResourcesRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListResourcesRequest); diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index eb49e0d9..e9e0ca90 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -17,6 +17,7 @@ use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -27,15 +28,14 @@ final class ListToolsHandler implements MethodHandlerInterface public function __construct( private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, - ) { - } + ) {} public function supports(HasMethodInterface $message): bool { return $message instanceof ListToolsRequest; } - public function handle(ListToolsRequest|HasMethodInterface $message): Response + public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListToolsRequest); diff --git a/src/Server/RequestHandler/PingHandler.php b/src/Server/RequestHandler/PingHandler.php index 2cf8ec91..30701332 100644 --- a/src/Server/RequestHandler/PingHandler.php +++ b/src/Server/RequestHandler/PingHandler.php @@ -16,6 +16,7 @@ use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -27,7 +28,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof PingRequest; } - public function handle(PingRequest|HasMethodInterface $message): Response + public function handle(PingRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof PingRequest); diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php index 9c80d2b1..bbe0eafd 100644 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ b/src/Server/RequestHandler/ReadResourceHandler.php @@ -19,6 +19,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -27,15 +28,14 @@ final class ReadResourceHandler implements MethodHandlerInterface { public function __construct( private readonly ResourceReaderInterface $resourceReader, - ) { - } + ) {} public function supports(HasMethodInterface $message): bool { return $message instanceof ReadResourceRequest; } - public function handle(ReadResourceRequest|HasMethodInterface $message): Response|Error + public function handle(ReadResourceRequest|HasMethodInterface $message, SessionInterface $session): Response|Error { \assert($message instanceof ReadResourceRequest); diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index fde3390f..fdaad8f7 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -201,8 +201,8 @@ public function setContainer(ContainerInterface $container): self } public function withSession( - SessionFactoryInterface $sessionFactory, SessionStoreInterface $sessionStore, + SessionFactoryInterface $sessionFactory = new SessionFactory(), int $ttl = 3600 ): self { $this->sessionFactory = $sessionFactory; @@ -323,11 +323,10 @@ public function build(): Server toolCaller: $toolCaller, resourceReader: $resourceReader, promptGetter: $promptGetter, + sessionStore: $sessionStore, + sessionFactory: $sessionFactory, logger: $logger, ), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - sessionTtl: $sessionTtl, logger: $logger, ); } diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php new file mode 100644 index 00000000..32de126c --- /dev/null +++ b/src/Server/Session/FileSessionStore.php @@ -0,0 +1,145 @@ +directory)) { + @mkdir($this->directory, 0775, true); + } + + if (!is_dir($this->directory) || !is_writable($this->directory)) { + throw new \RuntimeException(sprintf('Session directory "%s" is not writable.', $this->directory)); + } + } + + public function exists(Uuid $id): bool + { + $path = $this->pathFor($id); + + if (!is_file($path)) { + return false; + } + + $mtime = @filemtime($path) ?: 0; + return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl; + } + + public function read(Uuid $sessionId): string|false + { + $path = $this->pathFor($sessionId); + + if (!is_file($path)) { + return false; + } + + $mtime = @filemtime($path) ?: 0; + if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { + @unlink($path); + return false; + } + + $data = @file_get_contents($path); + if ($data === false) { + return false; + } + + return $data; + } + + public function write(Uuid $sessionId, string $data): bool + { + $path = $this->pathFor($sessionId); + + $tmp = $path . '.tmp'; + if (@file_put_contents($tmp, $data, LOCK_EX) === false) { + return false; + } + + // Atomic move + if (!@rename($tmp, $path)) { + // Fallback if rename fails cross-device + if (@copy($tmp, $path) === false) { + @unlink($tmp); + return false; + } + @unlink($tmp); + } + + @touch($path, $this->clock->now()->getTimestamp()); + + return true; + } + + public function destroy(Uuid $sessionId): bool + { + $path = $this->pathFor($sessionId); + + if (is_file($path)) { + @unlink($path); + } + + return true; + } + + /** + * Remove sessions older than the configured TTL. + * Returns an array of deleted session IDs (UUID instances). + */ + public function gc(): array + { + $deleted = []; + $now = $this->clock->now()->getTimestamp(); + + $dir = @opendir($this->directory); + if ($dir === false) { + return $deleted; + } + + while (($entry = readdir($dir)) !== false) { + // Skip dot entries + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $this->directory . DIRECTORY_SEPARATOR . $entry; + if (!is_file($path)) { + continue; + } + + $mtime = @filemtime($path) ?: 0; + if (($now - $mtime) > $this->ttl) { + @unlink($path); + try { + $deleted[] = Uuid::fromString($entry); + } catch (\Throwable) { + // ignore non-UUID file names + } + } + } + + closedir($dir); + + return $deleted; + } + + private function pathFor(Uuid $id): string + { + return $this->directory . DIRECTORY_SEPARATOR . $id->toRfc4122(); + } +} diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php index c73f9f68..fcc7c525 100644 --- a/src/Server/Session/InMemorySessionStore.php +++ b/src/Server/Session/InMemorySessionStore.php @@ -21,6 +21,11 @@ public function __construct( protected readonly ClockInterface $clock = new NativeClock(), ) {} + public function exists(Uuid $id): bool + { + return isset($this->store[$id->toRfc4122()]); + } + public function read(Uuid $sessionId): string|false { $session = $this->store[$sessionId->toRfc4122()] ?? ''; @@ -57,14 +62,14 @@ public function destroy(Uuid $sessionId): bool return true; } - public function gc(int $maxLifetime): array + public function gc(): array { $currentTimestamp = $this->clock->now()->getTimestamp(); $deletedSessions = []; foreach ($this->store as $sessionId => $session) { $sessionId = Uuid::fromString($sessionId); - if ($currentTimestamp - $session['timestamp'] > $maxLifetime) { + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { unset($this->store[$sessionId->toRfc4122()]); $deletedSessions[] = $sessionId; } diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php index b50f7e65..e6dda139 100644 --- a/src/Server/Session/SessionStoreInterface.php +++ b/src/Server/Session/SessionStoreInterface.php @@ -9,6 +9,13 @@ */ interface SessionStoreInterface { + /** + * Check if a session exists + * @param Uuid $id The session id. + * @return bool True if the session exists, false otherwise. + */ + public function exists(Uuid $id): bool; + /** * Read session data * @@ -35,7 +42,7 @@ public function destroy(Uuid $id): bool; /** * Cleanup old sessions * Sessions that have not updated for - * the last maxlifetime seconds will be removed. + * the configured TTL will be removed. */ - public function gc(int $maxLifetime): array; + public function gc(): array; } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 10d85a05..376c6364 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -13,13 +13,18 @@ use Mcp\Server\TransportInterface; use Symfony\Component\Uid\Uuid; -use Symfony\Component\Uid\UuidV4; use Psr\Log\NullLogger; use Psr\Log\LoggerInterface; +/** + * @author Kyrian Obikwelu + */ class StdioTransport implements TransportInterface { - private $messageListener; + private $messageListener = null; + private $sessionEndListener = null; + + private ?Uuid $sessionId = null; /** * @param resource $input @@ -28,7 +33,6 @@ class StdioTransport implements TransportInterface public function __construct( private $input = \STDIN, private $output = \STDOUT, - private readonly Uuid $sessionId = new UuidV4(), private readonly LoggerInterface $logger = new NullLogger() ) {} @@ -39,10 +43,14 @@ public function onMessage(callable $listener): void $this->messageListener = $listener; } - public function send(string $data): void + public function send(string $data, array $context): void { $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } + fwrite($this->output, $data . \PHP_EOL); } @@ -59,17 +67,32 @@ public function listen(): mixed $trimmedLine = trim($line); if (!empty($trimmedLine)) { $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); - call_user_func($this->messageListener, $trimmedLine, $this->sessionId); + if (is_callable($this->messageListener)) { + call_user_func($this->messageListener, $trimmedLine, $this->sessionId); + } } } $this->logger->info('StdioTransport finished listening.'); + if (is_callable($this->sessionEndListener)) { + call_user_func($this->sessionEndListener, $this->sessionId); + } + return null; } + public function onSessionEnd(callable $listener): void + { + $this->sessionEndListener = $listener; + } + public function close(): void { + if (is_callable($this->sessionEndListener)) { + call_user_func($this->sessionEndListener, $this->sessionId); + } + if (is_resource($this->input)) { fclose($this->input); } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 4180679e..03e4bb0d 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -26,11 +26,15 @@ */ class StreamableHttpTransport implements TransportInterface { - private $messageListener; + private $messageListener = null; + private $sessionEndListener = null; + private ?Uuid $sessionId = null; /** @var string[] */ private array $outgoingMessages = []; + private ?Uuid $outgoingSessionId = null; + private ?int $outgoingStatusCode = null; private array $corsHeaders = [ 'Access-Control-Allow-Origin' => '*', @@ -50,19 +54,21 @@ public function __construct( public function initialize(): void {} - public function onMessage(callable $listener): void - { - $this->messageListener = $listener; - } - - public function send(string $data): void + public function send(string $data, array $context): void { $this->outgoingMessages[] = $data; + + if (isset($context['session_id'])) { + $this->outgoingSessionId = $context['session_id']; + } + + if (isset($context['status_code']) && \is_int($context['status_code'])) { + $this->outgoingStatusCode = $context['status_code']; + } } public function listen(): mixed { - return match ($this->request->getMethod()) { 'OPTIONS' => $this->handleOptionsRequest(), 'GET' => $this->handleGetRequest(), @@ -72,6 +78,16 @@ public function listen(): mixed }; } + public function onMessage(callable $listener): void + { + $this->messageListener = $listener; + } + + public function onSessionEnd(callable $listener): void + { + $this->sessionEndListener = $listener; + } + protected function handleOptionsRequest(): ResponseInterface { return $this->withCorsHeaders($this->responseFactory->createResponse(204)); @@ -96,12 +112,11 @@ protected function handlePostRequest(): ResponseInterface return $this->createErrorResponse($error, 400); } - call_user_func($this->messageListener, $body, $this->sessionId); - - $hasRequestsInInput = str_contains($body, '"id"'); - $hasResponsesInOutput = !empty($this->outgoingMessages); + if (is_callable($this->messageListener)) { + call_user_func($this->messageListener, $body, $this->sessionId); + } - if ($hasRequestsInInput && !$hasResponsesInOutput) { + if (empty($this->outgoingMessages)) { return $this->withCorsHeaders($this->responseFactory->createResponse(202)); } @@ -109,10 +124,16 @@ protected function handlePostRequest(): ResponseInterface ? $this->outgoingMessages[0] : '[' . implode(',', $this->outgoingMessages) . ']'; - $response = $this->responseFactory->createResponse(200) + $status = $this->outgoingStatusCode ?? 200; + + $response = $this->responseFactory->createResponse($status) ->withHeader('Content-Type', 'application/json') ->withBody($this->streamFactory->createStream($responseBody)); + if ($this->outgoingSessionId) { + $response = $response->withHeader('Mcp-Session-Id', $this->outgoingSessionId->toRfc4122()); + } + return $this->withCorsHeaders($response); } @@ -124,8 +145,16 @@ protected function handleGetRequest(): ResponseInterface protected function handleDeleteRequest(): ResponseInterface { - $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); - return $this->withCorsHeaders($response); + if (!$this->sessionId) { + $error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.'); + return $this->createErrorResponse($error, 400); + } + + if (is_callable($this->sessionEndListener)) { + call_user_func($this->sessionEndListener, $this->sessionId); + } + + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); } protected function handleUnsupportedRequest(): ResponseInterface diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 641c69e1..4ec71fad 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -11,7 +11,6 @@ namespace Mcp\Server; -use Symfony\Component\Uid\Uuid; /** * @author Christopher Hertel @@ -46,8 +45,18 @@ public function listen(): mixed; * Sends a raw JSON-RPC message string back to the client. * * @param string $data The JSON-RPC message string to send + * @param array $context The context of the message */ - public function send(string $data): void; + public function send(string $data, array $context): void; + + + /** + * Registers a callback that will be invoked when a session needs to be destroyed. + * This can happen when a client disconnects or explicitly ends their session. + * + * @param callable(Uuid $sessionId): void $listener The callback function to execute when destroying a session + */ + public function onSessionEnd(callable $listener): void; /** * Closes the transport and cleans up any resources. From 1536db2671e68cd2aa05c575191cce48683e7432 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Sep 2025 14:41:24 +0100 Subject: [PATCH 08/11] feat: Update session builder method to use set* instead of with* --- examples/01-discovery-stdio-calculator/server.php | 8 ++++---- examples/10-simple-http-transport/server.php | 8 ++++---- src/Server/ServerBuilder.php | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php index ddd251dd..6320e5d5 100644 --- a/examples/01-discovery-stdio-calculator/server.php +++ b/examples/01-discovery-stdio-calculator/server.php @@ -19,10 +19,10 @@ logger()->info('Starting MCP Stdio Calculator Server...'); $server = Server::make() - ->withServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') - ->withContainer(container()) - ->withLogger(logger()) - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->setContainer(container()) + ->setLogger(logger()) + ->setDiscovery(__DIR__, ['.']) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/examples/10-simple-http-transport/server.php b/examples/10-simple-http-transport/server.php index 2490e090..fd285126 100644 --- a/examples/10-simple-http-transport/server.php +++ b/examples/10-simple-http-transport/server.php @@ -16,10 +16,10 @@ $request = $creator->fromGlobals(); $server = Server::make() - ->withServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport') - ->withContainer(container()) - ->withSession(new FileSessionStore(__DIR__ . '/sessions')) - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport') + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->setDiscovery(__DIR__, ['.']) ->build(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index fdaad8f7..bd5ea02e 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -200,7 +200,7 @@ public function setContainer(ContainerInterface $container): self return $this; } - public function withSession( + public function setSession( SessionStoreInterface $sessionStore, SessionFactoryInterface $sessionFactory = new SessionFactory(), int $ttl = 3600 From 468b9ffda426939808382cc461fe5c3ba5c80c84 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Sep 2025 15:47:27 +0100 Subject: [PATCH 09/11] feat: Fix test compatibility with new session management architecture --- src/Server/Transport/InMemoryTransport.php | 39 +++++++++++--- src/Server/Transport/StdioTransport.php | 4 +- tests/JsonRpc/HandlerTest.php | 43 +++++++++++++-- .../RequestHandler/CallToolHandlerTest.php | 53 ++++++++++--------- .../RequestHandler/GetPromptHandlerTest.php | 27 +++++----- .../Server/RequestHandler/PingHandlerTest.php | 23 ++++---- .../ReadResourceHandlerTest.php | 31 ++++++----- tests/ServerTest.php | 9 +++- 8 files changed, 152 insertions(+), 77 deletions(-) diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php index 015c70c5..001175bc 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -12,6 +12,7 @@ namespace Mcp\Server\Transport; use Mcp\Server\TransportInterface; +use Symfony\Component\Uid\Uuid; /** * @author Tobias Nyholm @@ -19,35 +20,57 @@ class InMemoryTransport implements TransportInterface { private bool $connected = true; + private $messageListener = null; + private $sessionDestroyListener = null; + private ?Uuid $sessionId = null; /** * @param list $messages */ public function __construct( private readonly array $messages = [], - ) { - } + ) {} + + public function initialize(): void {} - public function initialize(): void + public function onMessage(callable $listener): void { + $this->messageListener = $listener; } - public function isConnected(): bool + public function send(string $data, array $context): void { - return $this->connected; + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } } - public function receive(): \Generator + public function listen(): mixed { - yield from $this->messages; + foreach ($this->messages as $message) { + if (is_callable($this->messageListener)) { + call_user_func($this->messageListener, $message, $this->sessionId); + } + } + $this->connected = false; + + if (is_callable($this->sessionDestroyListener) && $this->sessionId !== null) { + call_user_func($this->sessionDestroyListener, $this->sessionId); + } + + return null; } - public function send(string $data): void + public function onSessionEnd(callable $listener): void { + $this->sessionDestroyListener = $listener; } public function close(): void { + if (is_callable($this->sessionDestroyListener) && $this->sessionId !== null) { + call_user_func($this->sessionDestroyListener, $this->sessionId); + } } } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 376c6364..c8076b61 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -75,7 +75,7 @@ public function listen(): mixed $this->logger->info('StdioTransport finished listening.'); - if (is_callable($this->sessionEndListener)) { + if (is_callable($this->sessionEndListener) && $this->sessionId !== null) { call_user_func($this->sessionEndListener, $this->sessionId); } @@ -89,7 +89,7 @@ public function onSessionEnd(callable $listener): void public function close(): void { - if (is_callable($this->sessionEndListener)) { + if (is_callable($this->sessionEndListener) && $this->sessionId !== null) { call_user_func($this->sessionEndListener, $this->sessionId); } diff --git a/tests/JsonRpc/HandlerTest.php b/tests/JsonRpc/HandlerTest.php index a2fdeec9..606a7324 100644 --- a/tests/JsonRpc/HandlerTest.php +++ b/tests/JsonRpc/HandlerTest.php @@ -15,8 +15,13 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Response; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; class HandlerTest extends TestCase { @@ -44,9 +49,24 @@ public function testHandleMultipleNotifications() $handlerC->method('supports')->willReturn(true); $handlerC->expects($this->once())->method('handle'); - $jsonRpc = new Handler(MessageFactory::make(), [$handlerA, $handlerB, $handlerC]); + $sessionFactory = $this->createMock(SessionFactoryInterface::class); + $sessionStore = $this->createMock(SessionStoreInterface::class); + $session = $this->createMock(SessionInterface::class); + + $sessionFactory->method('create')->willReturn($session); + $sessionFactory->method('createWithId')->willReturn($session); + $sessionStore->method('exists')->willReturn(true); + + $jsonRpc = new Handler( + MessageFactory::make(), + $sessionFactory, + $sessionStore, + [$handlerA, $handlerB, $handlerC] + ); + $sessionId = Uuid::v4(); $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "method": "notifications/initialized"}' + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId ); iterator_to_array($result); } @@ -75,9 +95,24 @@ public function testHandleMultipleRequests() $handlerC->method('supports')->willReturn(true); $handlerC->expects($this->never())->method('handle'); - $jsonRpc = new Handler(MessageFactory::make(), [$handlerA, $handlerB, $handlerC]); + $sessionFactory = $this->createMock(SessionFactoryInterface::class); + $sessionStore = $this->createMock(SessionStoreInterface::class); + $session = $this->createMock(SessionInterface::class); + + $sessionFactory->method('create')->willReturn($session); + $sessionFactory->method('createWithId')->willReturn($session); + $sessionStore->method('exists')->willReturn(true); + + $jsonRpc = new Handler( + MessageFactory::make(), + $sessionFactory, + $sessionStore, + [$handlerA, $handlerB, $handlerC] + ); + $sessionId = Uuid::v4(); $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId ); iterator_to_array($result); } diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php index e8f13622..a85443d6 100644 --- a/tests/Server/RequestHandler/CallToolHandlerTest.php +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -20,6 +20,7 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Server\RequestHandler\CallToolHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -27,16 +28,18 @@ class CallToolHandlerTest extends TestCase { private CallToolHandler $handler; - private ToolCallerInterface|MockObject $toolExecutor; + private ToolCallerInterface|MockObject $toolCaller; private LoggerInterface|MockObject $logger; + private SessionInterface|MockObject $session; protected function setUp(): void { - $this->toolExecutor = $this->createMock(ToolCallerInterface::class); + $this->toolCaller = $this->createMock(ToolCallerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->session = $this->createMock(SessionInterface::class); $this->handler = new CallToolHandler( - $this->toolExecutor, + $this->toolCaller, $this->logger, ); } @@ -53,7 +56,7 @@ public function testHandleSuccessfulToolCall(): void $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) @@ -63,7 +66,7 @@ public function testHandleSuccessfulToolCall(): void ->expects($this->never()) ->method('error'); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -75,13 +78,13 @@ public function testHandleToolCallWithEmptyArguments(): void $request = $this->createCallToolRequest('simple_tool', []); $expectedResult = new CallToolResult([new TextContent('Simple result')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -99,13 +102,13 @@ public function testHandleToolCallWithComplexArguments(): void $request = $this->createCallToolRequest('complex_tool', $arguments); $expectedResult = new CallToolResult([new TextContent('Complex result')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -116,7 +119,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); $exception = new ToolNotFoundException($request); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) @@ -133,7 +136,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void ], ); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -146,7 +149,7 @@ public function testHandleToolExecutionExceptionReturnsError(): void $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) @@ -163,7 +166,7 @@ public function testHandleToolExecutionExceptionReturnsError(): void ], ); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -176,13 +179,13 @@ public function testHandleWithNullResult(): void $request = $this->createCallToolRequest('null_tool', []); $expectedResult = new CallToolResult([]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -193,13 +196,13 @@ public function testHandleWithErrorResult(): void $request = $this->createCallToolRequest('error_tool', []); $expectedResult = CallToolResult::error([new TextContent('Tool error occurred')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -208,7 +211,7 @@ public function testHandleWithErrorResult(): void public function testConstructorWithDefaultLogger(): void { - $handler = new CallToolHandler($this->toolExecutor); + $handler = new CallToolHandler($this->toolCaller); $this->assertInstanceOf(CallToolHandler::class, $handler); } @@ -218,7 +221,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->willThrowException($exception); @@ -234,7 +237,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void ], ); - $this->handler->handle($request); + $this->handler->handle($request, $this->session); } public function testHandleWithSpecialCharactersInToolName(): void @@ -242,13 +245,13 @@ public function testHandleWithSpecialCharactersInToolName(): void $request = $this->createCallToolRequest('tool-with_special.chars', []); $expectedResult = new CallToolResult([new TextContent('Special tool result')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -264,13 +267,13 @@ public function testHandleWithSpecialCharactersInArguments(): void $request = $this->createCallToolRequest('unicode_tool', $arguments); $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -284,7 +287,7 @@ private function createCallToolRequest(string $name, array $arguments): CallTool return CallToolRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => CallToolRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), + 'id' => 'test-request-' . uniqid(), 'params' => [ 'name' => $name, 'arguments' => $arguments, diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php index 3debaa05..f8b9886d 100644 --- a/tests/Server/RequestHandler/GetPromptHandlerTest.php +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -22,6 +22,7 @@ use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\RequestHandler\GetPromptHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -29,10 +30,12 @@ class GetPromptHandlerTest extends TestCase { private GetPromptHandler $handler; private PromptGetterInterface|MockObject $promptGetter; + private SessionInterface|MockObject $session; protected function setUp(): void { $this->promptGetter = $this->createMock(PromptGetterInterface::class); + $this->session = $this->createMock(SessionInterface::class); $this->handler = new GetPromptHandler($this->promptGetter); } @@ -61,7 +64,7 @@ public function testHandleSuccessfulPromptGet(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -93,7 +96,7 @@ public function testHandlePromptGetWithArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -116,7 +119,7 @@ public function testHandlePromptGetWithNullArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -139,7 +142,7 @@ public function testHandlePromptGetWithEmptyArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -164,7 +167,7 @@ public function testHandlePromptGetWithMultipleMessages(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -182,7 +185,7 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -201,7 +204,7 @@ public function testHandlePromptGetExceptionReturnsError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -241,7 +244,7 @@ public function testHandlePromptGetWithComplexArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -269,7 +272,7 @@ public function testHandlePromptGetWithSpecialCharacters(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -289,7 +292,7 @@ public function testHandlePromptGetReturnsEmptyMessages(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -318,7 +321,7 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -332,7 +335,7 @@ private function createGetPromptRequest(string $name, ?array $arguments = null): return GetPromptRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => GetPromptRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), + 'id' => 'test-request-' . uniqid(), 'params' => [ 'name' => $name, 'arguments' => $arguments, diff --git a/tests/Server/RequestHandler/PingHandlerTest.php b/tests/Server/RequestHandler/PingHandlerTest.php index 0904d68b..cad47a1d 100644 --- a/tests/Server/RequestHandler/PingHandlerTest.php +++ b/tests/Server/RequestHandler/PingHandlerTest.php @@ -16,14 +16,17 @@ use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\RequestHandler\PingHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; class PingHandlerTest extends TestCase { private PingHandler $handler; + private SessionInterface $session; protected function setUp(): void { + $this->session = $this->createMock(SessionInterface::class); $this->handler = new PingHandler(); } @@ -38,7 +41,7 @@ public function testHandlePingRequest(): void { $request = $this->createPingRequest(); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -50,8 +53,8 @@ public function testHandleMultiplePingRequests(): void $request1 = $this->createPingRequest(); $request2 = $this->createPingRequest(); - $response1 = $this->handler->handle($request1); - $response2 = $this->handler->handle($request2); + $response1 = $this->handler->handle($request1, $this->session); + $response2 = $this->handler->handle($request2, $this->session); $this->assertInstanceOf(Response::class, $response1); $this->assertInstanceOf(Response::class, $response2); @@ -66,8 +69,8 @@ public function testHandlerHasNoSideEffects(): void $request = $this->createPingRequest(); // Handle same request multiple times - $response1 = $this->handler->handle($request); - $response2 = $this->handler->handle($request); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); // Both responses should be identical $this->assertEquals($response1->id, $response2->id); @@ -80,7 +83,7 @@ public function testHandlerHasNoSideEffects(): void public function testEmptyResultIsCorrectType(): void { $request = $this->createPingRequest(); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(EmptyResult::class, $response->result); @@ -96,8 +99,8 @@ public function testHandlerIsStateless(): void $request = $this->createPingRequest(); - $response1 = $handler1->handle($request); - $response2 = $handler2->handle($request); + $response1 = $handler1->handle($request, $this->session); + $response2 = $handler2->handle($request, $this->session); // Both handlers should produce equivalent results $this->assertEquals($response1->id, $response2->id); @@ -125,7 +128,7 @@ public function testHandlerCanBeReused(): void // Create multiple ping requests for ($i = 0; $i < 5; ++$i) { $requests[$i] = $this->createPingRequest(); - $responses[$i] = $this->handler->handle($requests[$i]); + $responses[$i] = $this->handler->handle($requests[$i], $this->session); } // All responses should be valid @@ -141,7 +144,7 @@ private function createPingRequest(): Request return PingRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => PingRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), + 'id' => 'test-request-' . uniqid(), ]); } } diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php index 6cb8acc6..d238ee40 100644 --- a/tests/Server/RequestHandler/ReadResourceHandlerTest.php +++ b/tests/Server/RequestHandler/ReadResourceHandlerTest.php @@ -21,6 +21,7 @@ use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\RequestHandler\ReadResourceHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -28,10 +29,12 @@ class ReadResourceHandlerTest extends TestCase { private ReadResourceHandler $handler; private ResourceReaderInterface|MockObject $resourceReader; + private SessionInterface|MockObject $session; protected function setUp(): void { $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->session = $this->createMock(SessionInterface::class); $this->handler = new ReadResourceHandler($this->resourceReader); } @@ -60,7 +63,7 @@ public function testHandleSuccessfulResourceRead(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -84,7 +87,7 @@ public function testHandleResourceReadWithBlobContent(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -112,7 +115,7 @@ public function testHandleResourceReadWithMultipleContents(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -131,12 +134,12 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + $this->assertEquals('Resource not found for uri: "' . $uri . '".', $response->message); } public function testHandleResourceReadExceptionReturnsGenericError(): void @@ -154,7 +157,7 @@ public function testHandleResourceReadExceptionReturnsGenericError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -188,7 +191,7 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -216,7 +219,7 @@ public function testHandleResourceReadWithSpecialCharactersInUri(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -239,7 +242,7 @@ public function testHandleResourceReadWithEmptyContent(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -287,7 +290,7 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -311,11 +314,11 @@ public function testHandleResourceNotFoundWithCustomMessage(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + $this->assertEquals('Resource not found for uri: "' . $uri . '".', $response->message); } public function testHandleResourceReadWithEmptyResult(): void @@ -330,7 +333,7 @@ public function testHandleResourceReadWithEmptyResult(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -342,7 +345,7 @@ private function createReadResourceRequest(string $uri): ReadResourceRequest return ReadResourceRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => ReadResourceRequest::getMethod(), - 'id' => 'test-request-'.uniqid(), + 'id' => 'test-request-' . uniqid(), 'params' => [ 'uri' => $uri, ], diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 19177112..c65c3ca4 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -33,15 +33,20 @@ public function testJsonExceptions() ->onlyMethods(['process']) ->getMock(); - $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); + $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls( + new Exception(new \JsonException('foobar')), + [['success', []]] + ); $transport = $this->getMockBuilder(InMemoryTransport::class) ->setConstructorArgs([['foo', 'bar']]) ->onlyMethods(['send']) ->getMock(); - $transport->expects($this->once())->method('send')->with('success'); + $transport->expects($this->once())->method('send')->with('success', []); $server = new Server($handler, $logger); $server->connect($transport); + + $transport->listen(); } } From e276e6bbf123eb704cda4d1e068336fe9232ae82 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 21 Sep 2025 04:46:09 +0100 Subject: [PATCH 10/11] chore: apply code style fixes --- .../01-discovery-stdio-calculator/server.php | 2 +- .../10-simple-http-transport/McpElements.php | 35 ++++++++++++------ examples/10-simple-http-transport/server.php | 17 +++++++-- src/JsonRpc/Handler.php | 20 ++++++---- src/Server.php | 3 +- src/Server/NativeClock.php | 14 +++++-- src/Server/RequestHandler/CallToolHandler.php | 3 +- .../RequestHandler/GetPromptHandler.php | 3 +- .../RequestHandler/InitializeHandler.php | 3 +- .../RequestHandler/ListPromptsHandler.php | 3 +- .../RequestHandler/ListResourcesHandler.php | 3 +- .../RequestHandler/ListToolsHandler.php | 3 +- .../RequestHandler/ReadResourceHandler.php | 3 +- src/Server/ServerBuilder.php | 14 +++---- src/Server/Session/FileSessionStore.php | 30 ++++++++++----- src/Server/Session/InMemorySessionStore.php | 16 ++++++-- src/Server/Session/Session.php | 30 +++++++++------ src/Server/Session/SessionFactory.php | 11 +++++- .../Session/SessionFactoryInterface.php | 11 +++++- src/Server/Session/SessionInterface.php | 15 +++++--- src/Server/Session/SessionStoreInterface.php | 34 ++++++++++++----- src/Server/Transport/InMemoryTransport.php | 23 +++++++----- src/Server/Transport/StdioTransport.php | 37 ++++++++++--------- .../Transport/StreamableHttpTransport.php | 32 ++++++++++------ src/Server/TransportInterface.php | 8 ++-- tests/JsonRpc/HandlerTest.php | 3 +- .../RequestHandler/CallToolHandlerTest.php | 2 +- .../RequestHandler/GetPromptHandlerTest.php | 2 +- .../Server/RequestHandler/PingHandlerTest.php | 2 +- .../ReadResourceHandlerTest.php | 6 +-- 30 files changed, 252 insertions(+), 136 deletions(-) diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php index 6320e5d5..4bff8b8b 100644 --- a/examples/01-discovery-stdio-calculator/server.php +++ b/examples/01-discovery-stdio-calculator/server.php @@ -10,7 +10,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__) . '/bootstrap.php'; +require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); use Mcp\Server; diff --git a/examples/10-simple-http-transport/McpElements.php b/examples/10-simple-http-transport/McpElements.php index cc6c3b21..2b1a1e26 100644 --- a/examples/10-simple-http-transport/McpElements.php +++ b/examples/10-simple-http-transport/McpElements.php @@ -1,5 +1,14 @@ $a + $b, 'subtract', '-' => $a - $b, 'multiply', '*' => $a * $b, - 'divide', '/' => $b != 0 ? $a / $b : 'Error: Division by zero', - default => 'Error: Unknown operation. Use: add, subtract, multiply, divide' + 'divide', '/' => 0 != $b ? $a / $b : 'Error: Division by zero', + default => 'Error: Unknown operation. Use: add, subtract, multiply, divide', }; } /** - * Server information resource + * Server information resource. */ #[McpResource( uri: 'info://server/status', @@ -54,12 +65,12 @@ public function getServerStatus(): array 'timestamp' => time(), 'version' => '1.0.0', 'transport' => 'HTTP', - 'uptime' => time() - $_SERVER['REQUEST_TIME'] + 'uptime' => time() - $_SERVER['REQUEST_TIME'], ]; } /** - * Configuration resource + * Configuration resource. */ #[McpResource( uri: 'config://app/settings', @@ -73,12 +84,12 @@ public function getAppConfig(): array 'debug' => $_SERVER['DEBUG'] ?? false, 'environment' => $_SERVER['APP_ENV'] ?? 'production', 'timezone' => date_default_timezone_get(), - 'locale' => 'en_US' + 'locale' => 'en_US', ]; } /** - * Greeting prompt + * Greeting prompt. */ #[McpPrompt( name: 'greet', @@ -90,12 +101,12 @@ public function greetPrompt(string $firstName = 'World', string $timeOfDay = 'da 'morning' => 'Good morning', 'afternoon' => 'Good afternoon', 'evening', 'night' => 'Good evening', - default => 'Hello' + default => 'Hello', }; return [ 'role' => 'user', - 'content' => "# {$greeting}, {$firstName}!\n\nWelcome to our MCP HTTP Server example. This demonstrates how to use the Model Context Protocol over HTTP transport." + 'content' => "# {$greeting}, {$firstName}!\n\nWelcome to our MCP HTTP Server example. This demonstrates how to use the Model Context Protocol over HTTP transport.", ]; } } diff --git a/examples/10-simple-http-transport/server.php b/examples/10-simple-http-transport/server.php index fd285126..0ce83404 100644 --- a/examples/10-simple-http-transport/server.php +++ b/examples/10-simple-http-transport/server.php @@ -1,14 +1,23 @@ setServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport') ->setContainer(container()) - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) ->build(); diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index a06ce520..e7e66964 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -24,16 +24,16 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\InitializeRequest; use Mcp\Server\MethodHandlerInterface; -use Mcp\Server\Session\SessionInterface; use Mcp\Server\NotificationHandler; use Mcp\Server\RequestHandler; use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionStoreInterface; -use Mcp\Schema\Request\InitializeRequest; -use Symfony\Component\Uid\Uuid; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @final @@ -126,9 +126,10 @@ public function process(string $input, ?Uuid $sessionId): iterable if ($hasInitializeRequest) { // Spec: An initialize request must not be part of a batch. - if (count($messages) > 1) { + if (\count($messages) > 1) { $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); yield [$this->encodeResponse($error), []]; + return; } @@ -136,6 +137,7 @@ public function process(string $input, ?Uuid $sessionId): iterable if ($sessionId) { $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); yield [$this->encodeResponse($error), []]; + return; } @@ -144,12 +146,14 @@ public function process(string $input, ?Uuid $sessionId): iterable if (!$sessionId) { $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); yield [$this->encodeResponse($error), ['status_code' => 400]]; + return; } if (!$this->sessionStore->exists($sessionId)) { $error = Error::forInvalidRequest('Session not found or has expired.'); yield [$this->encodeResponse($error), ['status_code' => 404]]; + return; } @@ -169,8 +173,8 @@ public function process(string $input, ?Uuid $sessionId): iterable ]); try { - $response = $this->encodeResponse($this->handle($message, $session)); - yield [$response, ['session_id' => $session->getId()]]; + $response = $this->handle($message, $session); + yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; } catch (\DomainException) { yield [null, []]; } catch (NotFoundExceptionInterface $e) { @@ -268,8 +272,8 @@ private function runGarbageCollection(): void $deletedSessions = $this->sessionStore->gc(); if (!empty($deletedSessions)) { $this->logger->debug('Garbage collected expired sessions.', [ - 'count' => count($deletedSessions), - 'session_ids' => array_map(fn(Uuid $id) => $id->toRfc4122(), $deletedSessions), + 'count' => \count($deletedSessions), + 'session_ids' => array_map(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), ]); } } diff --git a/src/Server.php b/src/Server.php index d6082df6..55b84159 100644 --- a/src/Server.php +++ b/src/Server.php @@ -27,7 +27,8 @@ final class Server public function __construct( private readonly Handler $jsonRpcHandler, private readonly LoggerInterface $logger = new NullLogger(), - ) {} + ) { + } public static function make(): ServerBuilder { diff --git a/src/Server/NativeClock.php b/src/Server/NativeClock.php index 05ab0e47..5e0b9d28 100644 --- a/src/Server/NativeClock.php +++ b/src/Server/NativeClock.php @@ -2,15 +2,23 @@ declare(strict_types=1); +/* + * This file is part of the official PHP MCP SDK. + * + * A collaboration between Symfony and the PHP Foundation. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Mcp\Server; use Psr\Clock\ClockInterface; -use DateTimeImmutable; class NativeClock implements ClockInterface { - public function now(): DateTimeImmutable + public function now(): \DateTimeImmutable { - return new DateTimeImmutable(); + return new \DateTimeImmutable(); } } diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index 5e5157b4..fd6bddae 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -31,7 +31,8 @@ final class CallToolHandler implements MethodHandlerInterface public function __construct( private readonly ToolCallerInterface $toolCaller, private readonly LoggerInterface $logger = new NullLogger(), - ) {} + ) { + } public function supports(HasMethodInterface $message): bool { diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/RequestHandler/GetPromptHandler.php index 5715949d..a6b98909 100644 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ b/src/Server/RequestHandler/GetPromptHandler.php @@ -27,7 +27,8 @@ final class GetPromptHandler implements MethodHandlerInterface { public function __construct( private readonly PromptGetterInterface $promptGetter, - ) {} + ) { + } public function supports(HasMethodInterface $message): bool { diff --git a/src/Server/RequestHandler/InitializeHandler.php b/src/Server/RequestHandler/InitializeHandler.php index 28066c22..e27d1fbb 100644 --- a/src/Server/RequestHandler/InitializeHandler.php +++ b/src/Server/RequestHandler/InitializeHandler.php @@ -28,7 +28,8 @@ final class InitializeHandler implements MethodHandlerInterface public function __construct( public readonly ?ServerCapabilities $capabilities = new ServerCapabilities(), public readonly ?Implementation $serverInfo = new Implementation(), - ) {} + ) { + } public function supports(HasMethodInterface $message): bool { diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index a01e0167..bd93dd60 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -27,7 +27,8 @@ final class ListPromptsHandler implements MethodHandlerInterface public function __construct( private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, - ) {} + ) { + } public function supports(HasMethodInterface $message): bool { diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index 8aab80df..f0abad28 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -27,7 +27,8 @@ final class ListResourcesHandler implements MethodHandlerInterface public function __construct( private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, - ) {} + ) { + } public function supports(HasMethodInterface $message): bool { diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index e9e0ca90..e792b0f6 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -28,7 +28,8 @@ final class ListToolsHandler implements MethodHandlerInterface public function __construct( private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, - ) {} + ) { + } public function supports(HasMethodInterface $message): bool { diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php index bbe0eafd..455e23a5 100644 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ b/src/Server/RequestHandler/ReadResourceHandler.php @@ -28,7 +28,8 @@ final class ReadResourceHandler implements MethodHandlerInterface { public function __construct( private readonly ResourceReaderInterface $resourceReader, - ) {} + ) { + } public function supports(HasMethodInterface $message): bool { diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index bd5ea02e..e44d4e6d 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -38,8 +38,8 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; -use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionStoreInterface; use Psr\Container\ContainerInterface; @@ -203,7 +203,7 @@ public function setContainer(ContainerInterface $container): self public function setSession( SessionStoreInterface $sessionStore, SessionFactoryInterface $sessionFactory = new SessionFactory(), - int $ttl = 3600 + int $ttl = 3600, ): self { $this->sessionFactory = $sessionFactory; $this->sessionStore = $sessionStore; @@ -352,7 +352,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -387,7 +387,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -425,7 +425,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -463,7 +463,7 @@ private function registerCapabilities( $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']); + $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { $classShortName = $reflection->getDeclaringClass()->getShortName(); @@ -486,7 +486,7 @@ private function registerCapabilities( continue; } - $paramTag = $paramTags['$' . $param->getName()] ?? null; + $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument( $param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index 32de126c..217d1eb4 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -2,6 +2,15 @@ declare(strict_types=1); +/* + * This file is part of the official PHP MCP SDK. + * + * A collaboration between Symfony and the PHP Foundation. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Mcp\Server\Session; use Mcp\Server\NativeClock; @@ -24,7 +33,7 @@ public function __construct( } if (!is_dir($this->directory) || !is_writable($this->directory)) { - throw new \RuntimeException(sprintf('Session directory "%s" is not writable.', $this->directory)); + throw new \RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); } } @@ -37,6 +46,7 @@ public function exists(Uuid $id): bool } $mtime = @filemtime($path) ?: 0; + return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl; } @@ -51,11 +61,12 @@ public function read(Uuid $sessionId): string|false $mtime = @filemtime($path) ?: 0; if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { @unlink($path); + return false; } $data = @file_get_contents($path); - if ($data === false) { + if (false === $data) { return false; } @@ -66,16 +77,17 @@ public function write(Uuid $sessionId, string $data): bool { $path = $this->pathFor($sessionId); - $tmp = $path . '.tmp'; - if (@file_put_contents($tmp, $data, LOCK_EX) === false) { + $tmp = $path.'.tmp'; + if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { return false; } // Atomic move if (!@rename($tmp, $path)) { // Fallback if rename fails cross-device - if (@copy($tmp, $path) === false) { + if (false === @copy($tmp, $path)) { @unlink($tmp); + return false; } @unlink($tmp); @@ -107,17 +119,17 @@ public function gc(): array $now = $this->clock->now()->getTimestamp(); $dir = @opendir($this->directory); - if ($dir === false) { + if (false === $dir) { return $deleted; } while (($entry = readdir($dir)) !== false) { // Skip dot entries - if ($entry === '.' || $entry === '..') { + if ('.' === $entry || '..' === $entry) { continue; } - $path = $this->directory . DIRECTORY_SEPARATOR . $entry; + $path = $this->directory.\DIRECTORY_SEPARATOR.$entry; if (!is_file($path)) { continue; } @@ -140,6 +152,6 @@ public function gc(): array private function pathFor(Uuid $id): string { - return $this->directory . DIRECTORY_SEPARATOR . $id->toRfc4122(); + return $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); } } diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php index fcc7c525..8da481e9 100644 --- a/src/Server/Session/InMemorySessionStore.php +++ b/src/Server/Session/InMemorySessionStore.php @@ -2,9 +2,17 @@ declare(strict_types=1); +/* + * This file is part of the official PHP MCP SDK. + * + * A collaboration between Symfony and the PHP Foundation. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Mcp\Server\Session; -use Mcp\Server\Session\SessionStoreInterface; use Mcp\Server\NativeClock; use Psr\Clock\ClockInterface; use Symfony\Component\Uid\Uuid; @@ -19,7 +27,8 @@ class InMemorySessionStore implements SessionStoreInterface public function __construct( protected readonly int $ttl = 3600, protected readonly ClockInterface $clock = new NativeClock(), - ) {} + ) { + } public function exists(Uuid $id): bool { @@ -29,7 +38,7 @@ public function exists(Uuid $id): bool public function read(Uuid $sessionId): string|false { $session = $this->store[$sessionId->toRfc4122()] ?? ''; - if ($session === '') { + if ('' === $session) { return false; } @@ -37,6 +46,7 @@ public function read(Uuid $sessionId): string|false if ($currentTimestamp - $session['timestamp'] > $this->ttl) { unset($this->store[$sessionId]); + return false; } diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 0ad95f94..c4ddab56 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -2,10 +2,17 @@ declare(strict_types=1); +/* + * This file is part of the official PHP MCP SDK. + * + * A collaboration between Symfony and the PHP Foundation. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Mcp\Server\Session; -use Mcp\Server\Session\SessionStoreInterface; -use Mcp\Server\Session\SessionInterface; use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\UuidV4; @@ -16,7 +23,7 @@ class Session implements SessionInterface { /** * @param array $data Stores all session data. - * Keys are snake_case by convention for MCP-specific data. + * Keys are snake_case by convention for MCP-specific data. * * Official keys are: * - initialized: bool @@ -26,7 +33,7 @@ class Session implements SessionInterface */ public function __construct( protected SessionStoreInterface $store, - protected Uuid $id = new UuidV4(), + protected Uuid $id = new UuidV4(), protected array $data = [], ) { if ($rawData = $this->store->read($this->id)) { @@ -55,7 +62,7 @@ public function get(string $key, mixed $default = null): mixed $data = $this->data; foreach ($key as $segment) { - if (is_array($data) && array_key_exists($segment, $data)) { + if (\is_array($data) && \array_key_exists($segment, $data)) { $data = $data[$segment]; } else { return $default; @@ -70,9 +77,9 @@ public function set(string $key, mixed $value, bool $overwrite = true): void $segments = explode('.', $key); $data = &$this->data; - while (count($segments) > 1) { + while (\count($segments) > 1) { $segment = array_shift($segments); - if (!isset($data[$segment]) || !is_array($data[$segment])) { + if (!isset($data[$segment]) || !\is_array($data[$segment])) { $data[$segment] = []; } $data = &$data[$segment]; @@ -90,9 +97,9 @@ public function has(string $key): bool $data = $this->data; foreach ($key as $segment) { - if (is_array($data) && array_key_exists($segment, $data)) { + if (\is_array($data) && \array_key_exists($segment, $data)) { $data = $data[$segment]; - } elseif (is_object($data) && isset($data->{$segment})) { + } elseif (\is_object($data) && isset($data->{$segment})) { $data = $data->{$segment}; } else { return false; @@ -107,9 +114,9 @@ public function forget(string $key): void $segments = explode('.', $key); $data = &$this->data; - while (count($segments) > 1) { + while (\count($segments) > 1) { $segment = array_shift($segments); - if (!isset($data[$segment]) || !is_array($data[$segment])) { + if (!isset($data[$segment]) || !\is_array($data[$segment])) { $data[$segment] = []; } $data = &$data[$segment]; @@ -130,6 +137,7 @@ public function pull(string $key, mixed $default = null): mixed { $value = $this->get($key, $default); $this->forget($key); + return $value; } diff --git a/src/Server/Session/SessionFactory.php b/src/Server/Session/SessionFactory.php index 15885b93..0064ae4c 100644 --- a/src/Server/Session/SessionFactory.php +++ b/src/Server/Session/SessionFactory.php @@ -1,9 +1,16 @@ */ -interface SessionInterface extends JsonSerializable +interface SessionInterface extends \JsonSerializable { /** * Get the session ID. @@ -69,8 +76,6 @@ public function hydrate(array $attributes): void; /** * Get the session store instance. - * - * @return SessionStoreInterface */ public function getStore(): SessionStoreInterface; } diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php index e6dda139..fa5043c6 100644 --- a/src/Server/Session/SessionStoreInterface.php +++ b/src/Server/Session/SessionStoreInterface.php @@ -1,5 +1,14 @@ messages as $message) { - if (is_callable($this->messageListener)) { - call_user_func($this->messageListener, $message, $this->sessionId); + if (\is_callable($this->messageListener)) { + \call_user_func($this->messageListener, $message, $this->sessionId); } } $this->connected = false; - if (is_callable($this->sessionDestroyListener) && $this->sessionId !== null) { - call_user_func($this->sessionDestroyListener, $this->sessionId); + if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { + \call_user_func($this->sessionDestroyListener, $this->sessionId); } return null; @@ -69,8 +72,8 @@ public function onSessionEnd(callable $listener): void public function close(): void { - if (is_callable($this->sessionDestroyListener) && $this->sessionId !== null) { - call_user_func($this->sessionDestroyListener, $this->sessionId); + if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { + \call_user_func($this->sessionDestroyListener, $this->sessionId); } } } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index c8076b61..19a020ed 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -12,17 +12,17 @@ namespace Mcp\Server\Transport; use Mcp\Server\TransportInterface; -use Symfony\Component\Uid\Uuid; -use Psr\Log\NullLogger; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @author Kyrian Obikwelu */ class StdioTransport implements TransportInterface { - private $messageListener = null; - private $sessionEndListener = null; + private $messageListener; + private $sessionEndListener; private ?Uuid $sessionId = null; @@ -33,10 +33,13 @@ class StdioTransport implements TransportInterface public function __construct( private $input = \STDIN, private $output = \STDOUT, - private readonly LoggerInterface $logger = new NullLogger() - ) {} + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } - public function initialize(): void {} + public function initialize(): void + { + } public function onMessage(callable $listener): void { @@ -51,7 +54,7 @@ public function send(string $data, array $context): void $this->sessionId = $context['session_id']; } - fwrite($this->output, $data . \PHP_EOL); + fwrite($this->output, $data.\PHP_EOL); } public function listen(): mixed @@ -60,23 +63,23 @@ public function listen(): mixed while (!feof($this->input)) { $line = fgets($this->input); - if ($line === false) { + if (false === $line) { break; } $trimmedLine = trim($line); if (!empty($trimmedLine)) { $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); - if (is_callable($this->messageListener)) { - call_user_func($this->messageListener, $trimmedLine, $this->sessionId); + if (\is_callable($this->messageListener)) { + \call_user_func($this->messageListener, $trimmedLine, $this->sessionId); } } } $this->logger->info('StdioTransport finished listening.'); - if (is_callable($this->sessionEndListener) && $this->sessionId !== null) { - call_user_func($this->sessionEndListener, $this->sessionId); + if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { + \call_user_func($this->sessionEndListener, $this->sessionId); } return null; @@ -89,15 +92,15 @@ public function onSessionEnd(callable $listener): void public function close(): void { - if (is_callable($this->sessionEndListener) && $this->sessionId !== null) { - call_user_func($this->sessionEndListener, $this->sessionId); + if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { + \call_user_func($this->sessionEndListener, $this->sessionId); } - if (is_resource($this->input)) { + if (\is_resource($this->input)) { fclose($this->input); } - if (is_resource($this->output)) { + if (\is_resource($this->output)) { fclose($this->output); } } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 03e4bb0d..fe770993 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -26,8 +26,8 @@ */ class StreamableHttpTransport implements TransportInterface { - private $messageListener = null; - private $sessionEndListener = null; + private $messageListener; + private $sessionEndListener; private ?Uuid $sessionId = null; @@ -46,13 +46,15 @@ public function __construct( private readonly ServerRequestInterface $request, private readonly ResponseFactoryInterface $responseFactory, private readonly StreamFactoryInterface $streamFactory, - private readonly LoggerInterface $logger = new NullLogger() + private readonly LoggerInterface $logger = new NullLogger(), ) { $sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id'); $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; } - public function initialize(): void {} + public function initialize(): void + { + } public function send(string $data, array $context): void { @@ -98,31 +100,34 @@ protected function handlePostRequest(): ResponseInterface $acceptHeader = $this->request->getHeaderLine('Accept'); if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) { $error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.'); + return $this->createErrorResponse($error, 406); } if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) { $error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.'); + return $this->createErrorResponse($error, 415); } $body = $this->request->getBody()->getContents(); if (empty($body)) { $error = Error::forInvalidRequest('Bad Request: Empty request body.'); + return $this->createErrorResponse($error, 400); } - if (is_callable($this->messageListener)) { - call_user_func($this->messageListener, $body, $this->sessionId); + if (\is_callable($this->messageListener)) { + \call_user_func($this->messageListener, $body, $this->sessionId); } if (empty($this->outgoingMessages)) { return $this->withCorsHeaders($this->responseFactory->createResponse(202)); } - $responseBody = count($this->outgoingMessages) === 1 + $responseBody = 1 === \count($this->outgoingMessages) ? $this->outgoingMessages[0] - : '[' . implode(',', $this->outgoingMessages) . ']'; + : '['.implode(',', $this->outgoingMessages).']'; $status = $this->outgoingStatusCode ?? 200; @@ -140,6 +145,7 @@ protected function handlePostRequest(): ResponseInterface protected function handleGetRequest(): ResponseInterface { $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); + return $this->withCorsHeaders($response); } @@ -147,11 +153,12 @@ protected function handleDeleteRequest(): ResponseInterface { if (!$this->sessionId) { $error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.'); + return $this->createErrorResponse($error, 400); } - if (is_callable($this->sessionEndListener)) { - call_user_func($this->sessionEndListener, $this->sessionId); + if (\is_callable($this->sessionEndListener)) { + \call_user_func($this->sessionEndListener, $this->sessionId); } return $this->withCorsHeaders($this->responseFactory->createResponse(204)); @@ -160,6 +167,7 @@ protected function handleDeleteRequest(): ResponseInterface protected function handleUnsupportedRequest(): ResponseInterface { $response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); + return $this->withCorsHeaders($response); } @@ -181,5 +189,7 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re ->withBody($this->streamFactory->createStream($errorPayload)); } - public function close(): void {} + public function close(): void + { + } } diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 4ec71fad..2e91f4af 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -11,7 +11,6 @@ namespace Mcp\Server; - /** * @author Christopher Hertel * @author Kyrian Obikwelu @@ -37,19 +36,18 @@ public function onMessage(callable $listener): void; * - 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 mixed The result of the transport's execution, if any. + * @return mixed the result of the transport's execution, if any */ public function listen(): mixed; /** * Sends a raw JSON-RPC message string back to the client. * - * @param string $data The JSON-RPC message string to send - * @param array $context The context of the message + * @param string $data The JSON-RPC message string to send + * @param array $context The context of the message */ public function send(string $data, array $context): void; - /** * Registers a callback that will be invoked when a session needs to be destroyed. * This can happen when a client disconnects or explicitly ends their session. diff --git a/tests/JsonRpc/HandlerTest.php b/tests/JsonRpc/HandlerTest.php index 606a7324..20781def 100644 --- a/tests/JsonRpc/HandlerTest.php +++ b/tests/JsonRpc/HandlerTest.php @@ -16,11 +16,10 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Server\MethodHandlerInterface; use Mcp\Server\Session\SessionFactoryInterface; -use Mcp\Server\Session\SessionStoreInterface; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Session\SessionStoreInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; class HandlerTest extends TestCase diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php index a85443d6..1b2187ff 100644 --- a/tests/Server/RequestHandler/CallToolHandlerTest.php +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -287,7 +287,7 @@ private function createCallToolRequest(string $name, array $arguments): CallTool return CallToolRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => CallToolRequest::getMethod(), - 'id' => 'test-request-' . uniqid(), + 'id' => 'test-request-'.uniqid(), 'params' => [ 'name' => $name, 'arguments' => $arguments, diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php index f8b9886d..120b0e08 100644 --- a/tests/Server/RequestHandler/GetPromptHandlerTest.php +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -335,7 +335,7 @@ private function createGetPromptRequest(string $name, ?array $arguments = null): return GetPromptRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => GetPromptRequest::getMethod(), - 'id' => 'test-request-' . uniqid(), + 'id' => 'test-request-'.uniqid(), 'params' => [ 'name' => $name, 'arguments' => $arguments, diff --git a/tests/Server/RequestHandler/PingHandlerTest.php b/tests/Server/RequestHandler/PingHandlerTest.php index cad47a1d..3be1176b 100644 --- a/tests/Server/RequestHandler/PingHandlerTest.php +++ b/tests/Server/RequestHandler/PingHandlerTest.php @@ -144,7 +144,7 @@ private function createPingRequest(): Request return PingRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => PingRequest::getMethod(), - 'id' => 'test-request-' . uniqid(), + 'id' => 'test-request-'.uniqid(), ]); } } diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php index d238ee40..a78aa853 100644 --- a/tests/Server/RequestHandler/ReadResourceHandlerTest.php +++ b/tests/Server/RequestHandler/ReadResourceHandlerTest.php @@ -139,7 +139,7 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals('Resource not found for uri: "' . $uri . '".', $response->message); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); } public function testHandleResourceReadExceptionReturnsGenericError(): void @@ -318,7 +318,7 @@ public function testHandleResourceNotFoundWithCustomMessage(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); - $this->assertEquals('Resource not found for uri: "' . $uri . '".', $response->message); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); } public function testHandleResourceReadWithEmptyResult(): void @@ -345,7 +345,7 @@ private function createReadResourceRequest(string $uri): ReadResourceRequest return ReadResourceRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => ReadResourceRequest::getMethod(), - 'id' => 'test-request-' . uniqid(), + 'id' => 'test-request-'.uniqid(), 'params' => [ 'uri' => $uri, ], From 5605f07bf2f3b0cee9b9a17ee417092d7d384cd8 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 21 Sep 2025 10:46:53 +0100 Subject: [PATCH 11/11] fix: remove deprecated SSE transport and resolve PHPStan issues --- .gitignore | 1 + .../02-discovery-http-userprofile/server.php | 30 +++++---- .../03-manual-registration-stdio/server.php | 11 +++- .../04-combined-registration-http/server.php | 25 +++++-- examples/05-stdio-env-variables/server.php | 11 +++- .../06-custom-dependencies-stdio/server.php | 11 +++- .../07-complex-tool-schema-http/server.php | 25 +++++-- .../08-schema-showcase-streamable/server.php | 25 +++++-- examples/09-standalone-cli/index.php | 22 +++---- examples/10-simple-http-transport/.gitignore | 1 - .../10-simple-http-transport/McpElements.php | 6 ++ phpstan-baseline.neon | 44 ------------- src/Server/ServerBuilder.php | 5 +- src/Server/Session/InMemorySessionStore.php | 6 +- src/Server/Session/Session.php | 1 + src/Server/Session/SessionInterface.php | 4 ++ src/Server/Session/SessionStoreInterface.php | 2 + src/Server/Transport/InMemoryTransport.php | 7 +- .../Transport/Sse/Store/CachePoolStore.php | 65 ------------------- src/Server/Transport/Sse/StoreInterface.php | 26 -------- src/Server/Transport/Sse/StreamTransport.php | 65 ------------------- src/Server/Transport/StdioTransport.php | 3 + .../Transport/StreamableHttpTransport.php | 23 +++++++ src/Server/TransportInterface.php | 6 +- 24 files changed, 165 insertions(+), 260 deletions(-) delete mode 100644 examples/10-simple-http-transport/.gitignore delete mode 100644 src/Server/Transport/Sse/Store/CachePoolStore.php delete mode 100644 src/Server/Transport/Sse/StoreInterface.php delete mode 100644 src/Server/Transport/Sse/StreamTransport.php diff --git a/.gitignore b/.gitignore index 3c7c26e2..825fe72f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ composer.lock vendor examples/**/dev.log +examples/**/sessions diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 1d4ddc45..163d495c 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -13,20 +13,23 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Capability\Registry\Container; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; -use Psr\Log\LoggerInterface; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP HTTP User Profile Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -// --- Setup DI Container for DI in McpElements class --- -$container = new Container(); -$container->set(LoggerInterface::class, logger()); +$request = $creator->fromGlobals(); -Server::make() +$server = Server::make() ->setServerInfo('HTTP User Profiles', '1.0.0') ->setLogger(logger()) - ->setContainer($container) + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) ->addTool( function (float $a, float $b, string $operation = 'add'): array { @@ -70,7 +73,12 @@ function (): array { description: 'Current system status and runtime information', mimeType: 'application/json' ) - ->build() - ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); + ->build(); -logger()->info('Server listener stopped gracefully.'); +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); + +(new SapiEmitter())->emit($response); diff --git a/examples/03-manual-registration-stdio/server.php b/examples/03-manual-registration-stdio/server.php index 8a4916fc..9b83d82e 100644 --- a/examples/03-manual-registration-stdio/server.php +++ b/examples/03-manual-registration-stdio/server.php @@ -19,7 +19,7 @@ logger()->info('Starting MCP Manual Registration (Stdio) Server...'); -Server::make() +$server = Server::make() ->setServerInfo('Manual Reg Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) @@ -27,7 +27,12 @@ ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') ->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') - ->build() - ->connect(new StdioTransport(logger: logger())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php index f3ea730d..e69c5aa5 100644 --- a/examples/04-combined-registration-http/server.php +++ b/examples/04-combined-registration-http/server.php @@ -13,16 +13,24 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\CombinedHttpExample\Manual\ManualHandlers; use Mcp\Server; -use Mcp\Server\Transports\HttpServerTransport; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP Combined Registration (HTTP) Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -Server::make() +$request = $creator->fromGlobals(); + +$server = Server::make() ->setServerInfo('Combined HTTP Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) ->addTool([ManualHandlers::class, 'manualGreeter']) ->addResource( @@ -30,7 +38,12 @@ 'config://priority', 'priority_config_manual', ) - ->build() - ->connect(new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined')); + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); -logger()->info('Server listener stopped gracefully.'); +(new SapiEmitter())->emit($response); diff --git a/examples/05-stdio-env-variables/server.php b/examples/05-stdio-env-variables/server.php index 1cfaa13d..af159f40 100644 --- a/examples/05-stdio-env-variables/server.php +++ b/examples/05-stdio-env-variables/server.php @@ -49,11 +49,16 @@ logger()->info('Starting MCP Stdio Environment Variable Example Server...'); -Server::make() +$server = Server::make() ->setServerInfo('Env Var Server', '1.0.0') ->setLogger(logger()) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php index 653d7773..3a6d86ff 100644 --- a/examples/06-custom-dependencies-stdio/server.php +++ b/examples/06-custom-dependencies-stdio/server.php @@ -27,12 +27,17 @@ $statsService = new Services\SystemStatsService($taskRepo); $container->set(Services\StatsServiceInterface::class, $statsService); -Server::make() +$server = Server::make() ->setServerInfo('Task Manager Server', '1.0.0') ->setLogger(logger()) ->setContainer($container) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/07-complex-tool-schema-http/server.php b/examples/07-complex-tool-schema-http/server.php index 44dbc45d..25e3039b 100644 --- a/examples/07-complex-tool-schema-http/server.php +++ b/examples/07-complex-tool-schema-http/server.php @@ -13,17 +13,30 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; -use Mcp\Server\Transports\HttpServerTransport; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP Complex Schema HTTP Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -Server::make() +$request = $creator->fromGlobals(); + +$server = Server::make() ->setServerInfo('Event Scheduler Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler')); + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); -logger()->info('Server listener stopped gracefully.'); +(new SapiEmitter())->emit($response); diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php index b77b6db8..c7a323ea 100644 --- a/examples/08-schema-showcase-streamable/server.php +++ b/examples/08-schema-showcase-streamable/server.php @@ -13,16 +13,29 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; -use Mcp\Server\Transports\StreamableHttpServerTransport; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP Schema Showcase Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -Server::make() +$request = $creator->fromGlobals(); + +$server = Server::make() ->setServerInfo('Schema Showcase', '1.0.0') ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); -logger()->info('Server listener stopped gracefully.'); +(new SapiEmitter())->emit($response); diff --git a/examples/09-standalone-cli/index.php b/examples/09-standalone-cli/index.php index f7a67423..9ef61b7e 100644 --- a/examples/09-standalone-cli/index.php +++ b/examples/09-standalone-cli/index.php @@ -11,6 +11,8 @@ require __DIR__.'/vendor/autoload.php'; +use Mcp\Server; +use Mcp\Server\Transport\StdioTransport; use Symfony\Component\Console as SymfonyConsole; use Symfony\Component\Console\Output\OutputInterface; @@ -20,18 +22,14 @@ $output = new SymfonyConsole\Output\ConsoleOutput($debug ? OutputInterface::VERBOSITY_VERY_VERBOSE : OutputInterface::VERBOSITY_NORMAL); $logger = new SymfonyConsole\Logger\ConsoleLogger($output); -// Configure the JsonRpcHandler and build the functionality -$jsonRpcHandler = new Mcp\JsonRpc\Handler( - Mcp\JsonRpc\MessageFactory::make(), - App\Builder::buildMethodHandlers(), - $logger -); +$server = Server::make() + ->setServerInfo('Standalone CLI', '1.0.0') + ->setLogger($logger) + ->setDiscovery(__DIR__, ['.']) + ->build(); -// Set up the server -$sever = new Mcp\Server($jsonRpcHandler, $logger); +$transport = new StdioTransport(logger: $logger); -// Create the transport layer using Stdio -$transport = new Mcp\Server\Transport\StdioTransport(logger: $logger); +$server->connect($transport); -// Start our application -$sever->connect($transport); +$transport->listen(); diff --git a/examples/10-simple-http-transport/.gitignore b/examples/10-simple-http-transport/.gitignore deleted file mode 100644 index 38bfd77a..00000000 --- a/examples/10-simple-http-transport/.gitignore +++ /dev/null @@ -1 +0,0 @@ -sessions/ \ No newline at end of file diff --git a/examples/10-simple-http-transport/McpElements.php b/examples/10-simple-http-transport/McpElements.php index 2b1a1e26..6ac08427 100644 --- a/examples/10-simple-http-transport/McpElements.php +++ b/examples/10-simple-http-transport/McpElements.php @@ -51,6 +51,8 @@ public function calculate(float $a, float $b, string $operation): float|string /** * Server information resource. + * + * @return array{status: string, timestamp: int, version: string, transport: string, uptime: int} */ #[McpResource( uri: 'info://server/status', @@ -71,6 +73,8 @@ public function getServerStatus(): array /** * Configuration resource. + * + * @return array{debug: bool, environment: string, timezone: string, locale: string} */ #[McpResource( uri: 'config://app/settings', @@ -90,6 +94,8 @@ public function getAppConfig(): array /** * Greeting prompt. + * + * @return array{role: string, content: string} */ #[McpPrompt( name: 'greet', diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a9382bf3..f7b29633 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,17 +54,6 @@ parameters: count: 1 path: examples/02-discovery-http-userprofile/server.php - - - message: '#^Instantiated class StreamableHttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/02-discovery-http-userprofile/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, StreamableHttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/02-discovery-http-userprofile/server.php - message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:getItemDetails\(\) return type has no value type specified in iterable type array\.$#' @@ -84,17 +73,6 @@ parameters: count: 2 path: examples/04-combined-registration-http/server.php - - - message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/04-combined-registration-http/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/04-combined-registration-http/server.php - message: '#^Method Mcp\\Example\\StdioEnvVariables\\EnvToolHandler\:\:processData\(\) return type has no value type specified in iterable type array\.$#' @@ -288,17 +266,6 @@ parameters: count: 2 path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/07-complex-tool-schema-http/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/07-complex-tool-schema-http/server.php - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:calculateRange\(\) return type has no value type specified in iterable type array\.$#' @@ -354,17 +321,6 @@ parameters: count: 1 path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - message: '#^Instantiated class Mcp\\Server\\Transports\\StreamableHttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/08-schema-showcase-streamable/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\StreamableHttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/08-schema-showcase-streamable/server.php - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\PromptChain given\.$#' diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index e44d4e6d..61af4745 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -46,6 +46,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Psr\SimpleCache\CacheInterface; /** * @author Kyrian Obikwelu @@ -69,8 +70,10 @@ final class ServerBuilder private ?ContainerInterface $container = null; private ?SessionFactoryInterface $sessionFactory = null; + private ?SessionStoreInterface $sessionStore = null; - private ?int $sessionTtl = 3600; + + private int $sessionTtl = 3600; private ?int $paginationLimit = 50; diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php index 8da481e9..4051ba76 100644 --- a/src/Server/Session/InMemorySessionStore.php +++ b/src/Server/Session/InMemorySessionStore.php @@ -20,7 +20,7 @@ class InMemorySessionStore implements SessionStoreInterface { /** - * @var array + * @var array */ protected array $store = []; @@ -45,7 +45,7 @@ public function read(Uuid $sessionId): string|false $currentTimestamp = $this->clock->now()->getTimestamp(); if ($currentTimestamp - $session['timestamp'] > $this->ttl) { - unset($this->store[$sessionId]); + unset($this->store[$sessionId->toRfc4122()]); return false; } @@ -66,7 +66,7 @@ public function write(Uuid $sessionId, string $data): bool public function destroy(Uuid $sessionId): bool { if (isset($this->store[$sessionId->toRfc4122()])) { - unset($this->store[$sessionId]); + unset($this->store[$sessionId->toRfc4122()]); } return true; diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index c4ddab56..6bf3c306 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -151,6 +151,7 @@ public function hydrate(array $attributes): void $this->data = $attributes; } + /** @return array */ public function jsonSerialize(): array { return $this->all(); diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php index 95c7b9a8..9ee5e807 100644 --- a/src/Server/Session/SessionInterface.php +++ b/src/Server/Session/SessionInterface.php @@ -65,12 +65,16 @@ public function pull(string $key, mixed $default = null): mixed; /** * Get all attributes of the session. + * + * @return array */ public function all(): array; /** * Set all attributes of the session, typically for hydration. * This will overwrite existing attributes. + * + * @param array $attributes */ public function hydrate(array $attributes): void; diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php index fa5043c6..13f5f161 100644 --- a/src/Server/Session/SessionStoreInterface.php +++ b/src/Server/Session/SessionStoreInterface.php @@ -57,6 +57,8 @@ public function destroy(Uuid $id): bool; * Cleanup old sessions * Sessions that have not updated for * the configured TTL will be removed. + * + * @return Uuid[] */ public function gc(): array; } diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php index 11a92b13..de80edee 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -19,9 +19,12 @@ */ class InMemoryTransport implements TransportInterface { - private bool $connected = true; + /** @var callable(string, ?Uuid): void */ private $messageListener; + + /** @var callable(Uuid): void */ private $sessionDestroyListener; + private ?Uuid $sessionId = null; /** @@ -56,8 +59,6 @@ public function listen(): mixed } } - $this->connected = false; - if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { \call_user_func($this->sessionDestroyListener, $this->sessionId); } diff --git a/src/Server/Transport/Sse/Store/CachePoolStore.php b/src/Server/Transport/Sse/Store/CachePoolStore.php deleted file mode 100644 index 68a476fb..00000000 --- a/src/Server/Transport/Sse/Store/CachePoolStore.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ -final class CachePoolStore implements StoreInterface -{ - public function __construct( - private readonly CacheItemPoolInterface $cachePool, - ) { - } - - public function push(Uuid $id, string $message): void - { - $item = $this->cachePool->getItem($this->getCacheKey($id)); - - $messages = $item->isHit() ? $item->get() : []; - $messages[] = $message; - $item->set($messages); - - $this->cachePool->save($item); - } - - public function pop(Uuid $id): ?string - { - $item = $this->cachePool->getItem($this->getCacheKey($id)); - - if (!$item->isHit()) { - return null; - } - - $messages = $item->get(); - $message = array_shift($messages); - - $item->set($messages); - $this->cachePool->save($item); - - return $message; - } - - public function remove(Uuid $id): void - { - $this->cachePool->deleteItem($this->getCacheKey($id)); - } - - private function getCacheKey(Uuid $id): string - { - return 'message_'.$id->toRfc4122(); - } -} diff --git a/src/Server/Transport/Sse/StoreInterface.php b/src/Server/Transport/Sse/StoreInterface.php deleted file mode 100644 index e2bed2d9..00000000 --- a/src/Server/Transport/Sse/StoreInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ -interface StoreInterface -{ - public function push(Uuid $id, string $message): void; - - public function pop(Uuid $id): ?string; - - public function remove(Uuid $id): void; -} diff --git a/src/Server/Transport/Sse/StreamTransport.php b/src/Server/Transport/Sse/StreamTransport.php deleted file mode 100644 index 70a01189..00000000 --- a/src/Server/Transport/Sse/StreamTransport.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ -final class StreamTransport implements TransportInterface -{ - public function __construct( - private readonly string $messageEndpoint, - private readonly StoreInterface $store, - private readonly Uuid $id, - ) { - } - - public function initialize(): void - { - ignore_user_abort(true); - $this->flushEvent('endpoint', $this->messageEndpoint); - } - - public function isConnected(): bool - { - return 0 === connection_aborted(); - } - - public function receive(): \Generator - { - yield $this->store->pop($this->id); - } - - public function send(string $data): void - { - $this->flushEvent('message', $data); - } - - public function close(): void - { - $this->store->remove($this->id); - } - - private function flushEvent(string $event, string $data): void - { - echo \sprintf('event: %s', $event).\PHP_EOL; - echo \sprintf('data: %s', $data).\PHP_EOL; - echo \PHP_EOL; - if (false !== ob_get_length()) { - ob_flush(); - } - flush(); - } -} diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 19a020ed..89132cae 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -21,7 +21,10 @@ */ class StdioTransport implements TransportInterface { + /** @var callable(string, ?Uuid): void */ private $messageListener; + + /** @var callable(Uuid): void */ private $sessionEndListener; private ?Uuid $sessionId = null; diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index fe770993..428315b4 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -26,7 +26,10 @@ */ class StreamableHttpTransport implements TransportInterface { + /** @var callable(string, ?Uuid): void */ private $messageListener; + + /** @var callable(Uuid): void */ private $sessionEndListener; private ?Uuid $sessionId = null; @@ -36,6 +39,7 @@ class StreamableHttpTransport implements TransportInterface private ?Uuid $outgoingSessionId = null; private ?int $outgoingStatusCode = null; + /** @var array */ private array $corsHeaders = [ 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', @@ -67,6 +71,12 @@ public function send(string $data, array $context): void if (isset($context['status_code']) && \is_int($context['status_code'])) { $this->outgoingStatusCode = $context['status_code']; } + + $this->logger->debug('Sending data to client via StreamableHttpTransport.', [ + 'data' => $data, + 'session_id' => $this->outgoingSessionId?->toRfc4122(), + 'status_code' => $this->outgoingStatusCode, + ]); } public function listen(): mixed @@ -100,12 +110,14 @@ protected function handlePostRequest(): ResponseInterface $acceptHeader = $this->request->getHeaderLine('Accept'); if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) { $error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.'); + $this->logger->warning('Client does not accept required content types.', ['accept' => $acceptHeader]); return $this->createErrorResponse($error, 406); } if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) { $error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.'); + $this->logger->warning('Client sent unsupported content type.', ['content_type' => $this->request->getHeaderLine('Content-Type')]); return $this->createErrorResponse($error, 415); } @@ -113,10 +125,16 @@ protected function handlePostRequest(): ResponseInterface $body = $this->request->getBody()->getContents(); if (empty($body)) { $error = Error::forInvalidRequest('Bad Request: Empty request body.'); + $this->logger->warning('Client sent empty request body.'); return $this->createErrorResponse($error, 400); } + $this->logger->debug('Received message on StreamableHttpTransport.', [ + 'body' => $body, + 'session_id' => $this->sessionId?->toRfc4122(), + ]); + if (\is_callable($this->messageListener)) { \call_user_func($this->messageListener, $body, $this->sessionId); } @@ -153,6 +171,7 @@ protected function handleDeleteRequest(): ResponseInterface { if (!$this->sessionId) { $error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.'); + $this->logger->warning('DELETE request received without session ID.'); return $this->createErrorResponse($error, 400); } @@ -166,6 +185,10 @@ protected function handleDeleteRequest(): ResponseInterface protected function handleUnsupportedRequest(): ResponseInterface { + $this->logger->warning('Unsupported HTTP method received.', [ + 'method' => $this->request->getMethod(), + ]); + $response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); return $this->withCorsHeaders($response); diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 2e91f4af..6e975188 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -11,6 +11,8 @@ namespace Mcp\Server; +use Symfony\Component\Uid\Uuid; + /** * @author Christopher Hertel * @author Kyrian Obikwelu @@ -43,8 +45,8 @@ public function listen(): mixed; /** * Sends a raw JSON-RPC message string back to the client. * - * @param string $data The JSON-RPC message string to send - * @param array $context The context of the message + * @param string $data The JSON-RPC message string to send + * @param array $context The context of the message */ public function send(string $data, array $context): void;