diff --git a/docs/server-builder.md b/docs/server-builder.md index f673000c..03fcaece 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -12,7 +12,7 @@ various aspects of the server behavior. - [Session Management](#session-management) - [Manual Capability Registration](#manual-capability-registration) - [Service Dependencies](#service-dependencies) -- [Custom Method Handlers](#custom-method-handlers) +- [Custom Message Handlers](#custom-message-handlers) - [Complete Example](#complete-example) - [Method Reference](#method-reference) @@ -344,50 +344,101 @@ $server = Server::builder() ->setEventDispatcher($eventDispatcher); ``` -## Custom Method Handlers +## Custom Message Handlers -**Low-level escape hatch.** Custom method handlers run before the SDK’s built-in handlers and give you total control over -individual JSON-RPC methods. They do not receive the builder’s registry, container, or discovery output unless you pass +**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over +individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass those dependencies in yourself. -Attach handlers with `addMethodHandler()` (single) or `addMethodHandlers()` (multiple). You can call these methods as -many times as needed; each call prepends the handlers so they execute before the defaults: +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> taking on the additional plumbing. + +### Request Handlers + +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +`Response` or an `Error` object. + +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +methods as many times as needed; each call prepends the handlers so they execute before the defaults: ```php $server = Server::builder() - ->addMethodHandler(new AuditHandler()) - ->addMethodHandlers([ - new CustomListToolsHandler(), + ->addRequestHandler(new CustomListToolsHandler()) + ->addRequestHandlers([ new CustomCallToolHandler(), + new CustomGetPromptHandler(), ]) ->build(); ``` -Custom handlers implement `MethodHandlerInterface`: +Request handlers implement `RequestHandlerInterface`: ```php -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; -interface MethodHandlerInterface +interface RequestHandlerInterface { - public function supports(HasMethodInterface $message): bool; + public function supports(Request $request): bool; - public function handle(HasMethodInterface $message, SessionInterface $session); + public function handle(Request $request, SessionInterface $session): Response|Error; } ``` -- `supports()` decides if the handler should look at the incoming message. -- `handle()` must return a JSON-RPC `Response`, an `Error`, or `null`. +- `supports()` decides if the handler should process the incoming request +- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) -Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement -custom `tool/list` and `tool/call` methods independently of the registry. +### Notification Handlers -> **Warning**: Custom method handlers bypass discovery, manual capability registration, and container lookups (unlesss -> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler -> loads and executes them manually. -> Reach for this API only when you need that level of control and are comfortable taking on the additional plumbing. +Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** +return anything - they perform side effects only. + +Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): + +```php +$server = Server::builder() + ->addNotificationHandler(new LoggingNotificationHandler()) + ->addNotificationHandlers([ + new InitializedNotificationHandler(), + new ProgressNotificationHandler(), + ]) + ->build(); +``` + +Notification handlers implement `NotificationHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} +``` + +- `supports()` decides if the handler should process the incoming notification +- `handle()` performs side effects but **does not** return a value (notifications have no response) + +### Key Differences + +| Handler Type | Interface | Returns | Use Case | +|-------------|-----------|---------|----------| +| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | +| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | + +### Example + +Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement +custom `tools/list` and `tools/call` request handlers independently of the registry. ## Complete Example @@ -453,8 +504,10 @@ $server = Server::builder() | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | | `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | -| `addMethodHandler()` | handler | Prepend a single custom method handler | -| `addMethodHandlers()` | handlers | Prepend multiple custom method handlers | +| `addRequestHandler()` | handler | Prepend a single custom request handler | +| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | +| `addNotificationHandler()` | handler | Prepend a single custom notification handler | +| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | | `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php index ef47f32d..9ff0a822 100644 --- a/examples/custom-method-handlers/server.php +++ b/examples/custom-method-handlers/server.php @@ -15,7 +15,7 @@ use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\ListToolsRequest; @@ -24,7 +24,7 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Transport\StdioTransport; @@ -58,7 +58,7 @@ ), ]; -$listToolsHandler = new class($toolDefinitions) implements MethodHandlerInterface { +$listToolsHandler = new class($toolDefinitions) implements RequestHandlerInterface { /** * @param array $toolDefinitions */ @@ -66,20 +66,20 @@ public function __construct(private array $toolDefinitions) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListToolsRequest; + return $request instanceof ListToolsRequest; } - public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - assert($message instanceof ListToolsRequest); + assert($request instanceof ListToolsRequest); - return new Response($message->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); + return new Response($request->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); } }; -$callToolHandler = new class($toolDefinitions) implements MethodHandlerInterface { +$callToolHandler = new class($toolDefinitions) implements RequestHandlerInterface { /** * @param array $toolDefinitions */ @@ -87,20 +87,20 @@ public function __construct(private array $toolDefinitions) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CallToolRequest; + return $request instanceof CallToolRequest; } - public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - assert($message instanceof CallToolRequest); + assert($request instanceof CallToolRequest); - $name = $message->name; - $args = $message->arguments ?? []; + $name = $request->name; + $args = $request->arguments ?? []; if (!isset($this->toolDefinitions[$name])) { - return new Error($message->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); + return new Error($request->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); } try { @@ -118,9 +118,9 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter $result = [new TextContent('Unknown tool')]; } - return new Response($message->getId(), new CallToolResult($result)); + return new Response($request->getId(), new CallToolResult($result)); } catch (Throwable $e) { - return new Response($message->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); + return new Response($request->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); } } }; @@ -132,7 +132,7 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter ->setLogger(logger()) ->setContainer(container()) ->setCapabilities($capabilities) - ->addMethodHandlers([$listToolsHandler, $callToolHandler]) + ->addRequestHandlers([$listToolsHandler, $callToolHandler]) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index b6e34cca..2ca446c2 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -13,62 +13,75 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\InvalidInputMessageException; -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Schema\Notification; -use Mcp\Schema\Request; +use Mcp\Schema; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\MessageInterface; +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; /** + * Factory for creating JSON-RPC message objects from raw input. + * + * Handles all types of JSON-RPC messages: + * - Requests (have method + id) + * - Notifications (have method, no id) + * - Responses (have result + id) + * - Errors (have error + id) + * * @author Christopher Hertel + * @author Kyrian Obikwelu */ final class MessageFactory { /** - * Registry of all known messages. + * Registry of all known message classes that have methods. * - * @var array> + * @var array> */ private const REGISTERED_MESSAGES = [ - Notification\CancelledNotification::class, - Notification\InitializedNotification::class, - Notification\LoggingMessageNotification::class, - Notification\ProgressNotification::class, - Notification\PromptListChangedNotification::class, - Notification\ResourceListChangedNotification::class, - Notification\ResourceUpdatedNotification::class, - Notification\RootsListChangedNotification::class, - Notification\ToolListChangedNotification::class, - Request\CallToolRequest::class, - Request\CompletionCompleteRequest::class, - Request\CreateSamplingMessageRequest::class, - Request\GetPromptRequest::class, - Request\InitializeRequest::class, - Request\ListPromptsRequest::class, - Request\ListResourcesRequest::class, - Request\ListResourceTemplatesRequest::class, - Request\ListRootsRequest::class, - Request\ListToolsRequest::class, - Request\PingRequest::class, - Request\ReadResourceRequest::class, - Request\ResourceSubscribeRequest::class, - Request\ResourceUnsubscribeRequest::class, - Request\SetLogLevelRequest::class, + Schema\Notification\CancelledNotification::class, + Schema\Notification\InitializedNotification::class, + Schema\Notification\LoggingMessageNotification::class, + Schema\Notification\ProgressNotification::class, + Schema\Notification\PromptListChangedNotification::class, + Schema\Notification\ResourceListChangedNotification::class, + Schema\Notification\ResourceUpdatedNotification::class, + Schema\Notification\RootsListChangedNotification::class, + Schema\Notification\ToolListChangedNotification::class, + + Schema\Request\CallToolRequest::class, + Schema\Request\CompletionCompleteRequest::class, + Schema\Request\CreateSamplingMessageRequest::class, + Schema\Request\GetPromptRequest::class, + Schema\Request\InitializeRequest::class, + Schema\Request\ListPromptsRequest::class, + Schema\Request\ListResourcesRequest::class, + Schema\Request\ListResourceTemplatesRequest::class, + Schema\Request\ListRootsRequest::class, + Schema\Request\ListToolsRequest::class, + Schema\Request\PingRequest::class, + Schema\Request\ReadResourceRequest::class, + Schema\Request\ResourceSubscribeRequest::class, + Schema\Request\ResourceUnsubscribeRequest::class, + Schema\Request\SetLogLevelRequest::class, ]; /** - * @param array> $registeredMessages + * @param array> $registeredMessages */ public function __construct( private readonly array $registeredMessages, ) { - foreach ($this->registeredMessages as $message) { - if (!is_subclass_of($message, HasMethodInterface::class)) { - throw new InvalidArgumentException(\sprintf('Message classes must implement %s.', HasMethodInterface::class)); + foreach ($this->registeredMessages as $messageClass) { + if (!is_subclass_of($messageClass, Request::class) && !is_subclass_of($messageClass, Notification::class)) { + throw new InvalidArgumentException(\sprintf('Message classes must extend %s or %s.', Request::class, Notification::class)); } } } /** - * Creates a new Factory instance with the all the protocol's default notifications and requests. + * Creates a new Factory instance with all the protocol's default messages. */ public static function make(): self { @@ -76,11 +89,16 @@ public static function make(): self } /** - * @return iterable + * Creates message objects from JSON input. + * + * Supports both single messages and batch requests. Returns an array containing + * MessageInterface objects or InvalidInputMessageException instances for invalid messages. + * + * @return array * * @throws \JsonException When the input string is not valid JSON */ - public function create(string $input): iterable + public function create(string $input): array { $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); @@ -88,32 +106,63 @@ public function create(string $input): iterable $data = [$data]; } + $messages = []; foreach ($data as $message) { - if (!isset($message['method']) || !\is_string($message['method'])) { - yield new InvalidInputMessageException('Invalid JSON-RPC request, missing valid "method".'); - continue; - } - try { - yield $this->getType($message['method'])::fromArray($message); + $messages[] = $this->createMessage($message); } catch (InvalidInputMessageException $e) { - yield $e; - continue; + $messages[] = $e; } } + + return $messages; } /** - * @return class-string + * Creates a single message object from parsed JSON data. + * + * @param array $data + * + * @throws InvalidInputMessageException + */ + private function createMessage(array $data): MessageInterface + { + try { + if (isset($data['error'])) { + return Error::fromArray($data); + } + + if (isset($data['result'])) { + return Response::fromArray($data); + } + + if (!isset($data['method'])) { + throw new InvalidInputMessageException('Invalid JSON-RPC message: missing "method", "result", or "error" field.'); + } + + $messageClass = $this->findMessageClassByMethod($data['method']); + + return $messageClass::fromArray($data); + } catch (InvalidArgumentException $e) { + throw new InvalidInputMessageException($e->getMessage(), 0, $e); + } + } + + /** + * Finds the registered message class for a given method name. + * + * @return class-string + * + * @throws InvalidInputMessageException */ - private function getType(string $method): string + private function findMessageClassByMethod(string $method): string { - foreach (self::REGISTERED_MESSAGES as $type) { - if ($type::getMethod() === $method) { - return $type; + foreach ($this->registeredMessages as $messageClass) { + if ($messageClass::getMethod() === $method) { + return $messageClass; } } - throw new InvalidInputMessageException(\sprintf('Invalid JSON-RPC request, unknown method "%s".', $method)); + throw new InvalidInputMessageException(\sprintf('Unknown method "%s".', $method)); } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 64cb8455..ae802580 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -57,17 +57,23 @@ public static function fromArray(array $data): self if (!isset($data['jsonrpc']) || MessageInterface::JSONRPC_VERSION !== $data['jsonrpc']) { throw new InvalidArgumentException('Invalid or missing "jsonrpc" in Error data.'); } - if (!isset($data['id']) || !\is_string($data['id'])) { + if (!isset($data['id'])) { throw new InvalidArgumentException('Invalid or missing "id" in Error data.'); } - if (!isset($data['code']) || !\is_int($data['code'])) { + if (!\is_string($data['id']) && !\is_int($data['id'])) { + throw new InvalidArgumentException('Invalid "id" type in Error data.'); + } + if (!isset($data['error']) || !\is_array($data['error'])) { + throw new InvalidArgumentException('Invalid or missing "error" field in Error data.'); + } + if (!isset($data['error']['code']) || !\is_int($data['error']['code'])) { throw new InvalidArgumentException('Invalid or missing "code" in Error data.'); } - if (!isset($data['message']) || !\is_string($data['message'])) { + if (!isset($data['error']['message']) || !\is_string($data['error']['message'])) { throw new InvalidArgumentException('Invalid or missing "message" in Error data.'); } - return new self($data['id'], $data['code'], $data['message'], $data['data'] ?? null); + return new self($data['id'], $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null); } public static function forParseError(string $message, string|int $id = ''): self diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 6e5ae2c6..f1521265 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -56,6 +56,9 @@ public static function fromArray(array $data): self if (!isset($data['result'])) { throw new InvalidArgumentException('Response must contain "result" field.'); } + if (!\is_array($data['result'])) { + throw new InvalidArgumentException('Response "result" must be an array.'); + } return new self($data['id'], $data['result']); } diff --git a/src/Server.php b/src/Server.php index 54ba8103..1eb24e8c 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,11 +12,10 @@ namespace Mcp; use Mcp\Server\Builder; -use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Symfony\Component\Uid\Uuid; /** * @author Christopher Hertel @@ -25,7 +24,7 @@ final class Server { public function __construct( - private readonly JsonRpcHandler $jsonRpcHandler, + private readonly Protocol $protocol, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -44,25 +43,11 @@ public static function builder(): Builder */ public function run(TransportInterface $transport): mixed { - $transport->initialize(); - - $this->logger->info('Transport initialized.', [ - 'transport' => $transport::class, - ]); + $this->logger->info('Running server...'); - $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { - foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { - if (null === $response) { - continue; - } - - $transport->send($response, $context); - } - }); + $transport->initialize(); - $transport->onSessionEnd(function (Uuid $sessionId) { - $this->jsonRpcHandler->destroySession($sessionId); - }); + $this->protocol->connect($transport); try { return $transport->listen(); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 8ac2dc4a..924851cd 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -36,8 +36,8 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; -use Mcp\Server\Handler\JsonRpcHandler; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -78,9 +78,14 @@ final class Builder private ?ServerCapabilities $explicitCapabilities = null; /** - * @var array + * @var array */ - private array $customMethodHandlers = []; + private array $requestHandlers = []; + + /** + * @var array + */ + private array $notificationHandlers = []; /** * @var array{ @@ -185,9 +190,9 @@ public function setCapabilities(ServerCapabilities $capabilities): self /** * Register a single custom method handler. */ - public function addMethodHandler(MethodHandlerInterface $handler): self + public function addRequestHandler(RequestHandlerInterface $handler): self { - $this->customMethodHandlers[] = $handler; + $this->requestHandlers[] = $handler; return $this; } @@ -195,12 +200,36 @@ public function addMethodHandler(MethodHandlerInterface $handler): self /** * Register multiple custom method handlers. * - * @param iterable $handlers + * @param iterable $handlers */ - public function addMethodHandlers(iterable $handlers): self + public function addRequestHandlers(iterable $handlers): self { foreach ($handlers as $handler) { - $this->customMethodHandlers[] = $handler; + $this->requestHandlers[] = $handler; + } + + return $this; + } + + /** + * Register a single custom notification handler. + */ + public function addNotificationHandler(NotificationHandlerInterface $handler): self + { + $this->notificationHandlers[] = $handler; + + return $this; + } + + /** + * Register multiple custom notification handlers. + * + * @param iterable $handlers + */ + public function addNotificationHandlers(iterable $handlers): self + { + foreach ($handlers as $handler) { + $this->notificationHandlers[] = $handler; } return $this; @@ -362,7 +391,7 @@ public function build(): Server $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); $referenceHandler = new ReferenceHandler($container); - $methodHandlers = array_merge($this->customMethodHandlers, [ + $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\PingHandler(), new Handler\Request\InitializeHandler($configuration), new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), @@ -372,19 +401,22 @@ public function build(): Server new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), + ]); + $notificationHandlers = array_merge($this->notificationHandlers, [ new Handler\Notification\InitializedHandler(), ]); - $jsonRpcHandler = new JsonRpcHandler( - methodHandlers: $methodHandlers, + $protocol = new Protocol( + requestHandlers: $requestHandlers, + notificationHandlers: $notificationHandlers, messageFactory: $messageFactory, sessionFactory: $sessionFactory, sessionStore: $sessionStore, logger: $logger, ); - return new Server($jsonRpcHandler, $logger); + return new Server($protocol, $logger); } private function performDiscovery( diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php deleted file mode 100644 index 5f785bc5..00000000 --- a/src/Server/Handler/JsonRpcHandler.php +++ /dev/null @@ -1,258 +0,0 @@ - - */ -class JsonRpcHandler -{ - /** - * @param array $methodHandlers - */ - public function __construct( - private readonly array $methodHandlers, - private readonly MessageFactory $messageFactory, - private readonly SessionFactoryInterface $sessionFactory, - private readonly SessionStoreInterface $sessionStore, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * @return iterable}> - */ - public function process(string $input, ?Uuid $sessionId): iterable - { - $this->logger->info('Received message to process.', ['message' => $input]); - - $this->runGarbageCollection(); - - try { - $messages = iterator_to_array($this->messageFactory->create($input)); - } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - $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]); - $error = Error::forInvalidRequest($message->getMessage()); - yield [$this->encodeResponse($error), []]; - continue; - } - - $this->logger->debug(\sprintf('Decoded incoming message "%s".', $message::class), [ - 'method' => $message->getMethod(), - ]); - - $messageId = $message instanceof Request ? $message->getId() : 0; - - try { - $response = $this->handle($message, $session); - yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; - } catch (\DomainException) { - yield [null, []]; - } catch (NotFoundExceptionInterface $e) { - $this->logger->warning( - \sprintf('Failed to create response: %s', $e->getMessage()), - ['exception' => $e], - ); - - $error = Error::forMethodNotFound($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\InvalidArgumentException $e) { - $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInvalidParams($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\Throwable $e) { - $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInternalError($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } - } - - $session->save(); - } - - /** - * Encodes a response to JSON, handling encoding errors gracefully. - */ - private function encodeResponse(Response|Error|null $response): ?string - { - if (null === $response) { - $this->logger->info('The handler created an empty response.'); - - return null; - } - - $this->logger->info('Encoding response.', ['response' => $response]); - - try { - if ($response instanceof Response && [] === $response->result) { - return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); - } - - return json_encode($response, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message_id' => $response->getId(), - 'exception' => $e, - ]); - - $fallbackError = new Error( - id: $response->getId(), - code: Error::INTERNAL_ERROR, - message: 'Response could not be encoded to JSON' - ); - - return json_encode($fallbackError, \JSON_THROW_ON_ERROR); - } - } - - /** - * If the handler does support the message, but does not create a response, other handlers will be tried. - * - * @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, SessionInterface $session): Response|Error|null - { - $this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [ - 'message' => $message, - ]); - - $handled = false; - foreach ($this->methodHandlers as $handler) { - 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; - } - } - - if ($handled) { - return 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/Handler/MethodHandlerInterface.php b/src/Server/Handler/MethodHandlerInterface.php deleted file mode 100644 index 0f61631b..00000000 --- a/src/Server/Handler/MethodHandlerInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -interface MethodHandlerInterface -{ - public function supports(HasMethodInterface $message): bool; - - /** - * @throws ExceptionInterface When the handler encounters an error processing the request - */ - public function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null; -} diff --git a/src/Server/Handler/Notification/InitializedHandler.php b/src/Server/Handler/Notification/InitializedHandler.php index 01881a13..08dec76a 100644 --- a/src/Server/Handler/Notification/InitializedHandler.php +++ b/src/Server/Handler/Notification/InitializedHandler.php @@ -11,27 +11,24 @@ namespace Mcp\Server\Handler\Notification; -use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\InitializedNotification; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class InitializedHandler implements MethodHandlerInterface +final class InitializedHandler implements NotificationHandlerInterface { - public function supports(HasMethodInterface $message): bool + public function supports(Notification $notification): bool { - return $message instanceof InitializedNotification; + return $notification instanceof InitializedNotification; } - public function handle(InitializedNotification|HasMethodInterface $message, SessionInterface $session): Response|Error|null + public function handle(Notification $message, SessionInterface $session): void { - $session->set('initialized', true); + \assert($message instanceof InitializedNotification); - return null; + $session->set('initialized', true); } } diff --git a/src/Server/Handler/Notification/NotificationHandlerInterface.php b/src/Server/Handler/Notification/NotificationHandlerInterface.php new file mode 100644 index 00000000..8746dc73 --- /dev/null +++ b/src/Server/Handler/Notification/NotificationHandlerInterface.php @@ -0,0 +1,25 @@ + + */ +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index d9b36066..dea2725b 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -17,11 +17,10 @@ use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -30,7 +29,7 @@ * @author Christopher Hertel * @author Tobias Nyholm */ -final class CallToolHandler implements MethodHandlerInterface +final class CallToolHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -39,24 +38,24 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CallToolRequest; + return $request instanceof CallToolRequest; } - public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof CallToolRequest); + \assert($request instanceof CallToolRequest); - $toolName = $message->name; - $arguments = $message->arguments ?? []; + $toolName = $request->name; + $arguments = $request->arguments ?? []; $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); try { $reference = $this->referenceProvider->getTool($toolName); if (null === $reference) { - throw new ToolNotFoundException($message); + throw new ToolNotFoundException($request); } $result = $this->referenceHandler->handle($reference, $arguments); @@ -67,25 +66,25 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter 'result_type' => \gettype($result), ]); - return new Response($message->getId(), new CallToolResult($formatted)); + return new Response($request->getId(), new CallToolResult($formatted)); } catch (ToolNotFoundException $e) { $this->logger->error('Tool not found', ['name' => $toolName]); - return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); } catch (ToolCallException|ExceptionInterface $e) { $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ 'tool' => $toolName, 'arguments' => $arguments, ]); - return Error::forInternalError('Error while executing tool', $message->getId()); + return Error::forInternalError('Error while executing tool', $request->getId()); } catch (\Throwable $e) { $this->logger->error('Unhandled error during tool execution', [ 'name' => $toolName, 'exception' => $e->getMessage(), ]); - return Error::forInternalError('Error while executing tool', $message->getId()); + return Error::forInternalError('Error while executing tool', $request->getId()); } } } diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index be4d67d1..6787e48b 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -14,11 +14,10 @@ use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CompletionCompleteRequest; use Mcp\Schema\Result\CompletionCompleteResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -27,7 +26,7 @@ * * @author Kyrian Obikwelu */ -final class CompletionCompleteHandler implements MethodHandlerInterface +final class CompletionCompleteHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -35,43 +34,43 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CompletionCompleteRequest; + return $request instanceof CompletionCompleteRequest; } - public function handle(CompletionCompleteRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof CompletionCompleteRequest); + \assert($request instanceof CompletionCompleteRequest); - $name = $message->argument['name'] ?? ''; - $value = $message->argument['value'] ?? ''; + $name = $request->argument['name'] ?? ''; + $value = $request->argument['value'] ?? ''; - $reference = match ($message->ref->type) { - 'ref/prompt' => $this->referenceProvider->getPrompt($message->ref->name), - 'ref/resource' => $this->referenceProvider->getResourceTemplate($message->ref->uri), + $reference = match ($request->ref->type) { + 'ref/prompt' => $this->referenceProvider->getPrompt($request->ref->name), + 'ref/resource' => $this->referenceProvider->getResourceTemplate($request->ref->uri), default => null, }; if (null === $reference) { - return new Response($message->getId(), new CompletionCompleteResult([])); + return new Response($request->getId(), new CompletionCompleteResult([])); } $providers = $reference->completionProviders; $provider = $providers[$name] ?? null; if (null === $provider) { - return new Response($message->getId(), new CompletionCompleteResult([])); + return new Response($request->getId(), new CompletionCompleteResult([])); } if (\is_string($provider)) { if (!class_exists($provider)) { - return Error::forInternalError('Invalid completion provider', $message->getId()); + return Error::forInternalError('Invalid completion provider', $request->getId()); } $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); } if (!$provider instanceof ProviderInterface) { - return Error::forInternalError('Invalid completion provider type', $message->getId()); + return Error::forInternalError('Invalid completion provider type', $request->getId()); } try { @@ -80,9 +79,9 @@ public function handle(CompletionCompleteRequest|HasMethodInterface $message, Se $hasMore = $total > 100; $paged = \array_slice($completions, 0, 100); - return new Response($message->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); + return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); } catch (\Throwable) { - return Error::forInternalError('Error while handling completion request', $message->getId()); + return Error::forInternalError('Error while handling completion request', $request->getId()); } } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 758ab9de..cf321981 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -17,11 +17,10 @@ use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -29,7 +28,7 @@ /** * @author Tobias Nyholm */ -final class GetPromptHandler implements MethodHandlerInterface +final class GetPromptHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -38,41 +37,41 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof GetPromptRequest; + return $request instanceof GetPromptRequest; } - public function handle(GetPromptRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof GetPromptRequest); + \assert($request instanceof GetPromptRequest); - $promptName = $message->name; - $arguments = $message->arguments ?? []; + $promptName = $request->name; + $arguments = $request->arguments ?? []; try { $reference = $this->referenceProvider->getPrompt($promptName); if (null === $reference) { - throw new PromptNotFoundException($message); + throw new PromptNotFoundException($request); } $result = $this->referenceHandler->handle($reference, $arguments); $formatted = $reference->formatResult($result); - return new Response($message->getId(), new GetPromptResult($formatted)); + return new Response($request->getId(), new GetPromptResult($formatted)); } catch (PromptNotFoundException $e) { $this->logger->error('Prompt not found', ['prompt_name' => $promptName]); - return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); } catch (PromptGetException|ExceptionInterface $e) { $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return Error::forInternalError('Error while handling prompt', $message->getId()); + return Error::forInternalError('Error while handling prompt', $request->getId()); } catch (\Throwable $e) { $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return Error::forInternalError('Error while handling prompt', $message->getId()); + return Error::forInternalError('Error while handling prompt', $request->getId()); } } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index e9d7a751..28bf109f 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -12,38 +12,37 @@ namespace Mcp\Server\Handler\Request; use Mcp\Schema\Implementation; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server\Configuration; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class InitializeHandler implements MethodHandlerInterface +final class InitializeHandler implements RequestHandlerInterface { public function __construct( public readonly ?Configuration $configuration = null, ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof InitializeRequest; + return $request instanceof InitializeRequest; } - public function handle(InitializeRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof InitializeRequest); + \assert($request instanceof InitializeRequest); - $session->set('client_info', $message->clientInfo->jsonSerialize()); + $session->set('client_info', $request->clientInfo->jsonSerialize()); return new Response( - $message->getId(), + $request->getId(), new InitializeResult( $this->configuration->capabilities ?? new ServerCapabilities(), $this->configuration->serverInfo ?? new Implementation(), diff --git a/src/Server/Handler/Request/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php index 4a5b0556..2db8a7ab 100644 --- a/src/Server/Handler/Request/ListPromptsHandler.php +++ b/src/Server/Handler/Request/ListPromptsHandler.php @@ -13,17 +13,16 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm */ -final class ListPromptsHandler implements MethodHandlerInterface +final class ListPromptsHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +30,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListPromptsRequest; + return $request instanceof ListPromptsRequest; } /** * @throws InvalidCursorException */ - public function handle(ListPromptsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListPromptsRequest); + \assert($request instanceof ListPromptsRequest); - $page = $this->registry->getPrompts($this->pageSize, $message->cursor); + $page = $this->registry->getPrompts($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListPromptsResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php index eadd3427..76b48bb0 100644 --- a/src/Server/Handler/Request/ListResourceTemplatesHandler.php +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -13,17 +13,16 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourceTemplatesRequest; use Mcp\Schema\Result\ListResourceTemplatesResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class ListResourceTemplatesHandler implements MethodHandlerInterface +final class ListResourceTemplatesHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +30,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListResourceTemplatesRequest; + return $request instanceof ListResourceTemplatesRequest; } /** * @throws InvalidCursorException */ - public function handle(ListResourceTemplatesRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListResourceTemplatesRequest); + \assert($request instanceof ListResourceTemplatesRequest); - $page = $this->registry->getResourceTemplates($this->pageSize, $message->cursor); + $page = $this->registry->getResourceTemplates($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListResourceTemplatesResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php index 383a5f43..7e4a4ce7 100644 --- a/src/Server/Handler/Request/ListResourcesHandler.php +++ b/src/Server/Handler/Request/ListResourcesHandler.php @@ -13,17 +13,16 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Result\ListResourcesResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm */ -final class ListResourcesHandler implements MethodHandlerInterface +final class ListResourcesHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +30,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListResourcesRequest; + return $request instanceof ListResourcesRequest; } /** * @throws InvalidCursorException */ - public function handle(ListResourcesRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListResourcesRequest); + \assert($request instanceof ListResourcesRequest); - $page = $this->registry->getResources($this->pageSize, $message->cursor); + $page = $this->registry->getResources($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListResourcesResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php index d34b9a5b..81854a62 100644 --- a/src/Server/Handler/Request/ListToolsHandler.php +++ b/src/Server/Handler/Request/ListToolsHandler.php @@ -13,18 +13,17 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel * @author Tobias Nyholm */ -final class ListToolsHandler implements MethodHandlerInterface +final class ListToolsHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -32,22 +31,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListToolsRequest; + return $request instanceof ListToolsRequest; } /** * @throws InvalidCursorException When the cursor is invalid */ - public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListToolsRequest); + \assert($request instanceof ListToolsRequest); - $page = $this->registry->getTools($this->pageSize, $message->cursor); + $page = $this->registry->getTools($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListToolsResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/PingHandler.php b/src/Server/Handler/Request/PingHandler.php index bc2cae32..378926c1 100644 --- a/src/Server/Handler/Request/PingHandler.php +++ b/src/Server/Handler/Request/PingHandler.php @@ -11,27 +11,26 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class PingHandler implements MethodHandlerInterface +final class PingHandler implements RequestHandlerInterface { - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof PingRequest; + return $request instanceof PingRequest; } - public function handle(PingRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof PingRequest); + \assert($request instanceof PingRequest); - return new Response($message->getId(), new EmptyResult()); + return new Response($request->getId(), new EmptyResult()); } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 6691021a..17d2781c 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -16,11 +16,10 @@ use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -28,7 +27,7 @@ /** * @author Tobias Nyholm */ -final class ReadResourceHandler implements MethodHandlerInterface +final class ReadResourceHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -37,23 +36,23 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ReadResourceRequest; + return $request instanceof ReadResourceRequest; } - public function handle(ReadResourceRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof ReadResourceRequest); + \assert($request instanceof ReadResourceRequest); - $uri = $message->uri; + $uri = $request->uri; $this->logger->debug('Reading resource', ['uri' => $uri]); try { $reference = $this->referenceProvider->getResource($uri); if (null === $reference) { - throw new ResourceNotFoundException($message); + throw new ResourceNotFoundException($request); } $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); @@ -64,15 +63,15 @@ public function handle(ReadResourceRequest|HasMethodInterface $message, SessionI $formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType); } - return new Response($message->getId(), new ReadResourceResult($formatted)); + return new Response($request->getId(), new ReadResourceResult($formatted)); } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri]); - return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); + return new Error($request->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); - return Error::forInternalError('Error while reading resource', $message->getId()); + return Error::forInternalError('Error while reading resource', $request->getId()); } } } diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php new file mode 100644 index 00000000..d89b2c1f --- /dev/null +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -0,0 +1,27 @@ + + */ +interface RequestHandlerInterface +{ + public function supports(Request $request): bool; + + public function handle(Request $request, SessionInterface $session): Response|Error; +} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php new file mode 100644 index 00000000..11ec6a87 --- /dev/null +++ b/src/Server/Protocol.php @@ -0,0 +1,324 @@ + + * @author Kyrian Obikwelu + */ +class Protocol +{ + /** @var TransportInterface|null */ + private ?TransportInterface $transport = null; + + /** + * @param array $requestHandlers + * @param array $notificationHandlers + */ + public function __construct( + private readonly array $requestHandlers, + private readonly array $notificationHandlers, + private readonly MessageFactory $messageFactory, + private readonly SessionFactoryInterface $sessionFactory, + private readonly SessionStoreInterface $sessionStore, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Connect this protocol to a transport. + * + * The protocol takes ownership of the transport and sets up all callbacks. + * + * @param TransportInterface $transport + */ + public function connect(TransportInterface $transport): void + { + if ($this->transport) { + throw new \RuntimeException('Protocol already connected to a transport'); + } + + $this->transport = $transport; + + $this->transport->onMessage([$this, 'processInput']); + + $this->transport->onSessionEnd([$this, 'destroySession']); + + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); + } + + /** + * Handle an incoming message from the transport. + * + * This is called by the transport whenever ANY message arrives. + */ + public function processInput(string $input, ?Uuid $sessionId): void + { + $this->logger->info('Received message to process.', ['message' => $input]); + + $this->gcSessions(); + + try { + $messages = $this->messageFactory->create($input); + } catch (\JsonException $e) { + $this->logger->warning('Failed to decode json message.', ['exception' => $e]); + $error = Error::forParseError($e->getMessage()); + $this->sendResponse($error, ['session_id' => $sessionId]); + + return; + } + + $session = $this->resolveSession($sessionId, $messages); + if (null === $session) { + return; + } + + foreach ($messages as $message) { + if ($message instanceof InvalidInputMessageException) { + $this->handleInvalidMessage($message, $session); + } elseif ($message instanceof Request) { + $this->handleRequest($message, $session); + } elseif ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message, $session); + } elseif ($message instanceof Notification) { + $this->handleNotification($message, $session); + } + } + + $session->save(); + } + + private function handleInvalidMessage(InvalidInputMessageException $exception, SessionInterface $session): void + { + $this->logger->warning('Failed to create message.', ['exception' => $exception]); + + $error = Error::forInvalidRequest($exception->getMessage()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } + + private function handleRequest(Request $request, SessionInterface $session): void + { + $this->logger->info('Handling request.', ['request' => $request]); + + $handlerFound = false; + + foreach ($this->requestHandlers as $handler) { + if (!$handler->supports($request)) { + continue; + } + + $handlerFound = true; + + try { + $response = $handler->handle($request, $session); + $this->sendResponse($response, ['session_id' => $session->getId()]); + } catch (\InvalidArgumentException $e) { + $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); + $error = Error::forInvalidParams($e->getMessage(), $request->getId()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); + $error = Error::forInternalError($e->getMessage(), $request->getId()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } + + break; + } + + if (!$handlerFound) { + $error = Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $request::getMethod()), $request->getId()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } + } + + private function handleResponse(Response|Error $response, SessionInterface $session): void + { + $this->logger->info('Handling response.', ['response' => $response]); + // TODO: Implement response handling + } + + private function handleNotification(Notification $notification, SessionInterface $session): void + { + $this->logger->info('Handling notification.', ['notification' => $notification]); + + foreach ($this->notificationHandlers as $handler) { + if (!$handler->supports($notification)) { + continue; + } + + try { + $handler->handle($notification, $session); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Error while handling notification: %s', $e->getMessage()), ['exception' => $e]); + } + } + } + + /** + * @param array $context + */ + public function sendRequest(Request $request, array $context = []): void + { + $this->logger->info('Sending request.', ['request' => $request, 'context' => $context]); + // TODO: Implement request sending + } + + /** + * @param array $context + */ + public function sendResponse(Response|Error $response, array $context = []): void + { + $this->logger->info('Sending response.', ['response' => $response, 'context' => $context]); + + $encoded = null; + + try { + if ($response instanceof Response && [] === $response->result) { + $encoded = json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); + } + + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message_id' => $response->getId(), + 'exception' => $e, + ]); + + $fallbackError = new Error( + id: $response->getId(), + code: Error::INTERNAL_ERROR, + message: 'Response could not be encoded to JSON' + ); + + $encoded = json_encode($fallbackError, \JSON_THROW_ON_ERROR); + } + $context['type'] = 'response'; + + $this->transport->send($encoded, $context); + } + + /** + * @param array $context + */ + public function sendNotification(Notification $notification, array $context = []): void + { + $this->logger->info('Sending notification.', ['notification' => $notification, 'context' => $context]); + $context['type'] = 'notification'; + // TODO: Implement notification sending + } + + /** + * @param array $messages + */ + private function hasInitializeRequest(array $messages): bool + { + foreach ($messages as $message) { + if ($message instanceof InitializeRequest) { + return true; + } + } + + return false; + } + + /** + * Resolves and validates the session based on the request context. + * + * @param Uuid|null $sessionId The session ID from the transport + * @param array $messages The parsed messages + */ + private function resolveSession(?Uuid $sessionId, array $messages): ?SessionInterface + { + if ($this->hasInitializeRequest($messages)) { + // 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.'); + $this->sendResponse($error, ['session_id' => $sessionId]); + + return null; + } + + // 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.'); + $this->sendResponse($error); + + return null; + } + + return $this->sessionFactory->create($this->sessionStore); + } + + if (!$sessionId) { + $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); + $this->sendResponse($error, ['status_code' => 400]); + + return null; + } + + if (!$this->sessionStore->exists($sessionId)) { + $error = Error::forInvalidRequest('Session not found or has expired.'); + $this->sendResponse($error, ['status_code' => 404]); + + return null; + } + + return $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + } + + /** + * Run garbage collection on expired sessions. + * Uses the session store's internal TTL configuration. + */ + private function gcSessions(): 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/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php index c910154f..a082d070 100644 --- a/src/Server/Transport/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -27,9 +27,11 @@ interface TransportInterface public function initialize(): void; /** - * Registers a callback that will be invoked whenever the transport receives an incoming message. + * Register callback for ALL incoming messages. * - * @param callable(string $message, ?Uuid $sessionId): void $listener The callback function to execute when the message occurs + * The transport calls this whenever ANY message arrives, regardless of source. + * + * @param callable(string $message, ?Uuid $sessionId): void $listener */ public function onMessage(callable $listener): void; @@ -45,15 +47,21 @@ public function onMessage(callable $listener): void; public function listen(): mixed; /** - * Sends a raw JSON-RPC message string back to the client. + * Send a message to the client. + * + * The transport decides HOW to send based on context * - * @param string $data The JSON-RPC message string to send - * @param array $context The context of the message + * @param array $context Context about this message: + * - 'session_id': Uuid|null + * - 'type': 'response'|'request'|'notification' + * - 'in_reply_to': int|string|null (ID of request this responds to) + * - 'expects_response': bool (if this is a request needing response) */ public function send(string $data, array $context): void; /** - * Registers a callback that will be invoked when a session needs to be destroyed. + * Register callback for session termination. + * * 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 @@ -65,7 +73,6 @@ public function onSessionEnd(callable $listener): void; * * This method should be called when the transport is no longer needed. * It should clean up any resources and close any connections. - * `Server::run()` calls this automatically after `listen()` exits. */ public function close(): void; } diff --git a/tests/Unit/JsonRpc/HandlerTest.php b/tests/Unit/JsonRpc/HandlerTest.php deleted file mode 100644 index be9820ed..00000000 --- a/tests/Unit/JsonRpc/HandlerTest.php +++ /dev/null @@ -1,118 +0,0 @@ -getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle'); - - $handlerB = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->once())->method('handle'); - - $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 JsonRpcHandler( - methodHandlers: [$handlerA, $handlerB, $handlerC], - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - ); - $sessionId = Uuid::v4(); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - iterator_to_array($result); - } - - #[TestDox('Make sure a single request can NOT be handled by multiple handlers.')] - public function testHandleMultipleRequests() - { - $handlerA = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle')->willReturn(new Response(1, ['result' => 'success'])); - - $handlerB = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->never())->method('handle'); - - $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 JsonRpcHandler( - methodHandlers: [$handlerA, $handlerB, $handlerC], - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - ); - $sessionId = Uuid::v4(); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', - $sessionId - ); - iterator_to_array($result); - } -} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 12d2b233..7f591e57 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -13,9 +13,12 @@ use Mcp\Exception\InvalidInputMessageException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\CancelledNotification; use Mcp\Schema\Notification\InitializedNotification; use Mcp\Schema\Request\GetPromptRequest; +use Mcp\Schema\Request\PingRequest; use PHPUnit\Framework\TestCase; final class MessageFactoryTest extends TestCase @@ -28,68 +31,373 @@ protected function setUp(): void CancelledNotification::class, InitializedNotification::class, GetPromptRequest::class, + PingRequest::class, ]); } - public function testCreateRequest() + public function testCreateRequestWithIntegerId(): void { $json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}'; - $result = $this->first($this->factory->create($json)); + $results = $this->factory->create($json); + $this->assertCount(1, $results); + /** @var GetPromptRequest $result */ + $result = $results[0]; $this->assertInstanceOf(GetPromptRequest::class, $result); $this->assertSame('prompts/get', $result::getMethod()); $this->assertSame('create_story', $result->name); $this->assertSame(123, $result->getId()); } - public function testCreateNotification() + public function testCreateRequestWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "method": "ping", "id": "abc-123"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var PingRequest $result */ + $result = $results[0]; + $this->assertInstanceOf(PingRequest::class, $result); + $this->assertSame('ping', $result::getMethod()); + $this->assertSame('abc-123', $result->getId()); + } + + public function testCreateNotification(): void { $json = '{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 12345}}'; - $result = $this->first($this->factory->create($json)); + $results = $this->factory->create($json); + $this->assertCount(1, $results); + /** @var CancelledNotification $result */ + $result = $results[0]; $this->assertInstanceOf(CancelledNotification::class, $result); $this->assertSame('notifications/cancelled', $result::getMethod()); $this->assertSame(12345, $result->requestId); } - public function testInvalidJson() + public function testCreateNotificationWithoutParams(): void + { + $json = '{"jsonrpc": "2.0", "method": "notifications/initialized"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var InitializedNotification $result */ + $result = $results[0]; + $this->assertInstanceOf(InitializedNotification::class, $result); + $this->assertSame('notifications/initialized', $result::getMethod()); + } + + public function testCreateResponseWithIntegerId(): void + { + $json = '{"jsonrpc": "2.0", "id": 456, "result": {"content": [{"type": "text", "text": "Hello"}]}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Response $result */ + $result = $results[0]; + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(456, $result->getId()); + $this->assertIsArray($result->result); + $this->assertArrayHasKey('content', $result->result); + } + + public function testCreateResponseWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "id": "response-1", "result": {"status": "ok"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Response $result */ + $result = $results[0]; + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('response-1', $result->getId()); + $this->assertEquals(['status' => 'ok'], $result->result); + } + + public function testCreateErrorWithIntegerId(): void + { + $json = '{"jsonrpc": "2.0", "id": 789, "error": {"code": -32601, "message": "Method not found"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertSame(789, $result->getId()); + $this->assertSame(-32601, $result->code); + $this->assertSame('Method not found', $result->message); + $this->assertNull($result->data); + } + + public function testCreateErrorWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "id": "err-1", "error": {"code": -32600, "message": "Invalid request"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertSame('err-1', $result->getId()); + $this->assertSame(-32600, $result->code); + $this->assertSame('Invalid request', $result->message); + } + + public function testCreateErrorWithData(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "Server error", "data": {"details": "Something went wrong"}}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertEquals(['details' => 'Something went wrong'], $result->data); + } + + public function testBatchRequests(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "test"}, "id": 2}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(3, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(GetPromptRequest::class, $results[1]); + $this->assertInstanceOf(InitializedNotification::class, $results[2]); + } + + public function testBatchWithMixedMessages(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "id": 2, "result": {"status": "ok"}}, + {"jsonrpc": "2.0", "id": 3, "error": {"code": -32600, "message": "Invalid"}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(4, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(Response::class, $results[1]); + $this->assertInstanceOf(Error::class, $results[2]); + $this->assertInstanceOf(InitializedNotification::class, $results[3]); + } + + public function testInvalidJson(): void { $this->expectException(\JsonException::class); - $this->first($this->factory->create('invalid json')); + $this->factory->create('invalid json'); } - public function testMissingMethod() + public function testMissingJsonRpcVersion(): void { - $result = $this->first($this->factory->create('{"jsonrpc": "2.0", "params": {}, "id": 1}')); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing valid "method".', $result->getMessage()); + $json = '{"method": "ping", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); } - public function testBatchMissingMethod() + public function testInvalidJsonRpcVersion(): void { - $results = $this->factory->create('[{"jsonrpc": "2.0", "params": {}, "id": 1}, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}]'); + $json = '{"jsonrpc": "1.0", "method": "ping", "id": 1}'; - $results = iterator_to_array($results); - $result = array_shift($results); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing valid "method".', $result->getMessage()); + $results = $this->factory->create($json); - $result = array_shift($results); - $this->assertInstanceOf(InitializedNotification::class, $result); + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); + } + + public function testMissingAllIdentifyingFields(): void + { + $json = '{"jsonrpc": "2.0", "params": {}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('missing', $results[0]->getMessage()); + } + + public function testUnknownMethod(): void + { + $json = '{"jsonrpc": "2.0", "method": "unknown/method", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); + } + + public function testUnknownNotificationMethod(): void + { + $json = '{"jsonrpc": "2.0", "method": "notifications/unknown"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); + } + + public function testNotificationMethodUsedAsRequest(): void + { + // When a notification method is used with an id, it should still create the notification + // The fromArray validation will handle any issues + $json = '{"jsonrpc": "2.0", "method": "notifications/initialized", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + // The notification class will reject the id in fromArray validation + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testErrorMissingId(): void + { + $json = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testErrorMissingCode(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('code', $results[0]->getMessage()); + } + + public function testErrorMissingMessage(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('message', $results[0]->getMessage()); + } + + public function testBatchWithErrors(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "params": {}, "id": 2}, + {"jsonrpc": "2.0", "method": "unknown/method", "id": 3}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(4, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[2]); + $this->assertInstanceOf(InitializedNotification::class, $results[3]); + } + + public function testMakeFactoryWithDefaultMessages(): void + { + $factory = MessageFactory::make(); + $json = '{"jsonrpc": "2.0", "method": "ping", "id": 1}'; + + $results = $factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + } + + public function testResponseWithInvalidIdType(): void + { + $json = '{"jsonrpc": "2.0", "id": true, "result": {"status": "ok"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testErrorWithInvalidIdType(): void + { + $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testResponseWithNonArrayResult(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "result": "not an array"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('result', $results[0]->getMessage()); } - /** - * @param iterable $items - */ - private function first(iterable $items): mixed + public function testErrorWithNonArrayErrorField(): void { - foreach ($items as $item) { - return $item; - } + $json = '{"jsonrpc": "2.0", "id": 1, "error": "not an object"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('error', $results[0]->getMessage()); + } + + public function testErrorWithInvalidCodeType(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": "not-a-number", "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('code', $results[0]->getMessage()); + } + + public function testErrorWithInvalidMessageType(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600, "message": 123}}'; + + $results = $this->factory->create($json); - return null; + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('message', $results[0]->getMessage()); } } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php new file mode 100644 index 00000000..859c4271 --- /dev/null +++ b/tests/Unit/Server/ProtocolTest.php @@ -0,0 +1,615 @@ + */ + private MockObject&TransportInterface $transport; + + protected function setUp(): void + { + $this->sessionFactory = $this->createMock(SessionFactoryInterface::class); + $this->sessionStore = $this->createMock(SessionStoreInterface::class); + $this->transport = $this->createMock(TransportInterface::class); + } + + #[TestDox('A single notification can be handled by multiple handlers')] + public function testNotificationHandledByMultipleHandlers(): void + { + $handlerA = $this->createMock(NotificationHandlerInterface::class); + $handlerA->method('supports')->willReturn(true); + $handlerA->expects($this->once())->method('handle'); + + $handlerB = $this->createMock(NotificationHandlerInterface::class); + $handlerB->method('supports')->willReturn(false); + $handlerB->expects($this->never())->method('handle'); + + $handlerC = $this->createMock(NotificationHandlerInterface::class); + $handlerC->method('supports')->willReturn(true); + $handlerC->expects($this->once())->method('handle'); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [$handlerA, $handlerB, $handlerC], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + } + + #[TestDox('A single request is handled only by the first matching handler')] + public function testRequestHandledByFirstMatchingHandler(): void + { + $handlerA = $this->createMock(RequestHandlerInterface::class); + $handlerA->method('supports')->willReturn(true); + $handlerA->expects($this->once())->method('handle')->willReturn(new Response(1, ['result' => 'success'])); + + $handlerB = $this->createMock(RequestHandlerInterface::class); + $handlerB->method('supports')->willReturn(false); + $handlerB->expects($this->never())->method('handle'); + + $handlerC = $this->createMock(RequestHandlerInterface::class); + $handlerC->method('supports')->willReturn(true); + $handlerC->expects($this->never())->method('handle'); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + $session->method('getId')->willReturn(Uuid::v4()); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['result']); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handlerA, $handlerB, $handlerC], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId + ); + } + + #[TestDox('Initialize request must not have a session ID')] + public function testInitializeRequestWithSessionIdReturnsError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'session ID MUST NOT be sent'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}', + $sessionId + ); + } + + #[TestDox('Initialize request must not be part of a batch')] + public function testInitializeRequestInBatchReturnsError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'MUST NOT be part of a batch'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + '[{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]', + null + ); + } + + #[TestDox('Non-initialize requests require a session ID')] + public function testNonInitializeRequestWithoutSessionIdReturnsError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'session id is REQUIRED'); + }), + $this->callback(function ($context) { + return isset($context['status_code']) && 400 === $context['status_code']; + }) + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + null + ); + } + + #[TestDox('Non-existent session ID returns error')] + public function testNonExistentSessionIdReturnsError(): void + { + $this->sessionStore->method('exists')->willReturn(false); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'Session not found or has expired'); + }), + $this->callback(function ($context) { + return isset($context['status_code']) && 404 === $context['status_code']; + }) + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId + ); + } + + #[TestDox('Invalid JSON returns parse error')] + public function testInvalidJsonReturnsParseError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::PARSE_ERROR === $decoded['error']['code']; + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + 'invalid json', + null + ); + } + + #[TestDox('Invalid message structure returns error')] + public function testInvalidMessageStructureReturnsError(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::INVALID_REQUEST === $decoded['error']['code']; + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "params": {}}', + $sessionId + ); + } + + #[TestDox('Request without handler returns method not found error')] + public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::METHOD_NOT_FOUND === $decoded['error']['code'] + && str_contains($decoded['error']['message'], 'No handler found'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "ping"}', + $sessionId + ); + } + + #[TestDox('Handler throwing InvalidArgumentException returns invalid params error')] + public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \InvalidArgumentException('Invalid parameter')); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::INVALID_PARAMS === $decoded['error']['code'] + && str_contains($decoded['error']['message'], 'Invalid parameter'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', + $sessionId + ); + } + + #[TestDox('Handler throwing unexpected exception returns internal error')] + public function testHandlerUnexpectedExceptionReturnsInternalError(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \RuntimeException('Unexpected error')); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::INTERNAL_ERROR === $decoded['error']['code'] + && str_contains($decoded['error']['message'], 'Unexpected error'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', + $sessionId + ); + } + + #[TestDox('Notification handler exceptions are caught and logged')] + public function testNotificationHandlerExceptionsAreCaught(): void + { + $handler = $this->createMock(NotificationHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \RuntimeException('Handler error')); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [$handler], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + + $this->expectNotToPerformAssertions(); + } + + #[TestDox('Successful request returns response with session ID')] + public function testSuccessfulRequestReturnsResponseWithSessionId(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturn(new Response(1, ['status' => 'ok'])); + + $sessionId = Uuid::v4(); + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn($sessionId); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['result']); + }), + $this->callback(function ($context) use ($sessionId) { + return isset($context['session_id']) && $context['session_id']->equals($sessionId); + }) + ); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId + ); + } + + #[TestDox('Batch requests are processed and send multiple responses')] + public function testBatchRequestsAreProcessed(): void + { + $handlerA = $this->createMock(RequestHandlerInterface::class); + $handlerA->method('supports')->willReturn(true); + $handlerA->method('handle')->willReturnCallback(function ($request) { + return new Response($request->getId(), ['method' => $request::getMethod()]); + }); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + // Expect two calls to send() + $this->transport->expects($this->exactly(2)) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['result']); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handlerA], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '[{"jsonrpc": "2.0", "method": "tools/list", "id": 1}, {"jsonrpc": "2.0", "method": "prompts/list", "id": 2}]', + $sessionId + ); + } + + #[TestDox('Session is saved after processing')] + public function testSessionIsSavedAfterProcessing(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $session->expects($this->once())->method('save'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + } + + #[TestDox('Destroy session removes session from store')] + public function testDestroySessionRemovesSession(): void + { + $sessionId = Uuid::v4(); + + $this->sessionStore->expects($this->once()) + ->method('destroy') + ->with($sessionId); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->destroySession($sessionId); + } +} diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 1c58f58b..f7a8a370 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -12,35 +12,146 @@ namespace Mcp\Tests\Unit; use Mcp\Server; -use Mcp\Server\Handler\JsonRpcHandler; -use Mcp\Server\Transport\InMemoryTransport; +use Mcp\Server\Builder; +use Mcp\Server\Protocol; +use Mcp\Server\Transport\TransportInterface; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class ServerTest extends TestCase +final class ServerTest extends TestCase { - public function testJsonExceptions() + /** @var MockObject&Protocol */ + private $protocol; + + /** @var MockObject&TransportInterface */ + private $transport; + + protected function setUp(): void + { + $this->protocol = $this->createMock(Protocol::class); + $this->transport = $this->createMock(TransportInterface::class); + } + + #[TestDox('builder() returns a Builder instance')] + public function testBuilderReturnsBuilderInstance(): void + { + $builder = Server::builder(); + + $this->assertInstanceOf(Builder::class, $builder); + } + + #[TestDox('run() orchestrates transport lifecycle and protocol connection')] + public function testRunOrchestatesTransportLifecycle(): void + { + $callOrder = []; + + $this->transport->expects($this->once()) + ->method('initialize') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'initialize'; + }); + + $this->protocol->expects($this->once()) + ->method('connect') + ->with($this->transport) + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'connect'; + }); + + $this->transport->expects($this->once()) + ->method('listen') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'listen'; + + return 0; + }); + + $this->transport->expects($this->once()) + ->method('close') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'close'; + }); + + $server = new Server($this->protocol); + $result = $server->run($this->transport); + + $this->assertEquals([ + 'initialize', + 'connect', + 'listen', + 'close', + ], $callOrder); + + $this->assertEquals(0, $result); + } + + #[TestDox('run() closes transport even if listen() throws exception')] + public function testRunClosesTransportEvenOnException(): void + { + $this->transport->method('initialize'); + $this->protocol->method('connect'); + + $this->transport->expects($this->once()) + ->method('listen') + ->willThrowException(new \RuntimeException('Transport error')); + + // close() should still be called even though listen() threw + $this->transport->expects($this->once())->method('close'); + + $server = new Server($this->protocol); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Transport error'); + + $server->run($this->transport); + } + + #[TestDox('run() propagates exception if initialize() throws')] + public function testRunPropagatesInitializeException(): void { - $handler = $this->getMockBuilder(JsonRpcHandler::class) - ->disableOriginalConstructor() - ->onlyMethods(['process']) - ->getMock(); - - $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls( - [['{"jsonrpc":"2.0","id":0,"error":{"code":-32700,"message":"Parse error"}}', []]], - [['success', []]] - ); - - $transport = $this->getMockBuilder(InMemoryTransport::class) - ->setConstructorArgs([['foo', 'bar']]) - ->onlyMethods(['send', 'close']) - ->getMock(); - $transport->expects($this->exactly(2))->method('send')->willReturnOnConsecutiveCalls( - null, - null - ); - $transport->expects($this->once())->method('close'); - - $server = new Server($handler); - $server->run($transport); + $this->transport->expects($this->once()) + ->method('initialize') + ->willThrowException(new \RuntimeException('Initialize error')); + + $server = new Server($this->protocol); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Initialize error'); + + $server->run($this->transport); + } + + #[TestDox('run() returns value from transport.listen()')] + public function testRunReturnsTransportListenValue(): void + { + $this->transport->method('initialize'); + $this->protocol->method('connect'); + $this->transport->method('close'); + + $expectedReturn = 42; + $this->transport->expects($this->once()) + ->method('listen') + ->willReturn($expectedReturn); + + $server = new Server($this->protocol); + $result = $server->run($this->transport); + + $this->assertEquals($expectedReturn, $result); + } + + #[TestDox('run() connects protocol to transport')] + public function testRunConnectsProtocolToTransport(): void + { + $this->transport->method('initialize'); + $this->transport->method('listen')->willReturn(0); + $this->transport->method('close'); + + $this->protocol->expects($this->once()) + ->method('connect') + ->with($this->identicalTo($this->transport)); + + $server = new Server($this->protocol); + $server->run($this->transport); } }