[Client][Server] Add Roots support - #395
Conversation
…y::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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…rding, 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NrvzihxUvQzDSoWzjsm58
290e411 to
3e4eade
Compare
| * 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()`. |
There was a problem hiding this comment.
Needs to move to a 0.8.0 section please
There was a problem hiding this comment.
Moved to a new 0.8.0 section in 4c21047.
| 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()); |
There was a problem hiding this comment.
i get why you're not using sendRequest - it's just a notification, but is this sufficient - we're skipping isConnected check here
There was a problem hiding this comment.
Good catch — it was worse than just skipping the check: Protocol::sendNotification() sends via $this->transport?->send(), so on a disconnected client the notification was silently dropped instead of failing. It now throws a ConnectionException like sendRequest() does (capability check stays first), with a test for it. 5d278a9
There was a problem hiding this comment.
i don't think we need the try-catch block here
and, not sure, but can we demo it the other way around so that the list roots request actually is executed?
There was a problem hiding this comment.
Both done in 5d278a9. try/catch is gone, and the demo is turned around: the client-communication server now has an inspect_workspace_roots tool using ClientGateway::supportsRoots()/listRoots(), and the example calls it — so the server really issues roots/list and the client's handler answers. Output when run:
Calling 'inspect_workspace_roots'...
[ROOTS] Server requested the client's list of roots
Result:
{
"status": "ok",
"message": "Client exposed 2 root(s).",
"roots": [
{"uri": "file:///home/user/projects/app", "name": "Application"},
{"uri": "file:///home/user/projects/library", "name": "Library"}
]
}
The sendRootsListChanged() notification is kept at the end as a secondary demo.
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) <noreply@anthropic.com>
…ts/list end-to-end 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) <noreply@anthropic.com>
Adds support for the MCP Roots capability, letting a client expose
file://"workspace folders" that the server is allowed to operate on.Client side
ListRootsRequestHandler+RootsCallbackInterface— answer serverroots/listrequests from a user-provided callback (mirrors the existingSamplingRequestHandler/SamplingCallbackInterfacepattern).Client::sendRootsListChanged()— sendnotifications/roots/list_changed. Guarded: it throws unless the client advertised theroots.listChangedcapability, so a misconfigured client can't emit an undeclared notification.Mcp\Exception\RootsException— thrown from a roots callback, its message is forwarded to the server (same contract asSamplingException); any other throwable is logged and returned as a generic internal error.Server side
ClientGateway::listRoots()— request the client's roots (returnsListRootsResult, throwsClientExceptionon an error response).ClientGateway::supportsRoots()— capability-gating helper alongsidesupportsElicitation().Schema
ListRootsResult::fromArray()— parse the client's response;Rootcontinues to enforce thefile://URI scheme required by the spec.Docs / examples / tests
docs/client.mdsections and a runnableexamples/client/stdio_roots.php.file://rejection), capability serialization/round-trip,sendRootsListChanged()gating, and theClientGatewayroots methods.0.7.0.Conformance notes
file://per the current spec.rootscapability presence gatesroots/list;roots.listChangedgates thelist_changednotification.