Skip to main content

claude_fs_mcp/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3use std::fs::File;
4use std::io::{self, Read};
5use std::path::{Path, PathBuf};
6
7use flate2::Compression;
8use flate2::write::GzEncoder;
9use nexcore_fs::walk::WalkDir;
10use rmcp::handler::server::router::tool::ToolRouter;
11use rmcp::handler::server::tool::ToolCallContext;
12use rmcp::handler::server::wrapper::Parameters;
13use rmcp::model::{
14    CallToolRequestParams, CallToolResult, ErrorCode, Implementation, ListToolsResult,
15    PaginatedRequestParams, ServerCapabilities, ServerInfo,
16};
17use rmcp::service::{RequestContext, RoleServer};
18use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_router};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use serde_json::json;
22use tokio::fs;
23use tokio::io::AsyncWriteExt;
24
25const CLAUDE_ROOT: &str = "/home/matthew/.claude";
26const BACKUP_DIR: &str = "/home/matthew/.claude/backup/sessions";
27
28#[derive(Debug, nexcore_error::Error)]
29pub enum FsError {
30    #[error("path escapes claude root: {0}")]
31    PathEscape(String),
32    #[error("not found: {0}")]
33    NotFound(String),
34    #[error("io error: {0}")]
35    Io(String),
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
39pub struct PathParam {
40    pub path: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
44pub struct WriteParam {
45    pub path: String,
46    pub content: String,
47    pub create_dirs: Option<bool>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
51pub struct SearchParam {
52    pub query: String,
53    pub root: Option<String>,
54    pub max_results: Option<usize>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
58pub struct ListParam {
59    pub path: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct TailParam {
64    pub path: String,
65    pub lines: Option<usize>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct DiffParam {
70    pub path_a: String,
71    pub path_b: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
75pub struct StatParam {
76    pub path: String,
77}
78
79#[derive(Clone)]
80pub struct ClaudeFsMcpServer {
81    tool_router: ToolRouter<Self>,
82}
83
84#[tool_router]
85impl ClaudeFsMcpServer {
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            tool_router: Self::tool_router(),
90        }
91    }
92
93    #[tool(description = "List files under a .claude path (non-recursive).")]
94    async fn claude_fs_list(
95        &self,
96        Parameters(params): Parameters<ListParam>,
97    ) -> Result<CallToolResult, McpError> {
98        let path = resolve_path(&params.path)?;
99        let mut entries = Vec::new();
100        let mut read_dir = fs::read_dir(&path).await.map_err(mcp_io)?;
101        while let Some(entry) = read_dir.next_entry().await.map_err(mcp_io)? {
102            let file_type = entry.file_type().await.map_err(mcp_io)?;
103            entries.push(format!(
104                "{}{}",
105                entry.path().display(),
106                if file_type.is_dir() { "/" } else { "" }
107            ));
108        }
109        entries.sort();
110        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
111            entries.join("\n"),
112        )]))
113    }
114
115    #[tool(description = "Read a file under .claude.")]
116    async fn claude_fs_read(
117        &self,
118        Parameters(params): Parameters<PathParam>,
119    ) -> Result<CallToolResult, McpError> {
120        let path = resolve_path(&params.path)?;
121        let content = fs::read_to_string(&path).await.map_err(mcp_io)?;
122        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
123            content,
124        )]))
125    }
126
127    #[tool(description = "Write a file under .claude.")]
128    async fn claude_fs_write(
129        &self,
130        Parameters(params): Parameters<WriteParam>,
131    ) -> Result<CallToolResult, McpError> {
132        let path = resolve_path(&params.path)?;
133        if params.create_dirs.unwrap_or(true) {
134            if let Some(parent) = path.parent() {
135                fs::create_dir_all(parent).await.map_err(mcp_io)?;
136            }
137        }
138        let mut file = fs::File::create(&path).await.map_err(mcp_io)?;
139        file.write_all(params.content.as_bytes())
140            .await
141            .map_err(mcp_io)?;
142        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
143            "ok",
144        )]))
145    }
146
147    #[tool(description = "Delete a file under .claude.")]
148    async fn claude_fs_delete(
149        &self,
150        Parameters(params): Parameters<PathParam>,
151    ) -> Result<CallToolResult, McpError> {
152        let path = resolve_path(&params.path)?;
153        if !path.exists() {
154            return Err(McpError::new(ErrorCode(404), "not found", None));
155        }
156        let meta = fs::metadata(&path).await.map_err(mcp_io)?;
157        if meta.is_dir() {
158            fs::remove_dir_all(&path).await.map_err(mcp_io)?;
159        } else {
160            fs::remove_file(&path).await.map_err(mcp_io)?;
161        }
162        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
163            "ok",
164        )]))
165    }
166
167    #[tool(description = "Search for a substring under .claude.")]
168    async fn claude_fs_search(
169        &self,
170        Parameters(params): Parameters<SearchParam>,
171    ) -> Result<CallToolResult, McpError> {
172        let root = params.root.as_deref().unwrap_or(".");
173        let root = resolve_path(root)?;
174        let max_results = params.max_results.unwrap_or(200);
175        let mut matches = Vec::new();
176        for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
177            if !entry.file_type().is_file() {
178                continue;
179            }
180            let path = entry.path();
181            if let Ok(mut file) = File::open(path) {
182                let mut buf = String::new();
183                if file.read_to_string(&mut buf).is_ok() && buf.contains(&params.query) {
184                    matches.push(path.display().to_string());
185                    if matches.len() >= max_results {
186                        break;
187                    }
188                }
189            }
190        }
191        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
192            matches.join("\n"),
193        )]))
194    }
195
196    #[tool(description = "Tail last N lines of a file under .claude.")]
197    async fn claude_fs_tail(
198        &self,
199        Parameters(params): Parameters<TailParam>,
200    ) -> Result<CallToolResult, McpError> {
201        let path = resolve_path(&params.path)?;
202        let content = fs::read_to_string(&path).await.map_err(mcp_io)?;
203        let count = params.lines.unwrap_or(100);
204        let lines: Vec<&str> = content.lines().collect();
205        let start = lines.len().saturating_sub(count);
206        let slice = lines[start..].join("\n");
207        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
208            slice,
209        )]))
210    }
211
212    #[tool(description = "Diff two files under .claude (simple line diff).")]
213    async fn claude_fs_diff(
214        &self,
215        Parameters(params): Parameters<DiffParam>,
216    ) -> Result<CallToolResult, McpError> {
217        let path_a = resolve_path(&params.path_a)?;
218        let path_b = resolve_path(&params.path_b)?;
219        let a = fs::read_to_string(&path_a).await.map_err(mcp_io)?;
220        let b = fs::read_to_string(&path_b).await.map_err(mcp_io)?;
221        if a == b {
222            return Ok(CallToolResult::success(vec![rmcp::model::Content::text(
223                "no diff",
224            )]));
225        }
226        let a_lines: Vec<&str> = a.lines().collect();
227        let b_lines: Vec<&str> = b.lines().collect();
228        let max = a_lines.len().max(b_lines.len());
229        let mut out = Vec::new();
230        for idx in 0..max {
231            let left = a_lines.get(idx).copied().unwrap_or("");
232            let right = b_lines.get(idx).copied().unwrap_or("");
233            if left != right {
234                out.push(format!("- {:4}: {}", idx + 1, left));
235                out.push(format!("+ {:4}: {}", idx + 1, right));
236            }
237        }
238        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
239            out.join("\n"),
240        )]))
241    }
242
243    #[tool(description = "Stat a file under .claude.")]
244    async fn claude_fs_stat(
245        &self,
246        Parameters(params): Parameters<StatParam>,
247    ) -> Result<CallToolResult, McpError> {
248        let path = resolve_path(&params.path)?;
249        let meta = fs::metadata(&path).await.map_err(mcp_io)?;
250        let modified = meta.modified().ok();
251        let payload = json!({
252            "path": path.display().to_string(),
253            "is_dir": meta.is_dir(),
254            "size": meta.len(),
255            "modified": modified.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()),
256        });
257        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
258            payload.to_string(),
259        )]))
260    }
261
262    #[tool(description = "Create a backup tar.gz of the full .claude directory.")]
263    async fn claude_backup_now(&self) -> Result<CallToolResult, McpError> {
264        match create_backup() {
265            Ok(path) => Ok(CallToolResult::success(vec![rmcp::model::Content::text(
266                path,
267            )])),
268            Err(err) => Ok(CallToolResult::success(vec![rmcp::model::Content::text(
269                err.to_string(),
270            )])),
271        }
272    }
273}
274
275impl Default for ClaudeFsMcpServer {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281impl ServerHandler for ClaudeFsMcpServer {
282    fn get_info(&self) -> ServerInfo {
283        ServerInfo {
284            instructions: Some(
285                r#"Claude FS MCP Server
286
287Full-access tools for .claude filesystem operations. A backup is created on server start.
288"#
289                .into(),
290            ),
291            capabilities: ServerCapabilities::builder().enable_tools().build(),
292            server_info: Implementation {
293                name: "claude-fs-mcp".into(),
294                version: env!("CARGO_PKG_VERSION").into(),
295                title: Some("Claude FS MCP Server".into()),
296                icons: None,
297                website_url: None,
298            },
299            ..Default::default()
300        }
301    }
302
303    fn call_tool(
304        &self,
305        request: CallToolRequestParams,
306        context: RequestContext<RoleServer>,
307    ) -> impl std::future::Future<Output = Result<CallToolResult, McpError>> + Send + '_ {
308        async move {
309            let tcc = ToolCallContext::new(self, request, context);
310            let result = self.tool_router.call(tcc).await?;
311            Ok(result)
312        }
313    }
314
315    fn list_tools(
316        &self,
317        _request: Option<PaginatedRequestParams>,
318        _context: RequestContext<RoleServer>,
319    ) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
320        std::future::ready(Ok(ListToolsResult {
321            tools: self.tool_router.list_all(),
322            meta: None,
323            next_cursor: None,
324        }))
325    }
326}
327
328pub fn create_backup() -> Result<String, FsError> {
329    let root = Path::new(CLAUDE_ROOT);
330    if !root.exists() {
331        return Err(FsError::NotFound(CLAUDE_ROOT.to_string()));
332    }
333
334    let backup_dir = Path::new(BACKUP_DIR);
335    std::fs::create_dir_all(backup_dir).map_err(|err| FsError::Io(err.to_string()))?;
336
337    let stamp = nexcore_chrono::DateTime::now_local()
338        .format("%Y%m%d-%H%M%S")
339        .unwrap_or_default();
340    let backup_path = backup_dir.join(format!("claude-backup-{stamp}.tar.gz"));
341    let tar_gz = File::create(&backup_path).map_err(|err| FsError::Io(err.to_string()))?;
342    let enc = GzEncoder::new(tar_gz, Compression::default());
343    let mut tar = tar::Builder::new(enc);
344
345    // Best-effort archive: skip files that disappear during traversal.
346    for entry in WalkDir::new(root)
347        .follow_links(true)
348        .into_iter()
349        .filter_map(Result::ok)
350    {
351        let path = entry.path();
352        let rel = match path.strip_prefix(root) {
353            Ok(r) => r,
354            Err(_) => continue,
355        };
356        let name = Path::new(".claude").join(rel);
357        let meta = match std::fs::symlink_metadata(path) {
358            Ok(m) => m,
359            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
360            Err(e) => return Err(FsError::Io(e.to_string())),
361        };
362        if meta.is_dir() {
363            if let Err(e) = tar.append_dir(name, path) {
364                if e.kind() == io::ErrorKind::NotFound {
365                    continue;
366                }
367                return Err(FsError::Io(e.to_string()));
368            }
369        } else if meta.is_file() {
370            let mut file = match File::open(path) {
371                Ok(f) => f,
372                Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
373                Err(e) => return Err(FsError::Io(e.to_string())),
374            };
375            if let Err(e) = tar.append_file(name, &mut file) {
376                if e.kind() == io::ErrorKind::NotFound {
377                    continue;
378                }
379                return Err(FsError::Io(e.to_string()));
380            }
381        }
382    }
383    let enc = tar
384        .into_inner()
385        .map_err(|err| FsError::Io(err.to_string()))?;
386    enc.finish().map_err(|err| FsError::Io(err.to_string()))?;
387
388    rotate_backups(backup_dir).map_err(|err| FsError::Io(err.to_string()))?;
389    Ok(backup_path.display().to_string())
390}
391
392fn rotate_backups(backup_dir: &Path) -> io::Result<()> {
393    let keep: usize = std::env::var("CLAUDE_BACKUP_KEEP")
394        .ok()
395        .and_then(|v| v.parse().ok())
396        .unwrap_or(10);
397    let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(backup_dir)?
398        .filter_map(Result::ok)
399        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("gz"))
400        .collect();
401    entries.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
402    if entries.len() > keep {
403        let excess = entries.len() - keep;
404        for entry in entries.into_iter().take(excess) {
405            let _ = std::fs::remove_file(entry.path());
406        }
407    }
408    Ok(())
409}
410
411fn resolve_path(rel: &str) -> Result<PathBuf, McpError> {
412    let root = Path::new(CLAUDE_ROOT);
413    let rel = rel.trim_start_matches('/');
414    let rel_path = Path::new(rel);
415    for component in rel_path.components() {
416        if matches!(component, std::path::Component::ParentDir) {
417            return Err(McpError::new(
418                ErrorCode(403),
419                "path escapes claude root",
420                None,
421            ));
422        }
423    }
424    Ok(root.join(rel_path))
425}
426
427fn mcp_io(err: io::Error) -> McpError {
428    McpError::new(ErrorCode(500), err.to_string(), None)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn resolves_path_inside_root() {
437        let path = resolve_path("skills").expect("valid path");
438        assert!(path.to_string_lossy().contains(".claude"));
439    }
440
441    #[test]
442    fn serialize_params() {
443        let params = WriteParam {
444            path: "brain/test.txt".to_string(),
445            content: "hello".to_string(),
446            create_dirs: Some(true),
447        };
448        let json = serde_json::to_string(&params).expect("serialize");
449        assert!(json.contains("\"path\""));
450    }
451}