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}