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}