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::{model::ProtocolVersion, 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 !negotiated_protocol_supports_roots(peer) {
88        return;
89    }
90    if !advertises_roots(peer) {
91        return;
92    }
93    adopt_first_valid_root(ws, peer, "initialized").await;
94}
95
96/// `notifications/roots/list_changed` arrived — re-run adoption, unless
97/// the operator has since claimed the root.
98pub(crate) async fn on_client_roots_changed(options: &ServerOptions, peer: &Peer<RoleServer>) {
99    let Some(ws) = adoption_candidate(options) else {
100        return;
101    };
102    // `Operator` never yields; `Unowned` (adoption failed or was never
103    // attempted) and `Adopted` both re-adopt.
104    if ws.root_ownership() == RootOwnership::Operator {
105        return;
106    }
107    if !negotiated_protocol_supports_roots(peer) {
108        return;
109    }
110    // A client that advertised `roots: {}` without `listChanged` should
111    // never have sent this. Ignore it rather than trust it.
112    if !advertises_roots_list_changed(peer) {
113        tracing::warn!(
114            "ignoring roots/list_changed from a client that did not advertise \
115             roots.listChanged"
116        );
117        return;
118    }
119    adopt_first_valid_root(ws, peer, "roots/list_changed").await;
120}
121
122/// The two cheapest guards: is there a workspace, and did the operator opt
123/// in? Both are pure field reads — a server that never sets
124/// `workspace.adopt_client_roots` exits here, before touching the peer.
125fn adoption_candidate(options: &ServerOptions) -> Option<&Workspace> {
126    let ws = options.workspace.as_ref()?;
127    ws.adopts_client_roots().then_some(ws)
128}
129
130/// `roots` was removed from the 2026-07-28 protocol revision. A client that
131/// accidentally carries its old capability forward must not make this server
132/// emit a method the negotiated revision no longer defines.
133fn negotiated_protocol_supports_roots(peer: &Peer<RoleServer>) -> bool {
134    peer.peer_info()
135        .is_some_and(|info| info.protocol_version != ProtocolVersion::V_2026_07_28)
136}
137
138/// Does the client advertise the `roots` capability at all? When it does
139/// not, no `roots/list` request is ever sent — that is requirement 3
140/// ("clients that do not advertise roots are wholly unaffected") enforced
141/// at its only possible site.
142fn advertises_roots(peer: &Peer<RoleServer>) -> bool {
143    // `peer_info()` is populated before `serve()` returns, strictly before
144    // any notification handler can run, so this is never spuriously `None`.
145    peer.peer_info()
146        .is_some_and(|info| info.capabilities.roots.is_some())
147}
148
149fn advertises_roots_list_changed(peer: &Peer<RoleServer>) -> bool {
150    peer.peer_info().is_some_and(|info| {
151        info.capabilities
152            .roots
153            .as_ref()
154            .is_some_and(|roots| roots.list_changed == Some(true))
155    })
156}
157
158/// Issue `roots/list`, then bind the first root that survives conversion
159/// and containment.
160///
161/// Every failure here is contained: a warning is logged and the workspace
162/// keeps whatever it had (typically nothing, i.e. it stays unanchored, and
163/// the source tools behave exactly as they do with no root configured).
164/// Nothing retries — a retry loop against a misbehaving client is worse
165/// than an unanchored server.
166async fn adopt_first_valid_root(ws: &Workspace, peer: &Peer<RoleServer>, trigger: &str) {
167    let roots = match tokio::time::timeout(LIST_ROOTS_TIMEOUT, peer.list_roots()).await {
168        Ok(Ok(result)) => result.roots,
169        Ok(Err(err)) => {
170            tracing::warn!(
171                "roots/list ({trigger}) failed: {err}; \
172                 continuing without a client-advertised root"
173            );
174            return;
175        }
176        Err(_) => {
177            tracing::warn!(
178                "roots/list ({trigger}) timed out after {}s; \
179                 continuing without a client-advertised root",
180                LIST_ROOTS_TIMEOUT.as_secs()
181            );
182            return;
183        }
184    };
185    if roots.is_empty() {
186        tracing::warn!("client advertised roots but returned none ({trigger})");
187        return;
188    }
189    // "First valid" is client-order-dependent: no revision of the MCP
190    // schema says anything about the order of `ListRootsResult.roots`, so
191    // a client with several roots picks which one we bind. Operators who
192    // need determinism set `workspace.root`.
193    for root in &roots {
194        // `Root.name` is display-only and must never influence path
195        // resolution.
196        let path = match file_uri_to_path(&root.uri) {
197            Ok(path) => path,
198            Err(reason) => {
199                tracing::warn!("skipping client root {:?}: {reason}", root.uri);
200                continue;
201            }
202        };
203        // Canonicalize before the containment test — it is what makes
204        // `..` and symlinks (and, on macOS, `/var` → `/private/var`)
205        // resolve to the path that will actually be read.
206        let canon = match path.canonicalize() {
207            Ok(canon) => canon,
208            Err(err) => {
209                tracing::warn!(
210                    "skipping client root {:?}: cannot resolve {}: {err}",
211                    root.uri,
212                    path.display()
213                );
214                continue;
215            }
216        };
217        if !canon.is_dir() {
218            tracing::warn!(
219                "skipping client root {:?}: {} is not a directory",
220                root.uri,
221                canon.display()
222            );
223            continue;
224        }
225        match ws.adopt_client_root(&canon) {
226            Ok(msg) => {
227                tracing::info!(
228                    "adopted client root {} ({trigger}): {}",
229                    canon.display(),
230                    msg.lines().next().unwrap_or_default()
231                );
232                return;
233            }
234            Err(reason) => {
235                tracing::warn!("rejected client root {}: {reason}", canon.display());
236                continue;
237            }
238        }
239    }
240    tracing::warn!(
241        "no client-advertised root could be adopted ({trigger}); \
242         the workspace is unchanged"
243    );
244}
245
246/// Convert a `file://` URI to a filesystem path.
247///
248/// `Root.uri` **MUST** be a `file://` URI per the spec, and the spec says
249/// nothing about what a server does with one it cannot interpret — so this
250/// returns a reason string and the caller skips that root with a warning
251/// rather than failing the connection or answering the client with a
252/// protocol error.
253///
254/// Accepted: `file:///abs/path` (empty authority), `file://localhost/abs/path`
255/// (RFC 8089's named-local form), and the authority-less `file:/abs/path`.
256/// Rejected: any other scheme, a non-local host (`file://other/path` names a
257/// file on *another* machine), a relative path, and malformed percent
258/// escapes. Query and fragment are stripped before decoding; percent
259/// decoding happens **after** the URI is split, so an encoded `/`, `?` or
260/// `#` cannot change how the URI parses.
261///
262/// Hand-rolled rather than pulling in `url`: the rules above are the whole
263/// of RFC 8089 that a `file://` root can exercise, and the crate is a
264/// published library whose dependency surface is worth keeping small.
265pub fn file_uri_to_path(uri: &str) -> Result<PathBuf, String> {
266    let Some(rest) = strip_scheme(uri, "file") else {
267        return Err(format!(
268            "only file:// roots are supported (got {:?})",
269            uri.split(':').next().unwrap_or(uri)
270        ));
271    };
272    // Strip fragment then query — both are meaningless for a directory
273    // root, and leaving them in would make them part of the path.
274    let rest = rest.split('#').next().unwrap_or(rest);
275    let rest = rest.split('?').next().unwrap_or(rest);
276
277    let raw_path = match rest.strip_prefix("//") {
278        Some(after_slashes) => {
279            // `//authority/path` — the authority ends at the first `/`.
280            let (authority, path) = match after_slashes.find('/') {
281                Some(idx) => after_slashes.split_at(idx),
282                None => (after_slashes, ""),
283            };
284            if !(authority.is_empty() || authority.eq_ignore_ascii_case("localhost")) {
285                return Err(format!(
286                    "host {authority:?} is not this machine; \
287                     only file:// URIs with an empty host or `localhost` name a local path"
288                ));
289            }
290            path
291        }
292        // `file:/abs/path` — no authority component at all.
293        None => rest,
294    };
295    if raw_path.is_empty() {
296        return Err("no path component".to_string());
297    }
298    let decoded = percent_decode(raw_path)?;
299    #[cfg(windows)]
300    // `file:///C:/x` decodes to `/C:/x`; the drive letter form needs the
301    // leading separator removed to be a real Windows path.
302    let decoded = {
303        let bytes = decoded.as_bytes();
304        if bytes.len() >= 3
305            && bytes[0] == b'/'
306            && bytes[1].is_ascii_alphabetic()
307            && bytes[2] == b':'
308        {
309            decoded[1..].to_string()
310        } else {
311            decoded
312        }
313    };
314    let path = PathBuf::from(decoded);
315    if !path.is_absolute() {
316        return Err(format!("path {} is not absolute", path.display()));
317    }
318    Ok(path)
319}
320
321/// Case-insensitive scheme strip. Schemes are ASCII and case-insensitive
322/// per RFC 3986, so `FILE://` is the same root as `file://`.
323fn strip_scheme<'a>(uri: &'a str, scheme: &str) -> Option<&'a str> {
324    let (head, tail) = uri.split_once(':')?;
325    head.eq_ignore_ascii_case(scheme).then_some(tail)
326}
327
328/// Decode `%XX` escapes. Rejects malformed escapes and any byte sequence
329/// that is not UTF-8 — `file://` URIs percent-encode UTF-8, and a path we
330/// cannot render as a string is a path we should not silently guess at.
331fn percent_decode(s: &str) -> Result<String, String> {
332    if !s.contains('%') {
333        return Ok(s.to_string());
334    }
335    let bytes = s.as_bytes();
336    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
337    let mut i = 0;
338    while i < bytes.len() {
339        if bytes[i] == b'%' {
340            let hex = bytes
341                .get(i + 1..i + 3)
342                .ok_or_else(|| format!("truncated percent-escape in {s:?}"))?;
343            let text =
344                std::str::from_utf8(hex).map_err(|_| format!("invalid percent-escape in {s:?}"))?;
345            let byte = u8::from_str_radix(text, 16)
346                .map_err(|_| format!("invalid percent-escape `%{text}` in {s:?}"))?;
347            out.push(byte);
348            i += 3;
349        } else {
350            out.push(bytes[i]);
351            i += 1;
352        }
353    }
354    String::from_utf8(out).map_err(|_| format!("percent-decoded {s:?} is not valid UTF-8"))
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn plain_absolute_file_uri() {
363        assert_eq!(
364            file_uri_to_path("file:///Users/dev/project").unwrap(),
365            PathBuf::from("/Users/dev/project")
366        );
367    }
368
369    #[test]
370    fn localhost_host_is_local() {
371        assert_eq!(
372            file_uri_to_path("file://localhost/srv/code").unwrap(),
373            PathBuf::from("/srv/code")
374        );
375        assert_eq!(
376            file_uri_to_path("FILE://LOCALHOST/srv/code").unwrap(),
377            PathBuf::from("/srv/code")
378        );
379    }
380
381    #[test]
382    fn authority_less_form_is_accepted() {
383        assert_eq!(
384            file_uri_to_path("file:/srv/code").unwrap(),
385            PathBuf::from("/srv/code")
386        );
387    }
388
389    #[test]
390    fn foreign_host_rejected() {
391        let err = file_uri_to_path("file://otherbox/srv/code").unwrap_err();
392        assert!(err.contains("otherbox"), "unexpected error: {err}");
393    }
394
395    #[test]
396    fn non_file_scheme_rejected() {
397        for uri in [
398            "https://example.com/repo",
399            "git+ssh://host/repo",
400            "/tmp/x",
401            // These two are shaped exactly like an acceptable `file://`
402            // URI — empty authority, absolute path — so only the scheme
403            // check can reject them.
404            "http:///srv/code",
405            "data:/srv/code",
406        ] {
407            assert!(
408                file_uri_to_path(uri).is_err(),
409                "{uri} should not convert to a path"
410            );
411        }
412    }
413
414    #[test]
415    fn an_encoded_separator_cannot_forge_a_local_authority() {
416        // `localhost%2Fevil` is one authority component naming a host we
417        // do not have, not `localhost` followed by a path. Decoding before
418        // the authority split would turn it into the latter.
419        let err = file_uri_to_path("file://localhost%2Fevil/path").unwrap_err();
420        assert!(err.contains("localhost%2Fevil"), "unexpected error: {err}");
421    }
422
423    #[test]
424    fn percent_escapes_decode_after_splitting() {
425        assert_eq!(
426            file_uri_to_path("file:///Users/dev/my%20project").unwrap(),
427            PathBuf::from("/Users/dev/my project")
428        );
429        // An encoded separator stays data: it is decoded only after the
430        // authority/path split, so it can never introduce a new component
431        // boundary during parsing.
432        assert_eq!(
433            file_uri_to_path("file://localhost/a%2Fb").unwrap(),
434            PathBuf::from("/a/b")
435        );
436        // Encoded `?` and `#` are path characters, not delimiters: they are
437        // decoded only after the query and fragment have been split off, so
438        // they cannot truncate the path.
439        assert_eq!(
440            file_uri_to_path("file:///a%3Fb/src").unwrap(),
441            PathBuf::from("/a?b/src")
442        );
443        assert_eq!(
444            file_uri_to_path("file:///a%23b/src").unwrap(),
445            PathBuf::from("/a#b/src")
446        );
447        assert_eq!(
448            file_uri_to_path("file:///caf%C3%A9/src").unwrap(),
449            PathBuf::from("/café/src")
450        );
451    }
452
453    #[test]
454    fn malformed_percent_escape_rejected() {
455        assert!(file_uri_to_path("file:///a%2").is_err());
456        assert!(file_uri_to_path("file:///a%zz/b").is_err());
457    }
458
459    #[test]
460    fn query_and_fragment_stripped() {
461        assert_eq!(
462            file_uri_to_path("file:///srv/code?ref=main#L10").unwrap(),
463            PathBuf::from("/srv/code")
464        );
465    }
466
467    #[test]
468    fn empty_and_relative_paths_rejected() {
469        assert!(file_uri_to_path("file://").is_err());
470        assert!(file_uri_to_path("file://localhost").is_err());
471        assert!(file_uri_to_path("file:relative/path").is_err());
472    }
473}