From 9ec41893035cfd63854ee11a5059565587ec351d Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 13 Jul 2026 19:39:29 +0000 Subject: [PATCH 1/6] [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 01bf9a90..ef216325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,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 e0885631..52e656f8 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,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 494edf1f..0441cb84 100644 --- a/docs/client.md +++ b/docs/client.md @@ -600,6 +600,44 @@ Only the `Accept` action carries content. See `examples/client/stdio_elicitation.php` for a runnable example against the elicitation demo server. +### 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 b325b8874ea2f872622ebdc149681bae1ef363ed Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 13 Jul 2026 19:58:17 +0000 Subject: [PATCH 2/6] [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 ef216325..1aeb388a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,11 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. * Reject malformed `Mcp-Session-Id` headers with a `400` response: a repeated header or a value that is not a valid UUID is now rejected up front instead of surfacing as an uncaught `Uuid::fromString()` error. * Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break). +* 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 2386fafe126f2ecab6633e4b61daf3cb6b2d823e Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 13 Jul 2026 20:20:43 +0000 Subject: [PATCH 3/6] [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 52e656f8..4e1a868d 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,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 0441cb84..12d07367 100644 --- a/docs/client.md +++ b/docs/client.md @@ -626,13 +626,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 = [ From 3e4eadedaa906d9927eedd869d5461bcf04b9aad Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 20 Jul 2026 08:50:37 +0200 Subject: [PATCH 4/6] [Schema] Reject non-array root elements in ListRootsResult::fromArray --- src/Schema/Result/ListRootsResult.php | 12 ++++++++---- tests/Unit/Schema/Result/ListRootsResultTest.php | 11 +++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index 4e399d7b..b2088dd0 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -46,10 +46,14 @@ public static function fromArray(array $data): self 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']), - ); + $roots = []; + foreach ($data['roots'] as $root) { + if (!\is_array($root)) { + throw new InvalidArgumentException('Invalid root in ListRootsResult data, expected an array.'); + } + + $roots[] = Root::fromArray($root); + } $meta = isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null; diff --git a/tests/Unit/Schema/Result/ListRootsResultTest.php b/tests/Unit/Schema/Result/ListRootsResultTest.php index e1fc059a..46729fbf 100644 --- a/tests/Unit/Schema/Result/ListRootsResultTest.php +++ b/tests/Unit/Schema/Result/ListRootsResultTest.php @@ -63,6 +63,17 @@ public function testFromArrayWithMissingRoots(): void ListRootsResult::fromArray([]); } + public function testFromArrayWithNonArrayRoot(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid root in ListRootsResult data, expected an array.'); + + /* @phpstan-ignore argument.type */ + ListRootsResult::fromArray([ + 'roots' => ['file:///tmp'], + ]); + } + public function testFromArrayRejectsNonFileUri(): void { $this->expectException(InvalidArgumentException::class); From 4c210476386d6862badff432268e67b87f85202b Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 27 Jul 2026 19:56:49 +0000 Subject: [PATCH 5/6] [Client][Server] Move Roots changelog entry to a new 0.8.0 section v0.7.0 is released, so the Roots entry belongs in an unreleased 0.8.0 section instead of the shipped 0.7.0 one. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aeb388a..301abb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.8.0 +----- + +* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. + 0.7.0 ----- @@ -15,7 +20,6 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. * Reject malformed `Mcp-Session-Id` headers with a `400` response: a repeated header or a value that is not a valid UUID is now rejected up front instead of surfacing as an uncaught `Uuid::fromString()` error. * Extract RFC 9728 metadata serving into `ProtectedResourceMetadataHandler`, a transport-neutral PSR-15 `RequestHandlerInterface` that can be mounted directly as a Symfony/Laravel controller; `ProtectedResourceMetadataMiddleware` now delegates to it (no BC break). -* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. 0.6.0 ----- From 5d278a99894de410b56fb03e09964a8d7f735f38 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 27 Jul 2026 20:09:45 +0000 Subject: [PATCH 6/6] [Client][Server] Guard sendRootsListChanged() on connection, demo roots/list end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendRootsListChanged() went straight to Protocol::sendNotification(), which sends via `$this->transport?->send()` — on a client that is not connected the notification was silently dropped. It now throws a ConnectionException like sendRequest() does. The stdio example only advertised the capability, so the roots/list handler never ran. The client-communication demo server gets an `inspect_workspace_roots` tool that calls ClientGateway::supportsRoots()/listRoots(); the example calls it, so the server actually issues roots/list and the client answers it. Dropped the try/catch wrapper from the example. Co-Authored-By: Claude Opus 5 (1M context) --- docs/client.md | 7 ++- examples/client/stdio_roots.php | 44 +++++++++---------- .../ClientAwareService.php | 36 +++++++++++++++ src/Client.php | 7 ++- tests/Unit/Client/ClientTest.php | 19 ++++++++ 5 files changed, 89 insertions(+), 24 deletions(-) diff --git a/docs/client.md b/docs/client.md index 12d07367..c2276493 100644 --- a/docs/client.md +++ b/docs/client.md @@ -634,12 +634,17 @@ $client = Client::builder() When the client's roots change, notify the server so it can request the updated list via `roots/list`. This requires advertising the `roots.listChanged` capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` -throws a `RuntimeException`: +throws a `RuntimeException`. On a client that is not connected it throws a +`ConnectionException`: ```php $client->sendRootsListChanged(); ``` +See `examples/client/stdio_roots.php` for a runnable example: it calls the +`inspect_workspace_roots` tool of the client-communication demo server, which +answers by issuing the `roots/list` request back to the client. + ## Error Handling The client throws exceptions for various error conditions: diff --git a/examples/client/stdio_roots.php b/examples/client/stdio_roots.php index d451661e..b10e7431 100644 --- a/examples/client/stdio_roots.php +++ b/examples/client/stdio_roots.php @@ -16,7 +16,9 @@ * - The client advertises the `roots` capability during initialization. * - It answers server `roots/list` requests via a RootsCallbackInterface, * exposing a couple of `file://` workspace folders. - * - It can notify the server when its list of roots changes. + * - Calling the server's `inspect_workspace_roots` tool makes the server issue + * a `roots/list` request, so the handler below actually runs. + * - It notifies the server when its list of roots changes. * * Usage: php examples/client/stdio_roots.php */ @@ -28,6 +30,7 @@ use Mcp\Client\Handler\Request\RootsCallbackInterface; use Mcp\Client\Transport\StdioTransport; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\TextContent; use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\ListRootsResult; use Mcp\Schema\Root; @@ -57,29 +60,26 @@ public function __invoke(ListRootsRequest $request): ListRootsResult args: [__DIR__.'/../server/client-communication/server.php'], ); -try { - echo "Connecting to MCP server...\n"; - $client->connect($transport); +echo "Connecting to MCP server...\n"; +$client->connect($transport); - $serverInfo = $client->getServerInfo(); - echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; +$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"; +// The server tool asks the client for its roots, which triggers the handler above. +echo "Calling 'inspect_workspace_roots'...\n"; +$result = $client->callTool(name: 'inspect_workspace_roots'); + +echo "\nResult:\n"; +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\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(); +// Whenever the client's workspace folders change, notify the server so it can +// request an updated list via roots/list. +echo "\nNotifying 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(); -} +$client->disconnect(); diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php index 2a3d8342..1e895c48 100644 --- a/examples/server/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -25,6 +25,42 @@ public function __construct( $this->logger->info('SamplingTool instantiated for sampling example.'); } + /** + * Ask the client which workspace folders the server is allowed to operate on. + * + * Demonstrates the server side of the "roots" client capability: the tool + * issues a roots/list request that the client answers from its own handler. + * + * @return array{status: string, message: string, roots?: list} + */ + #[McpTool(name: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')] + public function inspectWorkspaceRoots(RequestContext $context): array + { + $clientGateway = $context->getClientGateway(); + + if (!$clientGateway->supportsRoots()) { + return [ + 'status' => 'unsupported', + 'message' => 'Client does not expose roots. Advertise the "roots" capability and register a ListRootsRequestHandler to let the server discover your workspace folders.', + ]; + } + + $result = $clientGateway->listRoots(); + + $roots = []; + foreach ($result->roots as $root) { + $roots[] = ['uri' => $root->uri, 'name' => $root->name]; + } + + $clientGateway->log(LoggingLevel::Info, \sprintf('Client exposed %d root(s).', \count($roots))); + + return [ + 'status' => 'ok', + 'message' => \sprintf('Client exposed %d root(s).', \count($roots)), + 'roots' => $roots, + ]; + } + /** * @return array{incident: string, recommended_actions: string, model: string} */ diff --git a/src/Client.php b/src/Client.php index 305a4a83..7df6e3d6 100644 --- a/src/Client.php +++ b/src/Client.php @@ -251,7 +251,8 @@ public function setLoggingLevel(LoggingLevel $level): void * * The server should react by requesting an updated list via roots/list. * - * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability + * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability + * @throws ConnectionException if the client is not connected */ public function sendRootsListChanged(): void { @@ -259,6 +260,10 @@ public function sendRootsListChanged(): void 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).'); } + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + $this->protocol->sendNotification(new RootsListChangedNotification()); } diff --git a/tests/Unit/Client/ClientTest.php b/tests/Unit/Client/ClientTest.php index e6277e68..29d23ffe 100644 --- a/tests/Unit/Client/ClientTest.php +++ b/tests/Unit/Client/ClientTest.php @@ -12,7 +12,9 @@ namespace Mcp\Tests\Unit\Client; use Mcp\Client; +use Mcp\Client\State\ClientStateInterface; use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\ConnectionException; use Mcp\Exception\RuntimeException; use Mcp\Schema\ClientCapabilities; use PHPUnit\Framework\TestCase; @@ -26,6 +28,10 @@ public function testSendRootsListChangedSendsNotificationWhenCapabilityDeclared( $transport->method('send')->willReturnCallback(static function (string $data) use (&$sent): void { $sent[] = $data; }); + // Stand in for a completed initialize handshake, which the transport drives. + $transport->method('setState')->willReturnCallback(static function (ClientStateInterface $state): void { + $state->setInitialized(true); + }); $client = Client::builder() ->setClientInfo('Roots Test', '1.0.0') @@ -57,4 +63,17 @@ public function testSendRootsListChangedThrowsWhenCapabilityNotDeclared(): void $client->sendRootsListChanged(); } + + public function testSendRootsListChangedThrowsWhenNotConnected(): void + { + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('Client is not connected.'); + + $client->sendRootsListChanged(); + } }