diff --git a/.gitignore b/.gitignore index 3c7c26e..825fe72 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ composer.lock vendor examples/**/dev.log +examples/**/sessions diff --git a/composer.json b/composer.json index 4d94c1a..051e49b 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,11 @@ "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", + "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" @@ -34,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": { @@ -51,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/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php index ce19dc0..4bff8b8 100644 --- a/examples/01-discovery-stdio-calculator/server.php +++ b/examples/01-discovery-stdio-calculator/server.php @@ -18,12 +18,17 @@ logger()->info('Starting MCP Stdio Calculator Server...'); -Server::make() +$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())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 1d4ddc4..163d495 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 8a4916f..9b83d82 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 f3ea730..e69c5aa 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 1cfaa13..af159f4 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 653d777..3a6d86f 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 44dbc45..25e3039 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 b77b6db..c7a323e 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 f7a6742..9ef61b7 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/McpElements.php b/examples/10-simple-http-transport/McpElements.php new file mode 100644 index 0000000..6ac0842 --- /dev/null +++ b/examples/10-simple-http-transport/McpElements.php @@ -0,0 +1,118 @@ + $a + $b, + 'subtract', '-' => $a - $b, + 'multiply', '*' => $a * $b, + 'divide', '/' => 0 != $b ? $a / $b : 'Error: Division by zero', + default => 'Error: Unknown operation. Use: add, subtract, multiply, divide', + }; + } + + /** + * Server information resource. + * + * @return array{status: string, timestamp: int, version: string, transport: string, uptime: int} + */ + #[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. + * + * @return array{debug: bool, environment: string, timezone: string, locale: string} + */ + #[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. + * + * @return array{role: string, content: string} + */ + #[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/examples/10-simple-http-transport/README.md b/examples/10-simple-http-transport/README.md new file mode 100644 index 0000000..aa06a67 --- /dev/null +++ b/examples/10-simple-http-transport/README.md @@ -0,0 +1,24 @@ +# 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. + +## Usage + +**Step 1: Start the HTTP server** + +```bash +cd examples/10-simple-http-transport +php -S localhost:8000 server.php +``` + +**Step 2: Connect with MCP Inspector** + +```bash +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +## Available Features + +- **Tools**: `current_time`, `calculate` +- **Resources**: `info://server/status`, `config://app/settings` +- **Prompts**: `greet` diff --git a/examples/10-simple-http-transport/server.php b/examples/10-simple-http-transport/server.php new file mode 100644 index 0000000..0ce8340 --- /dev/null +++ b/examples/10-simple-http-transport/server.php @@ -0,0 +1,40 @@ +fromGlobals(); + +$server = Server::make() + ->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); + +$server->connect($transport); + +$response = $transport->listen(); + +(new SapiEmitter())->emit($response); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a9382bf..f7b2963 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/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 8699eab..e7e6696 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -24,11 +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\NotificationHandler; use Mcp\Server\RequestHandler; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Session\SessionStoreInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @final @@ -47,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(), ) { @@ -62,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), @@ -82,29 +93,78 @@ 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 */ - public function process(string $input): iterable + 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; } @@ -113,24 +173,32 @@ public function process(string $input): iterable ]); try { - yield $this->encodeResponse($this->handle($message)); + $response = $this->handle($message, $session); + yield [$this->encodeResponse($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], + $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(); } /** @@ -159,7 +227,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, @@ -167,18 +235,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; } } @@ -188,4 +258,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 fc81382..55b8415 100644 --- a/src/Server.php +++ b/src/Server.php @@ -16,9 +16,11 @@ use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @author Christopher Hertel + * @author Kyrian Obikwelu */ final class Server { @@ -36,37 +38,30 @@ public static function make(): ServerBuilder public function connect(TransportInterface $transport): void { $transport->initialize(); + $this->logger->info('Transport initialized.', [ '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->send($response); + $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { + try { + foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { + if (null === $response) { + continue; } - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message' => $message, - 'exception' => $e, - ]); - continue; + + $transport->send($response, $context); } + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message' => $message, + 'exception' => $e, + ]); } + }); - usleep(1000); - } - - $transport->close(); - $this->logger->info('Transport closed'); + $transport->onSessionEnd(function (Uuid $sessionId) { + $this->jsonRpcHandler->destroySession($sessionId); + }); } } diff --git a/src/Server/MethodHandlerInterface.php b/src/Server/MethodHandlerInterface.php index 7f949bb..4abca85 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/NativeClock.php b/src/Server/NativeClock.php new file mode 100644 index 0000000..5e0b9d2 --- /dev/null +++ b/src/Server/NativeClock.php @@ -0,0 +1,24 @@ + @@ -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 28aab38..fd6bdda 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; @@ -38,7 +39,7 @@ 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 1ac0a3f..a6b9890 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 @@ -34,7 +35,7 @@ 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 11d9b0a..e27d1fb 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 @@ -35,11 +36,14 @@ 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 2bf479c..bd93dd6 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 @@ -34,7 +35,7 @@ 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 212f4f0..f0abad2 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 @@ -34,7 +35,7 @@ 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 eb49e0d..e792b0f 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 @@ -35,7 +36,7 @@ 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 2cf8ec9..3070133 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 9c80d2b..455e23a 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 @@ -35,7 +36,7 @@ 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 4075cdb..61af474 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -38,6 +38,10 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\SessionFactory; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionStoreInterface; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -65,6 +69,12 @@ 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 +203,18 @@ public function setContainer(ContainerInterface $container): self return $this; } + public function setSession( + SessionStoreInterface $sessionStore, + SessionFactoryInterface $sessionFactory = new SessionFactory(), + int $ttl = 3600, + ): self { + $this->sessionFactory = $sessionFactory; + $this->sessionStore = $sessionStore; + $this->sessionTtl = $ttl; + + return $this; + } + public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], @@ -292,6 +314,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, @@ -300,6 +326,8 @@ public function build(): Server toolCaller: $toolCaller, resourceReader: $resourceReader, promptGetter: $promptGetter, + sessionStore: $sessionStore, + sessionFactory: $sessionFactory, logger: $logger, ), logger: $logger, diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php new file mode 100644 index 0000000..217d1eb --- /dev/null +++ b/src/Server/Session/FileSessionStore.php @@ -0,0 +1,157 @@ +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 (false === $data) { + return false; + } + + return $data; + } + + public function write(Uuid $sessionId, string $data): bool + { + $path = $this->pathFor($sessionId); + + $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 (false === @copy($tmp, $path)) { + @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 (false === $dir) { + 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 new file mode 100644 index 0000000..4051ba7 --- /dev/null +++ b/src/Server/Session/InMemorySessionStore.php @@ -0,0 +1,90 @@ + + */ + protected array $store = []; + + public function __construct( + protected readonly int $ttl = 3600, + 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()] ?? ''; + if ('' === $session) { + return false; + } + + $currentTimestamp = $this->clock->now()->getTimestamp(); + + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + unset($this->store[$sessionId->toRfc4122()]); + + 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->toRfc4122()]); + } + + return true; + } + + public function gc(): array + { + $currentTimestamp = $this->clock->now()->getTimestamp(); + $deletedSessions = []; + + foreach ($this->store as $sessionId => $session) { + $sessionId = Uuid::fromString($sessionId); + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + 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 0000000..6bf3c30 --- /dev/null +++ b/src/Server/Session/Session.php @@ -0,0 +1,159 @@ + + */ +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; + } + + /** @return array */ + 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 0000000..0064ae4 --- /dev/null +++ b/src/Server/Session/SessionFactory.php @@ -0,0 +1,32 @@ + + */ +class SessionFactory implements SessionFactoryInterface +{ + public function create(SessionStoreInterface $store): SessionInterface + { + return new Session($store, Uuid::v4()); + } + + public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface + { + return new Session($store, $id); + } +} diff --git a/src/Server/Session/SessionFactoryInterface.php b/src/Server/Session/SessionFactoryInterface.php new file mode 100644 index 0000000..1534334 --- /dev/null +++ b/src/Server/Session/SessionFactoryInterface.php @@ -0,0 +1,35 @@ + + */ +interface SessionFactoryInterface +{ + /** + * Creates a new session with an auto-generated UUID. + * This is the standard factory method for creating sessions. + */ + public function create(SessionStoreInterface $store): SessionInterface; + + /** + * Creates a session with a specific UUID. + * Use this when you need to reconstruct a session with a known ID. + */ + public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface; +} diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php new file mode 100644 index 0000000..9ee5e80 --- /dev/null +++ b/src/Server/Session/SessionInterface.php @@ -0,0 +1,85 @@ + + */ +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. + * + * @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; + + /** + * Get the session store instance. + */ + public function getStore(): SessionStoreInterface; +} diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php new file mode 100644 index 0000000..13f5f16 --- /dev/null +++ b/src/Server/Session/SessionStoreInterface.php @@ -0,0 +1,64 @@ + + */ +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. + * + * 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 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 015c70c..de80ede 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -12,13 +12,20 @@ namespace Mcp\Server\Transport; use Mcp\Server\TransportInterface; +use Symfony\Component\Uid\Uuid; /** * @author Tobias Nyholm */ 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; /** * @param list $messages @@ -32,22 +39,42 @@ public function initialize(): void { } - public function isConnected(): bool + public function onMessage(callable $listener): void { - return $this->connected; + $this->messageListener = $listener; } - public function receive(): \Generator + public function send(string $data, array $context): void { - yield from $this->messages; - $this->connected = false; + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } + } + + public function listen(): mixed + { + foreach ($this->messages as $message) { + if (\is_callable($this->messageListener)) { + \call_user_func($this->messageListener, $message, $this->sessionId); + } + } + + if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { + \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) && 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 68a476f..0000000 --- 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 e2bed2d..0000000 --- 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 70a0118..0000000 --- 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 309683a..89132ca 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -14,13 +14,20 @@ use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** - * Heavily inspired by https://jolicode.com/blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents. + * @author Kyrian Obikwelu */ class StdioTransport implements TransportInterface { - private string $buffer = ''; + /** @var callable(string, ?Uuid): void */ + private $messageListener; + + /** @var callable(Uuid): void */ + private $sessionEndListener; + + private ?Uuid $sessionId = null; /** * @param resource $input @@ -37,39 +44,67 @@ public function initialize(): void { } - public function isConnected(): bool + public function onMessage(callable $listener): void + { + $this->messageListener = $listener; + } + + public function send(string $data, array $context): void { - return true; + $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); } - public function receive(): \Generator + public function listen(): mixed { - $line = fgets($this->input); + $this->logger->info('StdioTransport is listening for messages on STDIN...'); - $this->logger->debug('Received message on StdioTransport.', [ - 'line' => $line, - ]); + while (!feof($this->input)) { + $line = fgets($this->input); + if (false === $line) { + break; + } - if (false === $line) { - return; + $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); + } + } } - $this->buffer .= rtrim($line).\PHP_EOL; - if (str_contains($this->buffer, \PHP_EOL)) { - $lines = explode(\PHP_EOL, $this->buffer); - $this->buffer = array_pop($lines); - yield from $lines; + $this->logger->info('StdioTransport finished listening.'); + + if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { + \call_user_func($this->sessionEndListener, $this->sessionId); } + + return null; } - public function send(string $data): void + public function onSessionEnd(callable $listener): void { - $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); - - fwrite($this->output, $data.\PHP_EOL); + $this->sessionEndListener = $listener; } public function close(): void { + if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { + \call_user_func($this->sessionEndListener, $this->sessionId); + } + + 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 0000000..428315b --- /dev/null +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -0,0 +1,218 @@ + + */ +class StreamableHttpTransport implements TransportInterface +{ + /** @var callable(string, ?Uuid): void */ + private $messageListener; + + /** @var callable(Uuid): void */ + private $sessionEndListener; + + private ?Uuid $sessionId = null; + + /** @var string[] */ + private array $outgoingMessages = []; + 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', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization, Accept', + ]; + + public function __construct( + private readonly ServerRequestInterface $request, + 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 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']; + } + + $this->logger->debug('Sending data to client via StreamableHttpTransport.', [ + 'data' => $data, + 'session_id' => $this->outgoingSessionId?->toRfc4122(), + 'status_code' => $this->outgoingStatusCode, + ]); + } + + public function listen(): mixed + { + return match ($this->request->getMethod()) { + 'OPTIONS' => $this->handleOptionsRequest(), + 'GET' => $this->handleGetRequest(), + 'POST' => $this->handlePostRequest(), + 'DELETE' => $this->handleDeleteRequest(), + default => $this->handleUnsupportedRequest(), + }; + } + + 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)); + } + + 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); + } + + $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); + } + + if (empty($this->outgoingMessages)) { + return $this->withCorsHeaders($this->responseFactory->createResponse(202)); + } + + $responseBody = 1 === \count($this->outgoingMessages) + ? $this->outgoingMessages[0] + : '['.implode(',', $this->outgoingMessages).']'; + + $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); + } + + protected function handleGetRequest(): ResponseInterface + { + $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); + + return $this->withCorsHeaders($response); + } + + 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); + } + + if (\is_callable($this->sessionEndListener)) { + \call_user_func($this->sessionEndListener, $this->sessionId); + } + + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } + + 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); + } + + protected function withCorsHeaders(ResponseInterface $response): ResponseInterface + { + 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($errorPayload)); + } + + public function close(): void + { + } +} diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 49963a7..6e97518 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -11,18 +11,58 @@ namespace Mcp\Server; +use Symfony\Component\Uid\Uuid; + /** * @author Christopher Hertel + * @author Kyrian Obikwelu */ interface TransportInterface { + /** + * Initializes the transport. + */ public function initialize(): void; - public function isConnected(): bool; + /** + * Registers a callback that will be invoked whenever the transport receives an incoming message. + * + * @param callable(string $message, ?Uuid $sessionId): void $listener The callback function to execute when the message occurs + */ + public function onMessage(callable $listener): void; + + /** + * Starts the transport's execution process. + * + * - For a blocking transport like STDIO, this method will run a continuous loop. + * - For a single-request transport like HTTP, this will process the request + * and return a result (e.g., a PSR-7 Response) to be sent to the client. + * + * @return mixed the result of the transport's execution, if any + */ + public function listen(): mixed; - public function receive(): \Generator; + /** + * 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, array $context): void; - public function send(string $data): 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. + * + * 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; } diff --git a/tests/JsonRpc/HandlerTest.php b/tests/JsonRpc/HandlerTest.php index a2fdeec..20781de 100644 --- a/tests/JsonRpc/HandlerTest.php +++ b/tests/JsonRpc/HandlerTest.php @@ -15,8 +15,12 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Response; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Session\SessionStoreInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; class HandlerTest extends TestCase { @@ -44,9 +48,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 +94,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 e8f1362..1b2187f 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); diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php index 3debaa0..120b0e0 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); diff --git a/tests/Server/RequestHandler/PingHandlerTest.php b/tests/Server/RequestHandler/PingHandlerTest.php index 0904d68..3be1176 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 diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php index 6cb8acc..a78aa85 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,7 +134,7 @@ 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); @@ -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,7 +314,7 @@ 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); @@ -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); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 1917711..c65c3ca 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(); } }