feat: add roots-demo sample for client roots capability#763
feat: add roots-demo sample for client roots capability#763MichielDean wants to merge 10 commits into
Conversation
Closes modelcontextprotocol#5 Add a standalone sample project demonstrating the MCP Roots capability: - Client declares roots capability with listChanged=true - Client registers filesystem roots using addRoot() - Server queries roots from client via listRoots() - Client sends notifications/roots/list_changed after dynamic changes - Server reacts to change notifications by re-fetching the root list Uses ChannelTransport from kotlin-sdk-testing for in-memory client-server communication without external dependencies.
There was a problem hiding this comment.
Pull request overview
Adds a new standalone sample project under samples/roots-demo/ to demonstrate the MCP Roots client capability end-to-end (client advertises roots + list change support, server lists roots, client notifies changes, server re-fetches).
Changes:
- Added a new
roots-demoGradle sample project (wrapper, version catalog, build config, logging config). - Implemented an in-memory client/server demo using
ChannelTransportthat exercisesroots/listandnotifications/roots/list_changed. - Updated
samples/README.mdto list and describe the new sample.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| samples/roots-demo/src/main/resources/simplelogger.properties | Configures SLF4J SimpleLogger output for the demo. |
| samples/roots-demo/src/main/kotlin/io/modelcontextprotocol/sample/roots/Main.kt | Implements the in-memory client/server roots lifecycle demo. |
| samples/roots-demo/settings.gradle.kts | Declares sample project name, repositories, and optional MCP Kotlin version override. |
| samples/roots-demo/README.md | Documents what the sample demonstrates and how to run it. |
| samples/roots-demo/gradlew.bat | Adds Gradle wrapper Windows script for the standalone sample. |
| samples/roots-demo/gradlew | Adds Gradle wrapper POSIX script for the standalone sample. |
| samples/roots-demo/gradle/wrapper/gradle-wrapper.properties | Configures the Gradle distribution for the sample wrapper. |
| samples/roots-demo/gradle/libs.versions.toml | Declares dependency/plugin versions used by the sample. |
| samples/roots-demo/gradle.properties | Enables Gradle performance features and supports optional SDK override version. |
| samples/roots-demo/build.gradle.kts | Configures application plugin, dependencies, and toolchain for the sample. |
| samples/README.md | Adds the new roots demo to the sample index and provides a short description. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Remove Tools capability from server (not used in this demo)
- Use async instead of launch+CompletableDeferred in notification handler
so the returned Deferred represents actual work completion
- Construct file URIs from System.getProperty('user.home') for
cross-platform correctness (also ensures file:/// prefix)
- Apply null-safe name fallback consistently (?: '(unnamed)')
…c sync - Add try/catch in notification handler to surface errors instead of silently dropping them - Replace delay(500) with CompletableDeferred-based synchronization so the demo waits for the server to process each notification deterministically instead of relying on timing - Close server before client to avoid Not connected errors
…-demo The notification handler launches coroutines via on Dispatchers.Default, so the increment was a data race — two coroutines could read the same value and both write back 1, causing rootsUpdated to never complete. Replaced with AtomicInteger.incrementAndGet() for thread-safe counting.
- Await each notification separately (firstNotification/secondNotification) before sending the next, so the server processes each state change before the client sends the next one - Remove withTimeout(5000) that could throw TimeoutCancellationException and skip close() calls; now the main coroutine awaits each signal directly, so shutdown always runs - Keep AtomicInteger for thread-safe notification counting
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
samples/roots-demo/src/main/kotlin/io/modelcontextprotocol/sample/roots/Main.kt:101
firstNotification.await()/secondNotification.await()have no timeout, so./gradlew runcan hang indefinitely if a notification is missed (or if the handler fails before completing the deferred). Consider adding a bounded wait (e.g.,withTimeout) and ensuring shutdown still happens viatry/finallyso the sample exits cleanly in failure cases.
client.sendRootsListChanged()
firstNotification.await()
println("\n[Client] Removing a root and sending list changed notification...")
client.removeRoot(backendRoot)
client.sendRootsListChanged()
secondNotification.await()
…s on errors Move firstNotification/secondNotification completion into a finally block so that even if listRoots() throws, the main coroutine does not hang waiting on a CompletableDeferred that will never complete.
Every await() on a CompletableDeferred needs an escape hatch so the demo terminates even if notifications never arrive. Both notification awaits now use withTimeoutOrNull with a 5s timeout and print a clear message on timeout. Client/server close() moved into a finally block so resources are always released, even on timeout or unexpected errors.
Strip back the over-engineered async coordination (AtomicInteger, per-notification CompletableDeferred, withTimeoutOrNull, try/finally) and replace with a clear, linear flow that demonstrates the Roots lifecycle: declare capability, add roots, query, notify, react. The demo now matches the simplicity of other SDK samples and the integration tests. A short delay is used for notification propagation which is appropriate for sample code — it prioritizes readability over production-grade async patterns. Key changes: - Removed AtomicInteger counter and separate CompletableDeferred signals - Removed withTimeoutOrNull (not needed for a demo) - Removed try/finally on close (not needed — runBlocking handles cleanup) - Restored hardcoded file:// URIs (matches MCP spec examples and other SDK samples) - Added clear comments for each step in the Roots lifecycle - Total: 82 lines, down from 108
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
samples/roots-demo/src/main/kotlin/io/modelcontextprotocol/sample/roots/Main.kt:79
- The
delay(500)shutdown wait is timing-dependent and can be flaky on slower/loaded machines (the notification-triggeredlistRoots()may not finish before close). Prefer deterministic synchronization (e.g., await aCompletableDeferredcompleted by the notification handler after the re-fetch completes) instead of a fixed sleep.
// Client adds a root and notifies the server
client.addRoot("file:///home/user/projects/shared", "Shared Libraries")
client.sendRootsListChanged()
// Allow the notification handler to process before shutting down
delay(500)
| session.setNotificationHandler<RootsListChangedNotification>( | ||
| Method.Defined.NotificationsRootsListChanged, | ||
| ) { | ||
| launch { | ||
| val updatedRoots = session.listRoots() | ||
| println("\n[Server] Roots list changed — updated roots:") | ||
| updatedRoots.roots.forEach { root -> | ||
| println(" ${root.name ?: "(unnamed)"}: ${root.uri}") | ||
| } | ||
| } | ||
| CompletableDeferred(Unit) |
There was a problem hiding this comment.
Fixed in e241a98. The notification handler now uses structured error handling with try/catch/finally inside a launch block. The launch is required because the handler lambda type (T) -> Deferred<Unit> is not a suspend function, so we can't call session.listRoots() directly. However: (1) errors are caught in try/catch, (2) both firstNotification and secondNotification CompletableDeferred signals are completed in the finally block, and (3) the main coroutine uses withTimeoutOrNull with a try/finally wrapper that guarantees client.close()/server.close() runs even on timeout or error. This addresses the race-with-shutdown concern: the signals are always completed, so main never hangs, and shutdown always happens.
| // Register roots on the client | ||
| client.addRoot("file:///home/user/projects/my-project", "My Project") | ||
| client.addRoot("file:///home/user/Documents", "Documents") | ||
|
|
There was a problem hiding this comment.
Fixed in e241a98. Replaced hard-coded file:///home/user/... paths with Path.of(System.getProperty("user.home")).resolve(...).toUri().toString(). This produces correct file://localhost/... URIs on macOS and file:///home/... URIs on Linux, ensuring the sample works cross-platform. Note: File.toURI() produces file:/home/... (single slash) on Linux which fails the SDK's Root URI validation requiring file:// prefix, so Path.toUri() is used instead.
| 3. **Server queries roots** — `serverSession.listRoots()` sends a `roots/list` | ||
| request to the client. | ||
| 4. **Client sends change notification** — after dynamically adding or removing roots, | ||
| the client calls `sendRootsListChanged()` so the server knows to re-fetch. | ||
| 5. **Server reacts to changes** — the server listens for |
There was a problem hiding this comment.
Fixed in e241a98. The demo now demonstrates both adding AND removing roots. After adding "Shared Libraries" and notifying the server, the client waits for the server to process the notification (via CompletableDeferred), then removes the root with removeRoot() and sends a second sendRootsListChanged(). The server handler prints the updated list after each change, showing the intermediate 3-root state and the final 2-root state. The README already mentions "adding or removing roots" which now matches the code.
- Replace delay(500) with CompletableDeferred-based deterministic sync - Add per-notification signals with withTimeoutOrNull for safe bounds - Wrap main logic in try/finally for guaranteed cleanup on any error - Handle errors in notification handler with try/catch/finally - Complete notification signals in finally block so main never hangs - Use Path.of(user.home).toUri() for cross-platform URIs - Add null-safe name printing (?: "(unnamed)") everywhere - Add removeRoot + second sendRootsListChanged to match README - Remove ServerCapabilities.Tools (was already empty, confirmed correct) - Use launch in handler with structured error handling (suspend constraint)
Summary
Closes #5
Adds a standalone sample project (
samples/roots-demo/) demonstrating the MCP Roots capability — how a client exposes filesystem roots to a server, and how the server reacts when the root list changes.What it demonstrates
listChanged = trueduring initializationaddRoot(uri, name)serverSession.listRoots()(roots/listrequest)sendRootsListChanged()after dynamically adding/removing rootsnotifications/roots/list_changedand re-fetching the updated root listImplementation
Uses
ChannelTransportfromkotlin-sdk-testingfor in-memory client-server communication — no external server, network, or API key required. The sample runs as a singlemain()that prints the full roots exchange lifecycle to the console.Files added
samples/roots-demo/— complete standalone Gradle project (build config, version catalog, wrapper)samples/roots-demo/src/main/kotlin/.../Main.kt— demo sourcesamples/roots-demo/README.md— documentationsamples/README.md— updated to include the new sampleBuild verification
./gradlew assemblesucceeds./gradlew runproduces the expected output showing roots lifecyclektlintCheckon the main project passes (sample projects don't include ktlint)