Add roots capability (client workspace declaration) #10

Closed
opened 2026-05-17 15:56:08 +00:00 by claude-noether · 1 comment
Collaborator

Add the Roots capability — client declares which filesystem/URL roots are in scope for this session, and the server reads them to scope its operations.

Goal

Today the shell/read_file/search_files tools have no idea which directory the user considers "the project." They take an absolute path and trust the caller. Roots give the server first-class workspace awareness: it can refuse paths outside declared roots, default cwd to the first root, and surface root-aware tool descriptions.

Methods to add

Method Direction Notes
roots/list server → client Returns { roots: [{ uri, name? }] }. uri is file://….
notifications/roots/list_changed client → server Sent when the user changes workspace folders. Server should roots/list again.

API for lmcp

local roots = server:roots()  -- cached; auto-refreshed on list_changed
for _, r in ipairs(roots) do print(r.uri, r.name) end

-- Helper used by file-touching tools:
if not server:path_in_roots("/etc/shadow") then
    return { ok = false, error = "outside declared roots" }
end

Cache the result of roots/list per session; invalidate on receipt of notifications/roots/list_changed.

Capabilities (server declares it can use roots; client declares it can serve them)

server.capabilities (no field — server consumes, doesn't advertise)
client.capabilities.roots = { listChanged = true }

Scope (v1)

  • roots/list request + notifications/roots/list_changed handling.
  • Cached list available to tool handlers via server:roots().
  • Helper server:path_in_roots(path) for opt-in path enforcement (does not auto-enforce — tools choose to call it).

Out of scope

  • Auto-enforcement of root scoping on every file tool (would break existing callers).
  • Persisting root choices across sessions.

Depends on

  • Streamable HTTP done properly — server must be able to send roots/list to the client.

Priority

Medium. Concrete safety win for the shell/file family once it lands. Transport-gated like Sampling.

Add the **Roots** capability — client declares which filesystem/URL roots are in scope for this session, and the server reads them to scope its operations. ## Goal Today the `shell`/`read_file`/`search_files` tools have no idea which directory the user considers "the project." They take an absolute path and trust the caller. Roots give the server first-class workspace awareness: it can refuse paths outside declared roots, default `cwd` to the first root, and surface root-aware tool descriptions. ## Methods to add | Method | Direction | Notes | |---|---|---| | `roots/list` | server → client | Returns `{ roots: [{ uri, name? }] }`. `uri` is `file://…`. | | `notifications/roots/list_changed` | client → server | Sent when the user changes workspace folders. Server should `roots/list` again. | ## API for lmcp ```lua local roots = server:roots() -- cached; auto-refreshed on list_changed for _, r in ipairs(roots) do print(r.uri, r.name) end -- Helper used by file-touching tools: if not server:path_in_roots("/etc/shadow") then return { ok = false, error = "outside declared roots" } end ``` Cache the result of `roots/list` per session; invalidate on receipt of `notifications/roots/list_changed`. ## Capabilities (server declares it can use roots; client declares it can serve them) ``` server.capabilities (no field — server consumes, doesn't advertise) client.capabilities.roots = { listChanged = true } ``` ## Scope (v1) - `roots/list` request + `notifications/roots/list_changed` handling. - Cached list available to tool handlers via `server:roots()`. - Helper `server:path_in_roots(path)` for opt-in path enforcement (does not auto-enforce — tools choose to call it). ## Out of scope - Auto-enforcement of root scoping on every file tool (would break existing callers). - Persisting root choices across sessions. ## Depends on - **Streamable HTTP done properly** — server must be able to send `roots/list` to the client. ## Priority **Medium**. Concrete safety win for the shell/file family once it lands. Transport-gated like Sampling.
Author
Collaborator

Implemented. Builds on the bidirectional transport (#16) and follows the same pattern as #9 (sampling).

Added in lmcp.lua:

  • self._roots_cache[session_id] per-session cache
  • server:roots(session_id, on_fetched) — fires roots/list request via SSE; callback receives (roots_list, err) when client responds; cache populated as side effect
  • server:roots_cached(session_id) — synchronous lookup; returns nil if no fetch has completed
  • server:path_in_roots(session_id, path) — synchronous: true/false/nil (nil = no cache yet, caller should :roots() first). Handles both file:// URIs and bare paths uniformly
  • notifications/roots/list_changed from client → invalidates cache for the sending session. handle_request restructured to dispatch known notifications (side-effect) before the JSON-RPC id == nil early return.
  • server_request now omits params when nil or empty (avoids the {}[] gotcha on the wire). Fixes spec-strict clients reading roots/list requests.

Verified end-to-end:

  1. check_path before any fetch → result key absent (caller treats as nil)
  2. SSE GET stream open; tool calls server:roots(ctx.session_id, cb)roots/list request appears on SSE with no params
  3. Simulated client posts back two roots — callback fires with count=2, cache populated
  4. check_path "/home/user/project/x.lua" → true; "/var/log/x" → false
  5. Client posts notifications/roots/list_changed → no response (correctly suppressed); cache invalidated
  6. Subsequent check_path → nil (cache cleared)

Same handler-await limit as #9 (tool handler cannot block on the callback). For practical use today: prime the cache on session-start (via a tool the client always calls first), then synchronous path_in_roots works in subsequent handlers. Full async patterns wait on #20.

Implemented. Builds on the bidirectional transport (#16) and follows the same pattern as #9 (sampling). **Added in lmcp.lua:** - `self._roots_cache[session_id]` per-session cache - `server:roots(session_id, on_fetched)` — fires `roots/list` request via SSE; callback receives `(roots_list, err)` when client responds; cache populated as side effect - `server:roots_cached(session_id)` — synchronous lookup; returns nil if no fetch has completed - `server:path_in_roots(session_id, path)` — synchronous: true/false/nil (nil = no cache yet, caller should `:roots()` first). Handles both `file://` URIs and bare paths uniformly - `notifications/roots/list_changed` from client → invalidates cache for the sending session. handle_request restructured to dispatch known notifications (side-effect) before the JSON-RPC `id == nil` early return. - `server_request` now omits `params` when nil or empty (avoids the `{}` → `[]` gotcha on the wire). Fixes spec-strict clients reading `roots/list` requests. **Verified end-to-end:** 1. `check_path` before any fetch → `result` key absent (caller treats as nil) 2. SSE GET stream open; tool calls `server:roots(ctx.session_id, cb)` → `roots/list` request appears on SSE with no params 3. Simulated client posts back two roots — callback fires with `count=2`, cache populated 4. `check_path "/home/user/project/x.lua"` → true; `"/var/log/x"` → false 5. Client posts `notifications/roots/list_changed` → no response (correctly suppressed); cache invalidated 6. Subsequent `check_path` → nil (cache cleared) Same handler-await limit as #9 (tool handler cannot block on the callback). For practical use today: prime the cache on session-start (via a tool the client always calls first), then synchronous `path_in_roots` works in subsequent handlers. Full async patterns wait on #20.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marfrit/lmcp#10