Skip to main content

mcp_methods/server/
roots.rs

1//! MCP client-`roots` adoption (opt-in, fallback-only).
2//!
3//! Some hosts advertise the directory the user is working in via the MCP
4//! `roots` capability — opencode does it on every connection. This module
5//! consumes that advertisement so a server configured with
6//! `workspace.adopt_client_roots: true` and **no** `workspace.root` can
7//! bind the client's directory instead of making the operator hand-write a
8//! path the client already offered.
9//!
10//! Three properties this module exists to preserve, in order:
11//!
12//! 1. **Fallback only.** Anything the operator configured — manifest
13//!    `workspace.root`, `--watch`, `--source-root`, `--workspace`, or a
14//!    runtime `set_root_dir` — makes the workspace
15//!    [`RootOwnership::Operator`](crate::server::workspace::RootOwnership::Operator),
16//!    and adoption refuses to touch it. Forever, including across later
17//!    `roots/list_changed` notifications.
18//! 2. **Contained.** Adoption calls
19//!    [`Workspace::adopt_client_root`](crate::server::workspace::Workspace::adopt_client_root),
20//!    which is the same validate-canonicalize-contain-activate path
21//!    `set_root_dir` uses. A `workspace.sandbox_root` boundary therefore
22//!    applies identically to a path proposed by an external party. The spec
23//!    is explicit that roots are "informational guidance rather than an
24//!    access-control mechanism" — the boundary is what contains them.
25//! 3. **Invisible to everyone else.** The guard chain is ordered
26//!    cheapest-first and exits before any I/O for a server that did not opt
27//!    in or a client that does not advertise `roots`, so no `roots/list`
28//!    request is ever put on the wire for such a client and no connect
29//!    latency is added. The handler also runs on a task rmcp spawns, so
30//!    even the opted-in path never delays the client's session.
31//!
32//! # Deprecated upstream
33//!
34//! MCP `roots` is **deprecated** as of protocol revision `2026-07-28`
35//! ([SEP-2577]): *"New implementations SHOULD NOT adopt it; existing
36//! implementations SHOULD migrate to passing directories or files via tool
37//! parameters, resource URIs, or server configuration."* It is eligible for
38//! removal in the first revision released on or after **2027-07-28**, and
39//! that revision drops the server→client `roots/list` request and
40//! `notifications/roots/list_changed` entirely.
41//!
42//! This module is built anyway, deliberately: it is opt-in, so it costs
43//! deployments that do not enable it nothing, and rmcp negotiates
44//! `2025-11-25`, where the mechanism is live and fully implemented. The
45//! migration path when it goes away is the one the spec names — pass the
46//! directory via a tool parameter, a resource URI, or server configuration
47//! (`workspace.root`).
48//!
49//! [SEP-2577]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577
50
51// `Root`, `ListRootsResult` and `Peer::list_roots` all carry
52// `#[deprecated]` (SEP-2577). Referencing them is the entire point of this
53// module, and `make lint` runs clippy with `-D warnings`. Scoped to this
54// file, mirroring rmcp's own `handler/server.rs`.
55#![expect(deprecated)]
56
57use std::path::PathBuf;
58use std::time::Duration;
59
60use rmcp::service::Peer;
61use rmcp::RoleServer;
62
63use crate::server::server::ServerOptions;
64use crate::server::workspace::{RootOwnership, Workspace};
65
66/// Deadline for the `roots/list` round-trip.
67///
68/// `Peer::list_roots` is generated by rmcp's plain `peer_req` arm, **not**
69/// `peer_req_with_timeout`, so it has no deadline of its own: a client that
70/// accepts the request and never answers would pin this task for the life
71/// of the session. Five seconds is far beyond a local host's honest
72/// response time and far below anything a user would notice, since nothing
73/// waits on this task.
74const LIST_ROOTS_TIMEOUT: Duration = Duration::from_secs(5);
75
76/// `notifications/initialized` arrived — adopt a client root if this
77/// server is configured to and nothing is bound yet.
78pub(crate) async fn on_client_initialized(options: &ServerOptions, peer: &Peer<RoleServer>) {
79    let Some(ws) = adoption_candidate(options) else {
80        return;
81    };
82    // Only a wholly unbound workspace adopts at initialize time. Anything
83    // else means the operator configured a root.
84    if ws.root_ownership() != RootOwnership::Unowned {
85        return;
86    }
87    if !advertises_roots(peer) {
88        return;
89    }
90    adopt_first_valid_root(ws, peer, "initialized").await;
91}
92
93/// `notifications/roots/list_changed` arrived — re-run adoption, unless
94/// the operator has since claimed the root.
95pub(crate) async fn on_client_roots_changed(options: &ServerOptions, peer: &Peer<RoleServer>) {
96    let Some(ws) = adoption_candidate(options) else {
97        return;
98    };
99    // `Operator` never yields; `Unowned` (adoption failed or was never
100    // attempted) and `Adopted` both re-adopt.
101    if ws.root_ownership() == RootOwnership::Operator {
102        return;
103    }
104    // A client that advertised `roots: {}` without `listChanged` should
105    // never have sent this. Ignore it rather than trust it.
106    if !advertises_roots_list_changed(peer) {
107        tracing::warn!(
108            "ignoring roots/list_changed from a client that did not advertise \
109             roots.listChanged"
110        );
111        return;
112    }
113    adopt_first_valid_root(ws, peer, "roots/list_changed").await;
114}
115
116/// The two cheapest guards: is there a workspace, and did the operator opt
117/// in? Both are pure field reads — a server that never sets
118/// `workspace.adopt_client_roots` exits here, before touching the peer.
119fn adoption_candidate(options: &ServerOptions) -> Option<&Workspace> {
120    let ws = options.workspace.as_ref()?;
121    ws.adopts_client_roots().then_some(ws)
122}
123
124/// Does the client advertise the `roots` capability at all? When it does
125/// not, no `roots/list` request is ever sent — that is requirement 3
126/// ("clients that do not advertise roots are wholly unaffected") enforced
127/// at its only possible site.
128fn advertises_roots(peer: &Peer<RoleServer>) -> bool {
129    // `peer_info()` is populated before `serve()` returns, strictly before
130    // any notification handler can run, so this is never spuriously `None`.
131    peer.peer_info()
132        .is_some_and(|info| info.capabilities.roots.is_some())
133}
134
135fn advertises_roots_list_changed(peer: &Peer<RoleServer>) -> bool {
136    peer.peer_info().is_some_and(|info| {
137        info.capabilities
138            .roots
139            .as_ref()
140            .is_some_and(|roots| roots.list_changed == Some(true))
141    })
142}
143
144/// Issue `roots/list`, then bind the first root that survives conversion
145/// and containment.
146///
147/// Every failure here is contained: a warning is logged and the workspace
148/// keeps whatever it had (typically nothing, i.e. it stays unanchored, and
149/// the source tools behave exactly as they do with no root configured).
150/// Nothing retries — a retry loop against a misbehaving client is worse
151/// than an unanchored server.
152async fn adopt_first_valid_root(ws: &Workspace, peer: &Peer<RoleServer>, trigger: &str) {
153    let roots = match tokio::time::timeout(LIST_ROOTS_TIMEOUT, peer.list_roots()).await {
154        Ok(Ok(result)) => result.roots,
155        Ok(Err(err)) => {
156            tracing::warn!(
157                "roots/list ({trigger}) failed: {err}; \
158                 continuing without a client-advertised root"
159            );
160            return;
161        }
162        Err(_) => {
163            tracing::warn!(
164                "roots/list ({trigger}) timed out after {}s; \
165                 continuing without a client-advertised root",
166                LIST_ROOTS_TIMEOUT.as_secs()
167            );
168            return;
169        }
170    };
171    if roots.is_empty() {
172        tracing::warn!("client advertised roots but returned none ({trigger})");
173        return;
174    }
175    // "First valid" is client-order-dependent: no revision of the MCP
176    // schema says anything about the order of `ListRootsResult.roots`, so
177    // a client with several roots picks which one we bind. Operators who
178    // need determinism set `workspace.root`.
179    for root in &roots {
180        // `Root.name` is display-only and must never influence path
181        // resolution.
182        let path = match file_uri_to_path(&root.uri) {
183            Ok(path) => path,
184            Err(reason) => {
185                tracing::warn!("skipping client root {:?}: {reason}", root.uri);
186                continue;
187            }
188        };
189        // Canonicalize before the containment test — it is what makes
190        // `..` and symlinks (and, on macOS, `/var` → `/private/var`)
191        // resolve to the path that will actually be read.
192        let canon = match path.canonicalize() {
193            Ok(canon) => canon,
194            Err(err) => {
195                tracing::warn!(
196                    "skipping client root {:?}: cannot resolve {}: {err}",
197                    root.uri,
198                    path.display()
199                );
200                continue;
201            }
202        };
203        if !canon.is_dir() {
204            tracing::warn!(
205                "skipping client root {:?}: {} is not a directory",
206                root.uri,
207                canon.display()
208            );
209            continue;
210        }
211        match ws.adopt_client_root(&canon) {
212            Ok(msg) => {
213                tracing::info!(
214                    "adopted client root {} ({trigger}): {}",
215                    canon.display(),
216                    msg.lines().next().unwrap_or_default()
217                );
218                return;
219            }
220            Err(reason) => {
221                tracing::warn!("rejected client root {}: {reason}", canon.display());
222                continue;
223            }
224        }
225    }
226    tracing::warn!(
227        "no client-advertised root could be adopted ({trigger}); \
228         the workspace is unchanged"
229    );
230}
231
232/// Convert a `file://` URI to a filesystem path.
233///
234/// `Root.uri` **MUST** be a `file://` URI per the spec, and the spec says
235/// nothing about what a server does with one it cannot interpret — so this
236/// returns a reason string and the caller skips that root with a warning
237/// rather than failing the connection or answering the client with a
238/// protocol error.
239///
240/// Accepted: `file:///abs/path` (empty authority), `file://localhost/abs/path`
241/// (RFC 8089's named-local form), and the authority-less `file:/abs/path`.
242/// Rejected: any other scheme, a non-local host (`file://other/path` names a
243/// file on *another* machine), a relative path, and malformed percent
244/// escapes. Query and fragment are stripped before decoding; percent
245/// decoding happens **after** the URI is split, so an encoded `/`, `?` or
246/// `#` cannot change how the URI parses.
247///
248/// Hand-rolled rather than pulling in `url`: the rules above are the whole
249/// of RFC 8089 that a `file://` root can exercise, and the crate is a
250/// published library whose dependency surface is worth keeping small.
251pub fn file_uri_to_path(uri: &str) -> Result<PathBuf, String> {
252    let Some(rest) = strip_scheme(uri, "file") else {
253        return Err(format!(
254            "only file:// roots are supported (got {:?})",
255            uri.split(':').next().unwrap_or(uri)
256        ));
257    };
258    // Strip fragment then query — both are meaningless for a directory
259    // root, and leaving them in would make them part of the path.
260    let rest = rest.split('#').next().unwrap_or(rest);
261    let rest = rest.split('?').next().unwrap_or(rest);
262
263    let raw_path = match rest.strip_prefix("//") {
264        Some(after_slashes) => {
265            // `//authority/path` — the authority ends at the first `/`.
266            let (authority, path) = match after_slashes.find('/') {
267                Some(idx) => after_slashes.split_at(idx),
268                None => (after_slashes, ""),
269            };
270            if !(authority.is_empty() || authority.eq_ignore_ascii_case("localhost")) {
271                return Err(format!(
272                    "host {authority:?} is not this machine; \
273                     only file:// URIs with an empty host or `localhost` name a local path"
274                ));
275            }
276            path
277        }
278        // `file:/abs/path` — no authority component at all.
279        None => rest,
280    };
281    if raw_path.is_empty() {
282        return Err("no path component".to_string());
283    }
284    let decoded = percent_decode(raw_path)?;
285    #[cfg(windows)]
286    // `file:///C:/x` decodes to `/C:/x`; the drive letter form needs the
287    // leading separator removed to be a real Windows path.
288    let decoded = {
289        let bytes = decoded.as_bytes();
290        if bytes.len() >= 3
291            && bytes[0] == b'/'
292            && bytes[1].is_ascii_alphabetic()
293            && bytes[2] == b':'
294        {
295            decoded[1..].to_string()
296        } else {
297            decoded
298        }
299    };
300    let path = PathBuf::from(decoded);
301    if !path.is_absolute() {
302        return Err(format!("path {} is not absolute", path.display()));
303    }
304    Ok(path)
305}
306
307/// Case-insensitive scheme strip. Schemes are ASCII and case-insensitive
308/// per RFC 3986, so `FILE://` is the same root as `file://`.
309fn strip_scheme<'a>(uri: &'a str, scheme: &str) -> Option<&'a str> {
310    let (head, tail) = uri.split_once(':')?;
311    head.eq_ignore_ascii_case(scheme).then_some(tail)
312}
313
314/// Decode `%XX` escapes. Rejects malformed escapes and any byte sequence
315/// that is not UTF-8 — `file://` URIs percent-encode UTF-8, and a path we
316/// cannot render as a string is a path we should not silently guess at.
317fn percent_decode(s: &str) -> Result<String, String> {
318    if !s.contains('%') {
319        return Ok(s.to_string());
320    }
321    let bytes = s.as_bytes();
322    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
323    let mut i = 0;
324    while i < bytes.len() {
325        if bytes[i] == b'%' {
326            let hex = bytes
327                .get(i + 1..i + 3)
328                .ok_or_else(|| format!("truncated percent-escape in {s:?}"))?;
329            let text =
330                std::str::from_utf8(hex).map_err(|_| format!("invalid percent-escape in {s:?}"))?;
331            let byte = u8::from_str_radix(text, 16)
332                .map_err(|_| format!("invalid percent-escape `%{text}` in {s:?}"))?;
333            out.push(byte);
334            i += 3;
335        } else {
336            out.push(bytes[i]);
337            i += 1;
338        }
339    }
340    String::from_utf8(out).map_err(|_| format!("percent-decoded {s:?} is not valid UTF-8"))
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn plain_absolute_file_uri() {
349        assert_eq!(
350            file_uri_to_path("file:///Users/dev/project").unwrap(),
351            PathBuf::from("/Users/dev/project")
352        );
353    }
354
355    #[test]
356    fn localhost_host_is_local() {
357        assert_eq!(
358            file_uri_to_path("file://localhost/srv/code").unwrap(),
359            PathBuf::from("/srv/code")
360        );
361        assert_eq!(
362            file_uri_to_path("FILE://LOCALHOST/srv/code").unwrap(),
363            PathBuf::from("/srv/code")
364        );
365    }
366
367    #[test]
368    fn authority_less_form_is_accepted() {
369        assert_eq!(
370            file_uri_to_path("file:/srv/code").unwrap(),
371            PathBuf::from("/srv/code")
372        );
373    }
374
375    #[test]
376    fn foreign_host_rejected() {
377        let err = file_uri_to_path("file://otherbox/srv/code").unwrap_err();
378        assert!(err.contains("otherbox"), "unexpected error: {err}");
379    }
380
381    #[test]
382    fn non_file_scheme_rejected() {
383        for uri in [
384            "https://example.com/repo",
385            "git+ssh://host/repo",
386            "/tmp/x",
387            // These two are shaped exactly like an acceptable `file://`
388            // URI — empty authority, absolute path — so only the scheme
389            // check can reject them.
390            "http:///srv/code",
391            "data:/srv/code",
392        ] {
393            assert!(
394                file_uri_to_path(uri).is_err(),
395                "{uri} should not convert to a path"
396            );
397        }
398    }
399
400    #[test]
401    fn an_encoded_separator_cannot_forge_a_local_authority() {
402        // `localhost%2Fevil` is one authority component naming a host we
403        // do not have, not `localhost` followed by a path. Decoding before
404        // the authority split would turn it into the latter.
405        let err = file_uri_to_path("file://localhost%2Fevil/path").unwrap_err();
406        assert!(err.contains("localhost%2Fevil"), "unexpected error: {err}");
407    }
408
409    #[test]
410    fn percent_escapes_decode_after_splitting() {
411        assert_eq!(
412            file_uri_to_path("file:///Users/dev/my%20project").unwrap(),
413            PathBuf::from("/Users/dev/my project")
414        );
415        // An encoded separator stays data: it is decoded only after the
416        // authority/path split, so it can never introduce a new component
417        // boundary during parsing.
418        assert_eq!(
419            file_uri_to_path("file://localhost/a%2Fb").unwrap(),
420            PathBuf::from("/a/b")
421        );
422        // Encoded `?` and `#` are path characters, not delimiters: they are
423        // decoded only after the query and fragment have been split off, so
424        // they cannot truncate the path.
425        assert_eq!(
426            file_uri_to_path("file:///a%3Fb/src").unwrap(),
427            PathBuf::from("/a?b/src")
428        );
429        assert_eq!(
430            file_uri_to_path("file:///a%23b/src").unwrap(),
431            PathBuf::from("/a#b/src")
432        );
433        assert_eq!(
434            file_uri_to_path("file:///caf%C3%A9/src").unwrap(),
435            PathBuf::from("/café/src")
436        );
437    }
438
439    #[test]
440    fn malformed_percent_escape_rejected() {
441        assert!(file_uri_to_path("file:///a%2").is_err());
442        assert!(file_uri_to_path("file:///a%zz/b").is_err());
443    }
444
445    #[test]
446    fn query_and_fragment_stripped() {
447        assert_eq!(
448            file_uri_to_path("file:///srv/code?ref=main#L10").unwrap(),
449            PathBuf::from("/srv/code")
450        );
451    }
452
453    #[test]
454    fn empty_and_relative_paths_rejected() {
455        assert!(file_uri_to_path("file://").is_err());
456        assert!(file_uri_to_path("file://localhost").is_err());
457        assert!(file_uri_to_path("file:relative/path").is_err());
458    }
459}