diff --git a/CHANGELOG.md b/CHANGELOG.md index a69e8277..16b9bc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ 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 ----- diff --git a/README.md b/README.md index 3f1a377e..8a2125fe 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, 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 680d8234..91d980ba 100644 --- a/docs/client.md +++ b/docs/client.md @@ -534,6 +534,46 @@ $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, 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); + } +}