#![expect(deprecated)]
use std::path::PathBuf;
use std::time::Duration;
use rmcp::service::Peer;
use rmcp::{model::ProtocolVersion, RoleServer};
use crate::server::server::ServerOptions;
use crate::server::workspace::{RootOwnership, Workspace};
const LIST_ROOTS_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) async fn on_client_initialized(options: &ServerOptions, peer: &Peer<RoleServer>) {
let Some(ws) = adoption_candidate(options) else {
return;
};
if ws.root_ownership() != RootOwnership::Unowned {
return;
}
if !negotiated_protocol_supports_roots(peer) {
return;
}
if !advertises_roots(peer) {
return;
}
adopt_first_valid_root(ws, peer, "initialized").await;
}
pub(crate) async fn on_client_roots_changed(options: &ServerOptions, peer: &Peer<RoleServer>) {
let Some(ws) = adoption_candidate(options) else {
return;
};
if ws.root_ownership() == RootOwnership::Operator {
return;
}
if !negotiated_protocol_supports_roots(peer) {
return;
}
if !advertises_roots_list_changed(peer) {
tracing::warn!(
"ignoring roots/list_changed from a client that did not advertise \
roots.listChanged"
);
return;
}
adopt_first_valid_root(ws, peer, "roots/list_changed").await;
}
fn adoption_candidate(options: &ServerOptions) -> Option<&Workspace> {
let ws = options.workspace.as_ref()?;
ws.adopts_client_roots().then_some(ws)
}
fn negotiated_protocol_supports_roots(peer: &Peer<RoleServer>) -> bool {
peer.peer_info()
.is_some_and(|info| info.protocol_version != ProtocolVersion::V_2026_07_28)
}
fn advertises_roots(peer: &Peer<RoleServer>) -> bool {
peer.peer_info()
.is_some_and(|info| info.capabilities.roots.is_some())
}
fn advertises_roots_list_changed(peer: &Peer<RoleServer>) -> bool {
peer.peer_info().is_some_and(|info| {
info.capabilities
.roots
.as_ref()
.is_some_and(|roots| roots.list_changed == Some(true))
})
}
async fn adopt_first_valid_root(ws: &Workspace, peer: &Peer<RoleServer>, trigger: &str) {
let roots = match tokio::time::timeout(LIST_ROOTS_TIMEOUT, peer.list_roots()).await {
Ok(Ok(result)) => result.roots,
Ok(Err(err)) => {
tracing::warn!(
"roots/list ({trigger}) failed: {err}; \
continuing without a client-advertised root"
);
return;
}
Err(_) => {
tracing::warn!(
"roots/list ({trigger}) timed out after {}s; \
continuing without a client-advertised root",
LIST_ROOTS_TIMEOUT.as_secs()
);
return;
}
};
if roots.is_empty() {
tracing::warn!("client advertised roots but returned none ({trigger})");
return;
}
for root in &roots {
let path = match file_uri_to_path(&root.uri) {
Ok(path) => path,
Err(reason) => {
tracing::warn!("skipping client root {:?}: {reason}", root.uri);
continue;
}
};
let canon = match path.canonicalize() {
Ok(canon) => canon,
Err(err) => {
tracing::warn!(
"skipping client root {:?}: cannot resolve {}: {err}",
root.uri,
path.display()
);
continue;
}
};
if !canon.is_dir() {
tracing::warn!(
"skipping client root {:?}: {} is not a directory",
root.uri,
canon.display()
);
continue;
}
match ws.adopt_client_root(&canon) {
Ok(msg) => {
tracing::info!(
"adopted client root {} ({trigger}): {}",
canon.display(),
msg.lines().next().unwrap_or_default()
);
return;
}
Err(reason) => {
tracing::warn!("rejected client root {}: {reason}", canon.display());
continue;
}
}
}
tracing::warn!(
"no client-advertised root could be adopted ({trigger}); \
the workspace is unchanged"
);
}
pub fn file_uri_to_path(uri: &str) -> Result<PathBuf, String> {
let Some(rest) = strip_scheme(uri, "file") else {
return Err(format!(
"only file:// roots are supported (got {:?})",
uri.split(':').next().unwrap_or(uri)
));
};
let rest = rest.split('#').next().unwrap_or(rest);
let rest = rest.split('?').next().unwrap_or(rest);
let raw_path = match rest.strip_prefix("//") {
Some(after_slashes) => {
let (authority, path) = match after_slashes.find('/') {
Some(idx) => after_slashes.split_at(idx),
None => (after_slashes, ""),
};
if !(authority.is_empty() || authority.eq_ignore_ascii_case("localhost")) {
return Err(format!(
"host {authority:?} is not this machine; \
only file:// URIs with an empty host or `localhost` name a local path"
));
}
path
}
None => rest,
};
if raw_path.is_empty() {
return Err("no path component".to_string());
}
let decoded = percent_decode(raw_path)?;
#[cfg(windows)]
let decoded = {
let bytes = decoded.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'/'
&& bytes[1].is_ascii_alphabetic()
&& bytes[2] == b':'
{
decoded[1..].to_string()
} else {
decoded
}
};
let path = PathBuf::from(decoded);
if !path.is_absolute() {
return Err(format!("path {} is not absolute", path.display()));
}
Ok(path)
}
fn strip_scheme<'a>(uri: &'a str, scheme: &str) -> Option<&'a str> {
let (head, tail) = uri.split_once(':')?;
head.eq_ignore_ascii_case(scheme).then_some(tail)
}
fn percent_decode(s: &str) -> Result<String, String> {
if !s.contains('%') {
return Ok(s.to_string());
}
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = bytes
.get(i + 1..i + 3)
.ok_or_else(|| format!("truncated percent-escape in {s:?}"))?;
let text =
std::str::from_utf8(hex).map_err(|_| format!("invalid percent-escape in {s:?}"))?;
let byte = u8::from_str_radix(text, 16)
.map_err(|_| format!("invalid percent-escape `%{text}` in {s:?}"))?;
out.push(byte);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| format!("percent-decoded {s:?} is not valid UTF-8"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_absolute_file_uri() {
assert_eq!(
file_uri_to_path("file:///Users/dev/project").unwrap(),
PathBuf::from("/Users/dev/project")
);
}
#[test]
fn localhost_host_is_local() {
assert_eq!(
file_uri_to_path("file://localhost/srv/code").unwrap(),
PathBuf::from("/srv/code")
);
assert_eq!(
file_uri_to_path("FILE://LOCALHOST/srv/code").unwrap(),
PathBuf::from("/srv/code")
);
}
#[test]
fn authority_less_form_is_accepted() {
assert_eq!(
file_uri_to_path("file:/srv/code").unwrap(),
PathBuf::from("/srv/code")
);
}
#[test]
fn foreign_host_rejected() {
let err = file_uri_to_path("file://otherbox/srv/code").unwrap_err();
assert!(err.contains("otherbox"), "unexpected error: {err}");
}
#[test]
fn non_file_scheme_rejected() {
for uri in [
"https://example.com/repo",
"git+ssh://host/repo",
"/tmp/x",
"http:///srv/code",
"data:/srv/code",
] {
assert!(
file_uri_to_path(uri).is_err(),
"{uri} should not convert to a path"
);
}
}
#[test]
fn an_encoded_separator_cannot_forge_a_local_authority() {
let err = file_uri_to_path("file://localhost%2Fevil/path").unwrap_err();
assert!(err.contains("localhost%2Fevil"), "unexpected error: {err}");
}
#[test]
fn percent_escapes_decode_after_splitting() {
assert_eq!(
file_uri_to_path("file:///Users/dev/my%20project").unwrap(),
PathBuf::from("/Users/dev/my project")
);
assert_eq!(
file_uri_to_path("file://localhost/a%2Fb").unwrap(),
PathBuf::from("/a/b")
);
assert_eq!(
file_uri_to_path("file:///a%3Fb/src").unwrap(),
PathBuf::from("/a?b/src")
);
assert_eq!(
file_uri_to_path("file:///a%23b/src").unwrap(),
PathBuf::from("/a#b/src")
);
assert_eq!(
file_uri_to_path("file:///caf%C3%A9/src").unwrap(),
PathBuf::from("/café/src")
);
}
#[test]
fn malformed_percent_escape_rejected() {
assert!(file_uri_to_path("file:///a%2").is_err());
assert!(file_uri_to_path("file:///a%zz/b").is_err());
}
#[test]
fn query_and_fragment_stripped() {
assert_eq!(
file_uri_to_path("file:///srv/code?ref=main#L10").unwrap(),
PathBuf::from("/srv/code")
);
}
#[test]
fn empty_and_relative_paths_rejected() {
assert!(file_uri_to_path("file://").is_err());
assert!(file_uri_to_path("file://localhost").is_err());
assert!(file_uri_to_path("file:relative/path").is_err());
}
}