From eedb46207eadf53447f7519519ce789e01d58a50 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 14 Aug 2026 09:42:13 +0200 Subject: [PATCH 1/2] [Client][Server] Add Roots support 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, normalizing the stored capabilities to an array so an empty capabilities object (\stdClass) doesn't cause a TypeError. - Add ListRootsResult::fromArray() (used by listRoots()). - ListRootsRequestHandler forwards RootsException messages to the server (new Mcp\Exception\RootsException), mirroring SamplingRequestHandler. - Client::sendRootsListChanged() throws when the client did not advertise the roots.listChanged capability. Examples, docs and tests: - examples/README/docs advertise `new ClientCapabilities(roots: true, rootsListChanged: true)` before sending notifications/roots/list_changed. - CHANGELOG entry for Roots (unreleased 0.7.0 section at the time). - Unit tests for capability serialization/round-trip, sendRootsListChanged gating, and RootsException message forwarding. phpunit, php-cs-fixer and phpstan green. --- CHANGELOG.md | 1 + README.md | 9 ++ docs/client.md | 40 +++++++ examples/client/stdio_roots.php | 85 ++++++++++++++ src/Client.php | 18 +++ .../Request/ListRootsRequestHandler.php | 68 +++++++++++ .../Request/RootsCallbackInterface.php | 28 +++++ src/Exception/RootsException.php | 24 ++++ src/Schema/Result/ListRootsResult.php | 23 ++++ src/Server/ClientGateway.php | 47 +++++++- tests/Unit/Client/ClientTest.php | 60 ++++++++++ .../Request/ListRootsRequestHandlerTest.php | 105 +++++++++++++++++ tests/Unit/Schema/ClientCapabilitiesTest.php | 71 ++++++++++++ .../Schema/Result/ListRootsResultTest.php | 99 ++++++++++++++++ tests/Unit/Server/ClientGatewayTest.php | 109 ++++++++++++++++++ 15 files changed, 786 insertions(+), 1 deletion(-) 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 src/Exception/RootsException.php create mode 100644 tests/Unit/Client/ClientTest.php create mode 100644 tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php create mode 100644 tests/Unit/Schema/ClientCapabilitiesTest.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 715b2202..6dc97a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ 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 ----- diff --git a/README.md b/README.md index e0885631..4e1a868d 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, rootsListChanged: 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..12d07367 100644 --- a/docs/client.md +++ b/docs/client.md @@ -600,6 +600,46 @@ 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, 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`. This requires advertising the `roots.listChanged` +capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` +throws a `RuntimeException`: + +```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..d451661e --- /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, rootsListChanged: 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..305a4a83 100644 --- a/src/Client.php +++ b/src/Client.php @@ -17,11 +17,13 @@ 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; 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 +246,22 @@ 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. + * + * @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()); + } + /** * 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..eb18961c --- /dev/null +++ b/src/Client/Handler/Request/ListRootsRequestHandler.php @@ -0,0 +1,68 @@ + + * + * @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 (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]); + + 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/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/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..d7d55eb6 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 = (array) $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. * @@ -199,7 +244,7 @@ public function elicit(string $message, ElicitationSchema $requestedSchema, int */ 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 new file mode 100644 index 00000000..c101b168 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php @@ -0,0 +1,105 @@ +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); + } + + 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 { + public function __construct(private readonly ListRootsResult $result) + { + } + + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return $this->result; + } + }; + } +} 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 new file mode 100644 index 00000000..e1fc059a --- /dev/null +++ b/tests/Unit/Schema/Result/ListRootsResultTest.php @@ -0,0 +1,99 @@ +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 testFromArrayRejectsNonFileUri(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must start with "file://"'); + + ListRootsResult::fromArray([ + 'roots' => [['uri' => 'https://example.com']], + ]); + } + + 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 ece669f9e69b8b627ea38950468e421b7c8720b2 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 14 Aug 2026 09:42:24 +0200 Subject: [PATCH 2/2] [Client][Server] Address review: schema validation, changelog section, connection guard, working example Responds to chr-hertel's review of the Roots support: - ListRootsResult::fromArray() rejects non-array root elements instead of accepting malformed entries silently. - CHANGELOG entry moved to the next unreleased section as the release train advanced past it (0.7.0 -> 0.8.0). - Client::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 roots capability, so the roots/list handler never actually 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. - ClientTest's connected-client mock now goes through setState() so isConnected() reflects a real handshake instead of a stubbed flag. phpunit, php-cs-fixer and phpstan green. --- CHANGELOG.md | 2 +- docs/client.md | 7 ++- examples/client/stdio_roots.php | 44 +++++++++---------- .../ClientAwareService.php | 36 +++++++++++++++ src/Client.php | 7 ++- src/Schema/Result/ListRootsResult.php | 12 +++-- tests/Unit/Client/ClientTest.php | 19 ++++++++ .../Schema/Result/ListRootsResultTest.php | 11 +++++ 8 files changed, 109 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc97a0e..5ed92e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. +* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. 0.7.0 ----- @@ -22,7 +23,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 ----- 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/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/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(); + } } 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);