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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.path_a)?;
218 let path_b = resolve_path(¶ms.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(¶ms.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 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(¶ms).expect("serialize");
449 assert!(json.contains("\"path\""));
450 }
451}