Skip to main content

mcpls_core/bridge/
client_path.rs

1//! A file path received from an MCP client, parsed at the boundary.
2//!
3//! [`ClientPath`] makes the shapes that can never name a file (empty, NUL
4//! byte) unrepresentable, so they are rejected as invalid parameters at the
5//! edge instead of surfacing from `std::path::absolute` or `canonicalize`
6//! deep inside path validation.
7
8use std::path::{Path, PathBuf};
9
10/// Why a path received from a client is not a usable file path.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
12pub enum InvalidClientPath {
13    /// The path is empty.
14    #[error("file path is empty")]
15    Empty,
16    /// The path contains a NUL byte, which no filesystem accepts.
17    #[error("file path contains a NUL byte")]
18    ContainsNul,
19}
20
21/// A non-empty, NUL-free path supplied by an MCP client.
22///
23/// The path may be relative or contain `.`/`..`; whether it lies inside the
24/// workspace is decided later by `WorkspaceRoots::validate`. The MCP tool
25/// methods parse their `file_path` into a `ClientPath` first thing (not while
26/// deserializing the parameters, which rmcp reports as a tool-result error
27/// rather than a JSON-RPC error), so a malformed path is `-32602`.
28///
29/// # Examples
30///
31/// ```
32/// use std::path::PathBuf;
33///
34/// use mcpls_core::bridge::{ClientPath, InvalidClientPath};
35///
36/// let path = ClientPath::try_from(PathBuf::from("/ws/src/main.rs"))?;
37/// assert_eq!(path.as_path(), std::path::Path::new("/ws/src/main.rs"));
38/// assert_eq!(
39///     ClientPath::try_from(PathBuf::new()),
40///     Err(InvalidClientPath::Empty)
41/// );
42/// # Ok::<(), InvalidClientPath>(())
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct ClientPath(PathBuf);
46
47impl ClientPath {
48    /// The validated path.
49    #[must_use]
50    pub fn as_path(&self) -> &Path {
51        &self.0
52    }
53}
54
55impl TryFrom<PathBuf> for ClientPath {
56    type Error = InvalidClientPath;
57
58    fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
59        if path.is_empty() {
60            return Err(InvalidClientPath::Empty);
61        }
62        if path.as_os_str().as_encoded_bytes().contains(&0) {
63            return Err(InvalidClientPath::ContainsNul);
64        }
65        Ok(Self(path))
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn test_try_from_rejects_empty_and_nul() {
75        assert_eq!(
76            ClientPath::try_from(PathBuf::new()),
77            Err(InvalidClientPath::Empty)
78        );
79        assert_eq!(
80            ClientPath::try_from(PathBuf::from("/ws/a\0b.rs")),
81            Err(InvalidClientPath::ContainsNul)
82        );
83    }
84
85    #[test]
86    fn test_try_from_accepts_absolute_relative_and_dotted() {
87        for raw in ["/ws/a.rs", "a.rs", "../a.rs", "/ws/./a.rs"] {
88            let path = ClientPath::try_from(PathBuf::from(raw)).unwrap();
89            assert_eq!(path.as_path(), Path::new(raw));
90        }
91    }
92
93    #[test]
94    fn test_error_messages_name_the_cause() {
95        assert!(InvalidClientPath::Empty.to_string().contains("empty"));
96        assert!(InvalidClientPath::ContainsNul.to_string().contains("NUL"));
97    }
98}