From 390aa14e785310856931d9d43d52d15a0c072fb6 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 13 Jul 2026 19:39:29 +0000 Subject: [PATCH 1/3] [Client][Server] Add Roots support (roots/list handler + ClientGateway::listRoots) Wire up the MCP "roots" capability on both sides: Client (php-sdk as client): - RootsCallbackInterface + ListRootsRequestHandler answer server roots/list requests, mirroring the Sampling handler. - Client::sendRootsListChanged() emits notifications/roots/list_changed. Server (php-sdk as server): - ClientGateway::listRoots() requests the client's roots, and supportsRoots() reports whether the client advertised the capability. - Add ListRootsResult::fromArray() (used by listRoots()). Includes unit tests, a runnable examples/client/stdio_roots.php example, and docs/README/CHANGELOG updates. phpunit, php-cs-fixer and phpstan green. Co-Authored-By: Claude --- CHANGELOG.md | 1 + README.md | 9 ++ docs/client.md | 38 ++++++ examples/client/stdio_roots.php | 85 ++++++++++++++ src/Client.php | 11 ++ .../Request/ListRootsRequestHandler.php | 63 ++++++++++ .../Request/RootsCallbackInterface.php | 28 +++++ src/Schema/Result/ListRootsResult.php | 23 ++++ src/Server/ClientGateway.php | 45 ++++++++ .../Request/ListRootsRequestHandlerTest.php | 87 ++++++++++++++ .../Schema/Result/ListRootsResultTest.php | 89 ++++++++++++++ tests/Unit/Server/ClientGatewayTest.php | 109 ++++++++++++++++++ 12 files changed, 588 insertions(+) create mode 100644 examples/client/stdio_roots.php create mode 100644 src/Client/Handler/Request/ListRootsRequestHandler.php create mode 100644 src/Client/Handler/Request/RootsCallbackInterface.php create mode 100644 tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php create mode 100644 tests/Unit/Schema/Result/ListRootsResultTest.php create mode 100644 tests/Unit/Server/ClientGatewayTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a69e8277..5253ee8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.6.0 ----- +* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler)` for explicit registration of elements whose schema is only known at runtime. * Add handler interfaces `ToolHandlerInterface`, `ResourceHandlerInterface`, `ResourceTemplateHandlerInterface`, `PromptHandlerInterface`, and the `ElementHandlerInterface` marker. * [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias. diff --git a/README.md b/README.md index 3f1a377e..b37730a6 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,15 @@ $client = Client::builder() ->build(); ``` +- **Roots Support**: Expose `file://` workspace folders to the server +```php +$rootsHandler = new ListRootsRequestHandler($myCallback); +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true)) + ->addRequestHandler($rootsHandler) + ->build(); +``` + - **Logging Notifications**: Receive server log messages ```php $loggingHandler = new LoggingNotificationHandler($myCallback); diff --git a/docs/client.md b/docs/client.md index 680d8234..777d45e7 100644 --- a/docs/client.md +++ b/docs/client.md @@ -534,6 +534,44 @@ $client = Client::builder() > throw new \RuntimeException('Rate limit exceeded'); > ``` +### Roots + +Roots let the client expose a list of `file://` "workspace folders" that the server +is allowed to operate on. Advertise the `roots` capability and register a handler +that answers server `roots/list` requests: + +```php +use Mcp\Client\Handler\Request\ListRootsRequestHandler; +use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Root; + +class WorkspaceRootsCallback implements RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult([ + new Root('file:///home/user/projects/app', 'Application'), + new Root('file:///home/user/projects/library', 'Library'), + ]); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true)) + ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) + ->build(); +``` + +When the client's roots change, notify the server so it can request the updated +list via `roots/list`: + +```php +$client->sendRootsListChanged(); +``` + ## Error Handling The client throws exceptions for various error conditions: diff --git a/examples/client/stdio_roots.php b/examples/client/stdio_roots.php new file mode 100644 index 00000000..09e73514 --- /dev/null +++ b/examples/client/stdio_roots.php @@ -0,0 +1,85 @@ +setClientInfo('STDIO Roots Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(roots: true)) + ->addRequestHandler($rootsRequestHandler) + ->build(); + +$transport = new StdioTransport( + command: 'php', + args: [__DIR__.'/../server/client-communication/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}\n"; + } + echo "\n"; + + // Whenever the client's workspace folders change, notify the server so it can + // request an updated list via roots/list. + echo "Notifying the server that the roots list changed...\n"; + $client->sendRootsListChanged(); + + echo "Client is ready to answer roots/list requests from the server.\n"; +} catch (Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString()."\n"; +} finally { + $client->disconnect(); +} diff --git a/src/Client.php b/src/Client.php index dd290698..ffdded1f 100644 --- a/src/Client.php +++ b/src/Client.php @@ -22,6 +22,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Notification\RootsListChangedNotification; use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\CompletionCompleteRequest; @@ -244,6 +245,16 @@ public function setLoggingLevel(LoggingLevel $level): void $this->sendRequest($request); } + /** + * Notify the server that the client's list of roots has changed. + * + * The server should react by requesting an updated list via roots/list. + */ + public function sendRootsListChanged(): void + { + $this->protocol->sendNotification(new RootsListChangedNotification()); + } + /** * Send a request to the server and wait for response. * diff --git a/src/Client/Handler/Request/ListRootsRequestHandler.php b/src/Client/Handler/Request/ListRootsRequestHandler.php new file mode 100644 index 00000000..ced1a7ab --- /dev/null +++ b/src/Client/Handler/Request/ListRootsRequestHandler.php @@ -0,0 +1,63 @@ + + * + * @author Johannes Wachter + */ +class ListRootsRequestHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RootsCallbackInterface $callback, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListRootsRequest; + } + + /** + * @return Response|Error + */ + public function handle(Request $request): Response|Error + { + \assert($request instanceof ListRootsRequest); + + try { + $result = $this->callback->__invoke($request); + + return new Response($request->getId(), $result); + } catch (\Throwable $e) { + $this->logger->error('Unexpected error while listing roots', ['exception' => $e]); + + return Error::forInternalError('Error while listing roots', $request->getId()); + } + } +} diff --git a/src/Client/Handler/Request/RootsCallbackInterface.php b/src/Client/Handler/Request/RootsCallbackInterface.php new file mode 100644 index 00000000..80677f14 --- /dev/null +++ b/src/Client/Handler/Request/RootsCallbackInterface.php @@ -0,0 +1,28 @@ + + */ +interface RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult; +} diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index 3abe2e0e..4e399d7b 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Result; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Root; @@ -33,6 +34,28 @@ public function __construct( ) { } + /** + * @param array{ + * roots: array, + * _meta?: ?array + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['roots']) || !\is_array($data['roots'])) { + throw new InvalidArgumentException('Missing or invalid "roots" in ListRootsResult data.'); + } + + $roots = array_map( + static fn (array $root): Root => Root::fromArray($root), + array_values($data['roots']), + ); + + $meta = isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null; + + return new self($roots, $meta); + } + /** * @return array{ * roots: Root[], diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 46860715..b258501f 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -32,8 +32,10 @@ use Mcp\Schema\Notification\ProgressNotification; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Result\ListRootsResult; use Mcp\Server\Session\SessionInterface; /** @@ -188,6 +190,49 @@ public function elicit(string $message, ElicitationSchema $requestedSchema, int return ElicitResult::fromArray($response->result); } + /** + * Request the list of filesystem roots exposed by the client. + * + * Roots are the client's "workspace folders" — the directories or files the + * server is allowed to operate on. The client answers the roots/list request + * with a list of file:// URIs. + * + * @param int $timeout The timeout in seconds + * + * @return ListRootsResult The roots exposed by the client + * + * @throws ClientException if the client request results in an error message + */ + public function listRoots(int $timeout = 120): ListRootsResult + { + $request = new ListRootsRequest(); + + $response = $this->request($request, $timeout); + + if ($response instanceof Error) { + throw new ClientException($response); + } + + return ListRootsResult::fromArray($response->result); + } + + /** + * Check if the connected client supports roots. + * + * Roots allow servers to ask the client for the set of directories or files + * it is permitted to operate on. This method checks the client's advertised + * capabilities to determine if roots/list requests are supported. + * + * @return bool True if the client supports roots, false otherwise + */ + public function supportsRoots(): bool + { + $capabilities = $this->session->get('client_capabilities', []); + + // MCP spec: capability presence indicates support (value is typically {} or []) + return \array_key_exists('roots', $capabilities); + } + /** * Check if the connected client supports elicitation. * diff --git a/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php new file mode 100644 index 00000000..9db53858 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php @@ -0,0 +1,87 @@ +createCallback(new ListRootsResult([]))); + + $this->assertTrue($handler->supports(new ListRootsRequest())); + } + + public function testDoesNotSupportOtherRequests(): void + { + $handler = new ListRootsRequestHandler($this->createCallback(new ListRootsResult([]))); + + $this->assertFalse($handler->supports(new PingRequest())); + } + + public function testHandleReturnsRootsFromCallback(): void + { + $result = new ListRootsResult([ + new Root('file:///home/user/project', 'project'), + new Root('file:///tmp'), + ]); + + $handler = new ListRootsRequestHandler($this->createCallback($result)); + + $request = (new ListRootsRequest())->withId('req-1'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('req-1', $response->getId()); + $this->assertSame($result, $response->result); + } + + public function testHandleReturnsErrorWhenCallbackThrows(): void + { + $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + throw new \RuntimeException('boom'); + } + }); + + $request = (new ListRootsRequest())->withId('req-2'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('req-2', $response->getId()); + $this->assertSame('Error while listing roots', $response->message); + } + + private function createCallback(ListRootsResult $result): RootsCallbackInterface + { + return new class($result) implements RootsCallbackInterface { + public function __construct(private readonly ListRootsResult $result) + { + } + + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return $this->result; + } + }; + } +} diff --git a/tests/Unit/Schema/Result/ListRootsResultTest.php b/tests/Unit/Schema/Result/ListRootsResultTest.php new file mode 100644 index 00000000..b5101d69 --- /dev/null +++ b/tests/Unit/Schema/Result/ListRootsResultTest.php @@ -0,0 +1,89 @@ +assertSame($roots, $result->roots); + $this->assertNull($result->meta); + } + + public function testFromArray(): void + { + $result = ListRootsResult::fromArray([ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ['uri' => 'file:///tmp'], + ], + ]); + + $this->assertCount(2, $result->roots); + $this->assertSame('file:///home/user/project', $result->roots[0]->uri); + $this->assertSame('project', $result->roots[0]->name); + $this->assertSame('file:///tmp', $result->roots[1]->uri); + $this->assertNull($result->roots[1]->name); + $this->assertNull($result->meta); + } + + public function testFromArrayWithMeta(): void + { + $result = ListRootsResult::fromArray([ + 'roots' => [['uri' => 'file:///tmp']], + '_meta' => ['requestId' => 'abc'], + ]); + + $this->assertSame(['requestId' => 'abc'], $result->meta); + } + + public function testFromArrayWithMissingRoots(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "roots"'); + + /* @phpstan-ignore argument.type */ + ListRootsResult::fromArray([]); + } + + public function testFromArrayRoundTrip(): void + { + $data = [ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ['uri' => 'file:///tmp'], + ], + '_meta' => ['foo' => 'bar'], + ]; + + $result = ListRootsResult::fromArray($data); + + $this->assertSame($data, json_decode(json_encode($result), true)); + } + + public function testJsonSerializeWithoutMeta(): void + { + $result = new ListRootsResult([new Root('file:///tmp')]); + + $this->assertSame([ + 'roots' => [['uri' => 'file:///tmp']], + ], json_decode(json_encode($result), true)); + } +} diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php new file mode 100644 index 00000000..409669e0 --- /dev/null +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -0,0 +1,109 @@ +createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['roots' => []]); + + $gateway = new ClientGateway($session); + + $this->assertTrue($gateway->supportsRoots()); + } + + public function testSupportsRootsReturnsFalseWhenNotAdvertised(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['sampling' => []]); + + $gateway = new ClientGateway($session); + + $this->assertFalse($gateway->supportsRoots()); + } + + public function testListRootsReturnsRootsFromClient(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $gateway = new ClientGateway($session); + + $response = $this->response([ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ], + ]); + + $result = $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $response); + + $this->assertInstanceOf(ListRootsResult::class, $result); + $this->assertCount(1, $result->roots); + $this->assertSame('file:///home/user/project', $result->roots[0]->uri); + } + + public function testListRootsThrowsClientExceptionOnError(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $gateway = new ClientGateway($session); + + $error = Error::forInternalError('nope', '1'); + + $this->expectException(ClientException::class); + + $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $error); + } + + /** + * Runs the gateway call inside a Fiber, asserts it suspends with a roots/list + * request, then resumes it with the given client response. + * + * @param Response>|Error $response + */ + private function runInFiber(\Closure $call, Response|Error $response): mixed + { + $fiber = new \Fiber($call); + $suspend = $fiber->start(); + + $this->assertIsArray($suspend); + $this->assertSame('request', $suspend['type']); + $this->assertInstanceOf(ListRootsRequest::class, $suspend['request']); + + $fiber->resume($response); + + return $fiber->getReturn(); + } + + /** + * @param array $result + * + * @return Response> + */ + private function response(array $result): Response + { + return new Response('1', $result); + } +} From 792804fb8409800971d07a79b153bc53f7f7361b Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 13 Jul 2026 19:58:17 +0000 Subject: [PATCH 2/3] [Client][Server] Move Roots changelog entry to a new 0.7.0 section 0.6.0 is already released; the Roots additions belong under a new unreleased 0.7.0 section rather than the shipped 0.6.0. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5253ee8e..16b9bc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,11 @@ All notable changes to `mcp/sdk` will be documented in this file. ----- * Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build. +* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. 0.6.0 ----- -* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler)` for explicit registration of elements whose schema is only known at runtime. * Add handler interfaces `ToolHandlerInterface`, `ResourceHandlerInterface`, `ResourceTemplateHandlerInterface`, `PromptHandlerInterface`, and the `ElementHandlerInterface` marker. * [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias. From 68354d7452fd5a28bbbbcb4285e3a312c23f7130 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 13 Jul 2026 20:20:43 +0000 Subject: [PATCH 3/3] [Client][Server] Address Roots review: capability gating, error forwarding, docs & tests Applies the five findings from the PR #1 code review: 1. Spec conformance: examples/docs/README now advertise `new ClientCapabilities(roots: true, rootsListChanged: true)` before sending `notifications/roots/list_changed`. 2. `ClientGateway::supportsRoots()`/`supportsElicitation()` normalize the stored capabilities to an array, avoiding a TypeError when a client advertised an empty capabilities object (\stdClass). 3. `Client::sendRootsListChanged()` now throws when the client did not advertise the `roots.listChanged` capability. 4. Add tests: capability serialization/round-trip, sendRootsListChanged gating (sends when declared, throws otherwise), RootsException message forwarding, and rejection of non-file:// root URIs. 5. `ListRootsRequestHandler` forwards `RootsException` messages to the server (new Mcp\Exception\RootsException), mirroring SamplingRequestHandler. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012NrvzihxUvQzDSoWzjsm58 --- README.md | 2 +- docs/client.md | 6 +- examples/client/stdio_roots.php | 2 +- src/Client.php | 7 ++ .../Request/ListRootsRequestHandler.php | 5 ++ src/Exception/RootsException.php | 24 +++++++ src/Server/ClientGateway.php | 4 +- tests/Unit/Client/ClientTest.php | 60 ++++++++++++++++ .../Request/ListRootsRequestHandlerTest.php | 18 +++++ tests/Unit/Schema/ClientCapabilitiesTest.php | 71 +++++++++++++++++++ .../Schema/Result/ListRootsResultTest.php | 10 +++ 11 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 src/Exception/RootsException.php create mode 100644 tests/Unit/Client/ClientTest.php create mode 100644 tests/Unit/Schema/ClientCapabilitiesTest.php diff --git a/README.md b/README.md index b37730a6..8a2125fe 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ $client = Client::builder() ```php $rootsHandler = new ListRootsRequestHandler($myCallback); $client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true)) + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) ->addRequestHandler($rootsHandler) ->build(); ``` diff --git a/docs/client.md b/docs/client.md index 777d45e7..91d980ba 100644 --- a/docs/client.md +++ b/docs/client.md @@ -560,13 +560,15 @@ class WorkspaceRootsCallback implements RootsCallbackInterface } $client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true)) + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) ->build(); ``` When the client's roots change, notify the server so it can request the updated -list via `roots/list`: +list via `roots/list`. This requires advertising the `roots.listChanged` +capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` +throws a `RuntimeException`: ```php $client->sendRootsListChanged(); diff --git a/examples/client/stdio_roots.php b/examples/client/stdio_roots.php index 09e73514..d451661e 100644 --- a/examples/client/stdio_roots.php +++ b/examples/client/stdio_roots.php @@ -48,7 +48,7 @@ public function __invoke(ListRootsRequest $request): ListRootsResult ->setClientInfo('STDIO Roots Test', '1.0.0') ->setInitTimeout(30) ->setRequestTimeout(120) - ->setCapabilities(new ClientCapabilities(roots: true)) + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) ->addRequestHandler($rootsRequestHandler) ->build(); diff --git a/src/Client.php b/src/Client.php index ffdded1f..305a4a83 100644 --- a/src/Client.php +++ b/src/Client.php @@ -17,6 +17,7 @@ use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestException; +use Mcp\Exception\RuntimeException; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; @@ -249,9 +250,15 @@ public function setLoggingLevel(LoggingLevel $level): void * Notify the server that the client's list of roots has changed. * * The server should react by requesting an updated list via roots/list. + * + * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability */ public function sendRootsListChanged(): void { + if (true !== $this->config->capabilities->rootsListChanged) { + throw new RuntimeException('Cannot send a "roots/list_changed" notification without advertising the "roots.listChanged" capability. Build the client with new ClientCapabilities(roots: true, rootsListChanged: true).'); + } + $this->protocol->sendNotification(new RootsListChangedNotification()); } diff --git a/src/Client/Handler/Request/ListRootsRequestHandler.php b/src/Client/Handler/Request/ListRootsRequestHandler.php index ced1a7ab..eb18961c 100644 --- a/src/Client/Handler/Request/ListRootsRequestHandler.php +++ b/src/Client/Handler/Request/ListRootsRequestHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Handler\Request; +use Mcp\Exception\RootsException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -54,6 +55,10 @@ public function handle(Request $request): Response|Error $result = $this->callback->__invoke($request); return new Response($request->getId(), $result); + } catch (RootsException $e) { + $this->logger->error('Listing roots failed: '.$e->getMessage(), ['exception' => $e]); + + return Error::forInternalError($e->getMessage(), $request->getId()); } catch (\Throwable $e) { $this->logger->error('Unexpected error while listing roots', ['exception' => $e]); diff --git a/src/Exception/RootsException.php b/src/Exception/RootsException.php new file mode 100644 index 00000000..8d12cb06 --- /dev/null +++ b/src/Exception/RootsException.php @@ -0,0 +1,24 @@ + + */ +final class RootsException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index b258501f..d7d55eb6 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -227,7 +227,7 @@ public function listRoots(int $timeout = 120): ListRootsResult */ public function supportsRoots(): bool { - $capabilities = $this->session->get('client_capabilities', []); + $capabilities = (array) $this->session->get('client_capabilities', []); // MCP spec: capability presence indicates support (value is typically {} or []) return \array_key_exists('roots', $capabilities); @@ -244,7 +244,7 @@ public function supportsRoots(): bool */ public function supportsElicitation(): bool { - $capabilities = $this->session->get('client_capabilities', []); + $capabilities = (array) $this->session->get('client_capabilities', []); // MCP spec: capability presence indicates support (value is typically {} or []) return \array_key_exists('elicitation', $capabilities); diff --git a/tests/Unit/Client/ClientTest.php b/tests/Unit/Client/ClientTest.php new file mode 100644 index 00000000..e6277e68 --- /dev/null +++ b/tests/Unit/Client/ClientTest.php @@ -0,0 +1,60 @@ +createMock(TransportInterface::class); + $transport->method('send')->willReturnCallback(static function (string $data) use (&$sent): void { + $sent[] = $data; + }); + + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->build(); + + $client->connect($transport); + $client->sendRootsListChanged(); + + $this->assertCount(1, $sent); + $decoded = json_decode($sent[0], true); + $this->assertSame('notifications/roots/list_changed', $decoded['method']); + } + + public function testSendRootsListChangedThrowsWhenCapabilityNotDeclared(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->never())->method('send'); + + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true)) + ->build(); + + $client->connect($transport); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('roots.listChanged'); + + $client->sendRootsListChanged(); + } +} diff --git a/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php index 9db53858..c101b168 100644 --- a/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php +++ b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php @@ -13,6 +13,7 @@ use Mcp\Client\Handler\Request\ListRootsRequestHandler; use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Exception\RootsException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListRootsRequest; @@ -71,6 +72,23 @@ public function __invoke(ListRootsRequest $request): ListRootsResult $this->assertSame('Error while listing roots', $response->message); } + public function testHandleForwardsRootsExceptionMessage(): void + { + $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + throw new RootsException('permission denied'); + } + }); + + $request = (new ListRootsRequest())->withId('req-3'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('req-3', $response->getId()); + $this->assertSame('permission denied', $response->message); + } + private function createCallback(ListRootsResult $result): RootsCallbackInterface { return new class($result) implements RootsCallbackInterface { diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php new file mode 100644 index 00000000..3c62e00b --- /dev/null +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -0,0 +1,71 @@ +assertArrayHasKey('roots', $data); + $this->assertSame([], $data['roots']); + } + + public function testSerializesRootsWithListChanged(): void + { + $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); + + $data = json_decode(json_encode($capabilities), true); + + $this->assertSame(['listChanged' => true], $data['roots']); + } + + public function testSerializesEmptyCapabilitiesAsObject(): void + { + $capabilities = new ClientCapabilities(); + + $this->assertSame('{}', json_encode($capabilities)); + } + + public function testFromArrayReadsRootsListChanged(): void + { + $capabilities = ClientCapabilities::fromArray(['roots' => ['listChanged' => true]]); + + $this->assertTrue($capabilities->roots); + $this->assertTrue($capabilities->rootsListChanged); + } + + public function testFromArrayRootsWithoutListChanged(): void + { + $capabilities = ClientCapabilities::fromArray(['roots' => []]); + + $this->assertTrue($capabilities->roots); + $this->assertNull($capabilities->rootsListChanged); + } + + public function testRoundTripPreservesRootsListChanged(): void + { + $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); + + $data = json_decode(json_encode($capabilities), true); + $restored = ClientCapabilities::fromArray($data); + + $this->assertTrue($restored->roots); + $this->assertTrue($restored->rootsListChanged); + } +} diff --git a/tests/Unit/Schema/Result/ListRootsResultTest.php b/tests/Unit/Schema/Result/ListRootsResultTest.php index b5101d69..e1fc059a 100644 --- a/tests/Unit/Schema/Result/ListRootsResultTest.php +++ b/tests/Unit/Schema/Result/ListRootsResultTest.php @@ -63,6 +63,16 @@ public function testFromArrayWithMissingRoots(): void ListRootsResult::fromArray([]); } + public function testFromArrayRejectsNonFileUri(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must start with "file://"'); + + ListRootsResult::fromArray([ + 'roots' => [['uri' => 'https://example.com']], + ]); + } + public function testFromArrayRoundTrip(): void { $data = [