├── .windsurfrules ├── rustfmt.toml ├── .env.example ├── src ├── mcp │ ├── templates │ │ ├── resources.json │ │ ├── prompts.json │ │ └── tools.json │ ├── mod.rs │ ├── resources.rs │ ├── tools_test.rs │ ├── utilities.rs │ ├── prompts.rs │ ├── types.rs │ └── tools.rs └── main.rs ├── Cargo.toml ├── justfile ├── LICENSE ├── README.md └── .gitignore /.windsurfrules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Item" -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES=/path/number/one:/path/number/two 2 | -------------------------------------------------------------------------------- /src/mcp/templates/resources.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "file:///api/allowed_directories", 4 | "name": "Allowed Directories", 5 | "description": "List of directories that can be accessed", 6 | "mimeType": "application/json" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /src/mcp/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod prompts; 2 | pub mod resources; 3 | pub mod tools; 4 | pub mod types; 5 | pub mod utilities; 6 | 7 | const JSONRPC_VERSION: &str = "2.0"; 8 | const PROTOCOL_VERSION: &str = "2024-11-05"; 9 | const SERVER_NAME: &str = "rs_filesystem"; 10 | const SERVER_VERSION: &str = "0.1.0"; 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rs_filesystem" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["Chris Odom "] 6 | description = "MCP Filesystem Server" 7 | keywords = ["rust", "ai", "mcp", "cli", "filesystem"] 8 | categories = ["command-line-utilities"] 9 | readme = "README.md" 10 | license = "MIT" 11 | 12 | [dependencies] 13 | tokio = { version = "1.0", features = ["full"] } 14 | serde = "1" 15 | serde_json = { version = "1", features = ["preserve_order"] } 16 | url = { version = "2.5", features = ["serde"] } 17 | rpc-router = "0.1.3" 18 | maplit = "1" 19 | clap = { version = "4.5", features = ["derive"] } 20 | chrono = "0.4.38" 21 | signal-hook = "0.3" 22 | git2 = "0.18" 23 | dirs = "5.0" 24 | 25 | [dev-dependencies] 26 | tempfile = "3.8.1" 27 | tokio = { version = "1.32.0", features = ["full"] } 28 | 29 | [profile.dev] 30 | opt-level = 1 31 | 32 | [profile.dev.package."*"] 33 | opt-level = 3 34 | 35 | [profile.release] 36 | strip = true 37 | lto = true 38 | opt-level = "z" 39 | codegen-units = 1 40 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Remove or modify the shell line since Macs use bash/zsh by default 2 | # set shell := ["pwsh","-NoProfile","-NoLogo","-Command"] 3 | 4 | ping: 5 | echo '{ "jsonrpc": "2.0", "id": 1, "method": "ping" }' | ./target/debug/rs_filesystem --mcp 6 | 7 | prompts-list: 8 | echo '{ "jsonrpc": "2.0", "id": 1, "method": "prompts/list" }' | ./target/debug/rs_filesystem --mcp 9 | 10 | prompt-get: 11 | echo '{ "jsonrpc": "2.0", "id": 1, "method": "prompts/get", "params": {"name":"current_time","arguments": {"city": "hangzhou"} } }' | ./target/debug/rs_filesystem --mcp 12 | 13 | tools-list: 14 | echo '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' | ./target/debug/rs_filesystem --mcp 15 | 16 | resources-list: 17 | echo '{ "jsonrpc": "2.0", "id": 1, "method": "resources/list" }' | ./target/debug/rs_filesystem --mcp 18 | 19 | current-time: 20 | echo '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_current_time_in_city", "arguments": {"city":"Hangzhou" } } }' | ./target/debug/rs_filesystem --mcp 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Chris Odom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | ------------------- 24 | This project is based on mcp-server-hello 25 | Original work Copyright 2024 TeamDman 26 | Licensed under Apache License, Version 2.0 27 | http://www.apache.org/licenses/LICENSE-2.0 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | rs_filesystem: MCP Rust Filesystem tools 2 | ============================= 3 | 4 | rs_filesystem is a simple set of filesystem tools that can be used in Claude desktop or any other MCP client. 5 | 6 | 7 | # CLI options 8 | 9 | * `--mcp`: Enable MCP server 10 | * `--resources`: display resources 11 | * `--prompts`: display prompts 12 | * `--tools`: display tools 13 | 14 | # How to use MCP CLI server in Claude Desktop? 15 | 16 | 1. Edit `claude_desktop_config.json`: Claude Desktop -> `Settings` -> `Developer` -> `Edit Config` 17 | 2. Add the following configuration to the `servers` section: 18 | 19 | ```json 20 | { 21 | "mcpServers": { 22 | "rs_filesystem": { 23 | "command": "/path/to/rs_filesystem", 24 | "args": [ 25 | "--mcp" 26 | ], 27 | "env": { 28 | "MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES": "/path/number/one:/path/number/two" 29 | } 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | Make sure you use the actual path to the rs_filesystem binary. 36 | Make sure the `MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES` env variable is set to a colon-separated list of allowed directories. 37 | The tools will only work inside those directories. 38 | 39 | If you want to check MCP log, please use `tail -n 20 -f ~/Library/Logs/Claude/rs_filesystem.logs.jsonl`. 40 | 41 | 42 | # References 43 | 44 | * MCP Specification: https://spec.modelcontextprotocol.io/ 45 | * Model Context Protocol (MCP): https://modelcontextprotocol.io/introduction 46 | * rpc-router: json-rpc routing library - https://github.com/jeremychone/rust-rpc-router/ 47 | * Zed context_server: https://github.com/zed-industries/zed/tree/main/crates/context_server 48 | -------------------------------------------------------------------------------- /src/mcp/resources.rs: -------------------------------------------------------------------------------- 1 | use crate::mcp::types::*; 2 | use rpc_router::HandlerResult; 3 | use rpc_router::RpcParams; 4 | use rpc_router::IntoHandlerError; 5 | use url::Url; 6 | use serde_json::json; 7 | use serde::{Deserialize, Serialize}; 8 | use crate::mcp::utilities::get_allowed_directories; 9 | 10 | 11 | pub async fn resources_list( 12 | _request: Option, 13 | ) -> HandlerResult { 14 | let mut resources = Vec::new(); 15 | 16 | // Always include the allowed_directories resource 17 | resources.push(Resource { 18 | uri: Url::parse("file:///api/allowed_directories").unwrap(), 19 | name: "Allowed Directories".to_string(), 20 | description: Some("List of directories that can be accessed".to_string()), 21 | mime_type: Some("application/json".to_string()), 22 | }); 23 | 24 | let response = ListResourcesResult { 25 | resources, 26 | next_cursor: None, 27 | }; 28 | Ok(response) 29 | } 30 | 31 | pub async fn resource_read(request: ReadResourceRequest) -> HandlerResult { 32 | let response = match request.uri.path() { 33 | "/api/allowed_directories" => { 34 | let allowed_dirs = get_allowed_directories(); 35 | ReadResourceResult { 36 | contents: vec![TextResourceContents { 37 | uri: request.uri.clone(), 38 | mime_type: Some("application/json".to_string()), 39 | text: serde_json::to_string_pretty(&allowed_dirs).unwrap(), 40 | }], 41 | } 42 | }, 43 | _ => return Err(json!({"code": -32602, "message": "Resource not found"}).into_handler_error()), 44 | }; 45 | Ok(response) 46 | } 47 | 48 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 49 | pub struct GetAllowedDirectoriesRequest { 50 | } 51 | 52 | pub async fn allowed_directories(_request: GetAllowedDirectoriesRequest) -> HandlerResult { 53 | let allowed_dirs = get_allowed_directories(); 54 | Ok(ReadResourceResult { 55 | contents: vec![TextResourceContents { 56 | uri: Url::parse("file:///api/allowed_directories").unwrap(), 57 | mime_type: Some("application/json".to_string()), 58 | text: serde_json::to_string_pretty(&allowed_dirs).unwrap(), 59 | }], 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /src/mcp/tools_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use super::*; 4 | use std::fs; 5 | use std::path::Path; 6 | use tempfile::TempDir; 7 | 8 | fn setup_git_repo() -> (TempDir, String) { 9 | let temp_dir = TempDir::new().unwrap(); 10 | let repo_path = temp_dir.path().to_str().unwrap().to_string(); 11 | 12 | // Initialize git repo 13 | git2::Repository::init(&repo_path).unwrap(); 14 | 15 | // Create a test file 16 | let file_path = Path::new(&repo_path).join("test.txt"); 17 | fs::write(&file_path, "initial content\n").unwrap(); 18 | 19 | (temp_dir, file_path.to_str().unwrap().to_string()) 20 | } 21 | 22 | #[tokio::test] 23 | async fn test_file_edit_with_git() { 24 | let (temp_dir, file_path) = setup_git_repo(); 25 | 26 | let request = FileEditRequest { 27 | file_path: file_path.clone(), 28 | start_line: 0, 29 | end_line: 0, 30 | new_content: "modified content".to_string(), 31 | commit_message: "test commit".to_string(), 32 | }; 33 | 34 | let result = file_edit(request).await.unwrap(); 35 | 36 | // Verify file was modified 37 | let content = fs::read_to_string(&file_path).unwrap(); 38 | assert_eq!(content, "modified content"); 39 | 40 | // Verify git commit happened 41 | let repo = git2::Repository::open(temp_dir.path()).unwrap(); 42 | let head_commit = repo.head().unwrap().peel_to_commit().unwrap(); 43 | assert_eq!(head_commit.message().unwrap(), "test commit"); 44 | 45 | // Clean up 46 | drop(temp_dir); 47 | } 48 | 49 | #[tokio::test] 50 | async fn test_file_edit_without_git() { 51 | let temp_dir = TempDir::new().unwrap(); 52 | let file_path = temp_dir.path().join("test.txt"); 53 | fs::write(&file_path, "initial content\n").unwrap(); 54 | 55 | let request = FileEditRequest { 56 | file_path: file_path.to_str().unwrap().to_string(), 57 | start_line: 0, 58 | end_line: 0, 59 | new_content: "modified content".to_string(), 60 | commit_message: "test commit".to_string(), 61 | }; 62 | 63 | let result = file_edit(request).await.unwrap(); 64 | 65 | // Verify file was modified 66 | let content = fs::read_to_string(&file_path).unwrap(); 67 | assert_eq!(content, "modified content"); 68 | 69 | // Clean up 70 | drop(temp_dir); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/mcp/templates/prompts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "current_time", 4 | "description": "Display current time in the city", 5 | "arguments": [ 6 | { 7 | "name": "city", 8 | "description": "city name", 9 | "required": true 10 | } 11 | ] 12 | }, 13 | { 14 | "name": "get_local_time", 15 | "description": "Get the current local time", 16 | "arguments": null 17 | }, 18 | { 19 | "name": "file_edit", 20 | "description": "Make a targeted edit to a file", 21 | "arguments": [ 22 | { 23 | "name": "file_path", 24 | "description": "Path to file to edit", 25 | "required": true 26 | }, 27 | { 28 | "name": "commit_message", 29 | "description": "Message describing the edit", 30 | "required": true 31 | } 32 | ] 33 | }, 34 | { 35 | "name": "read_file", 36 | "description": "Read contents of a file", 37 | "arguments": [ 38 | { 39 | "name": "file_path", 40 | "description": "Path to file to read", 41 | "required": true 42 | } 43 | ] 44 | }, 45 | { 46 | "name": "list_directory", 47 | "description": "List contents of a directory", 48 | "arguments": [ 49 | { 50 | "name": "path", 51 | "description": "Path to directory to list", 52 | "required": true 53 | } 54 | ] 55 | }, 56 | { 57 | "name": "move_or_rename", 58 | "description": "Move or rename a file or directory", 59 | "arguments": [ 60 | { 61 | "name": "source_path", 62 | "description": "Source path to move/rename from", 63 | "required": true 64 | }, 65 | { 66 | "name": "target_path", 67 | "description": "Target path to move/rename to", 68 | "required": true 69 | } 70 | ] 71 | }, 72 | { 73 | "name": "get_file_info", 74 | "description": "Get metadata about a file", 75 | "arguments": [ 76 | { 77 | "name": "path", 78 | "description": "Path to file to get info about", 79 | "required": true 80 | } 81 | ] 82 | }, 83 | { 84 | "name": "create_directory", 85 | "description": "Create a new directory", 86 | "arguments": [ 87 | { 88 | "name": "path", 89 | "description": "Path to directory to create", 90 | "required": true 91 | } 92 | ] 93 | }, 94 | { 95 | "name": "overwrite_file", 96 | "description": "Overwrite contents of a file", 97 | "arguments": [ 98 | { 99 | "name": "file_path", 100 | "description": "Path to file to overwrite", 101 | "required": true 102 | }, 103 | { 104 | "name": "content", 105 | "description": "New content for the file", 106 | "required": true 107 | } 108 | ] 109 | } 110 | ] 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | rs_filesystem.logs.jsonl 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/rust,visualstudiocode,jetbrains+all 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=rust,visualstudiocode,jetbrains+all 5 | 6 | ### JetBrains+all ### 7 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 8 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 9 | 10 | # User-specific stuff 11 | .idea/**/workspace.xml 12 | .idea/**/tasks.xml 13 | .idea/**/usage.statistics.xml 14 | .idea/**/dictionaries 15 | .idea/**/shelf 16 | 17 | # AWS User-specific 18 | .idea/**/aws.xml 19 | 20 | # Generated files 21 | .idea/**/contentModel.xml 22 | 23 | # Sensitive or high-churn files 24 | .idea/**/dataSources/ 25 | .idea/**/dataSources.ids 26 | .idea/**/dataSources.local.xml 27 | .idea/**/sqlDataSources.xml 28 | .idea/**/dynamic.xml 29 | .idea/**/uiDesigner.xml 30 | .idea/**/dbnavigator.xml 31 | 32 | # Gradle 33 | .idea/**/gradle.xml 34 | .idea/**/libraries 35 | 36 | # Gradle and Maven with auto-import 37 | # When using Gradle or Maven with auto-import, you should exclude module files, 38 | # since they will be recreated, and may cause churn. Uncomment if using 39 | # auto-import. 40 | # .idea/artifacts 41 | # .idea/compiler.xml 42 | # .idea/jarRepositories.xml 43 | # .idea/modules.xml 44 | # .idea/*.iml 45 | # .idea/modules 46 | # *.iml 47 | # *.ipr 48 | 49 | # CMake 50 | cmake-build-*/ 51 | 52 | # Mongo Explorer plugin 53 | .idea/**/mongoSettings.xml 54 | 55 | # File-based project format 56 | *.iws 57 | 58 | # IntelliJ 59 | out/ 60 | 61 | # mpeltonen/sbt-idea plugin 62 | .idea_modules/ 63 | 64 | # JIRA plugin 65 | atlassian-ide-plugin.xml 66 | 67 | # Cursive Clojure plugin 68 | .idea/replstate.xml 69 | 70 | # SonarLint plugin 71 | .idea/sonarlint/ 72 | 73 | # Crashlytics plugin (for Android Studio and IntelliJ) 74 | com_crashlytics_export_strings.xml 75 | crashlytics.properties 76 | crashlytics-build.properties 77 | fabric.properties 78 | 79 | # Editor-based Rest Client 80 | .idea/httpRequests 81 | 82 | # Android studio 3.1+ serialized cache file 83 | .idea/caches/build_file_checksums.ser 84 | 85 | ### JetBrains+all Patch ### 86 | # Ignore everything but code style settings and run configurations 87 | # that are supposed to be shared within teams. 88 | 89 | .idea/* 90 | 91 | !.idea/codeStyles 92 | !.idea/runConfigurations 93 | 94 | ### Rust ### 95 | # Generated by Cargo 96 | # will have compiled files and executables 97 | debug/ 98 | target/ 99 | 100 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 101 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 102 | Cargo.lock 103 | 104 | # These are backup files generated by rustfmt 105 | **/*.rs.bk 106 | 107 | # MSVC Windows builds of rustc generate these, which store debugging information 108 | *.pdb 109 | 110 | ### VisualStudioCode ### 111 | .vscode/* 112 | !.vscode/settings.json 113 | !.vscode/tasks.json 114 | !.vscode/launch.json 115 | !.vscode/extensions.json 116 | !.vscode/*.code-snippets 117 | 118 | # Local History for Visual Studio Code 119 | .history/ 120 | 121 | # Built Visual Studio Code Extensions 122 | *.vsix 123 | 124 | ### VisualStudioCode Patch ### 125 | # Ignore all local history of files 126 | .history 127 | .ionide 128 | 129 | # End of https://www.toptal.com/developers/gitignore/api/rust,visualstudiocode,jetbrains+all 130 | -------------------------------------------------------------------------------- /src/mcp/utilities.rs: -------------------------------------------------------------------------------- 1 | use crate::mcp::types::*; 2 | use crate::mcp::PROTOCOL_VERSION; 3 | use crate::mcp::SERVER_NAME; 4 | use crate::mcp::SERVER_VERSION; 5 | use rpc_router::HandlerResult; 6 | use serde_json::json; 7 | use serde_json::Value; 8 | use std::path::Path; 9 | 10 | pub fn get_allowed_directories() -> Vec { 11 | std::env::var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES") 12 | .unwrap_or_default() 13 | .split(':') 14 | .filter(|s| !s.is_empty()) 15 | .map(String::from) 16 | .collect() 17 | } 18 | 19 | /// handler for `initialize` request from client 20 | pub async fn initialize(_request: InitializeRequest) -> HandlerResult { 21 | let result = InitializeResult { 22 | protocol_version: PROTOCOL_VERSION.to_string(), 23 | server_info: Implementation { 24 | name: SERVER_NAME.to_string(), 25 | version: SERVER_VERSION.to_string(), 26 | }, 27 | capabilities: ServerCapabilities { 28 | experimental: None, 29 | prompts: Some(PromptCapabilities::default()), 30 | resources: None, 31 | tools: Some(json!({})), 32 | roots: None, 33 | sampling: None, 34 | logging: None, 35 | }, 36 | instructions: None, 37 | }; 38 | Ok(result) 39 | } 40 | 41 | /// handler for SIGINT by client 42 | pub fn graceful_shutdown() { 43 | // shutdown server 44 | } 45 | 46 | /// handler for `notifications/initialized` from client 47 | pub fn notifications_initialized() {} 48 | 49 | /// handler for `notifications/cancelled` from client 50 | pub fn notifications_cancelled(_params: CancelledNotification) { 51 | // cancel request 52 | } 53 | 54 | pub async fn ping() -> HandlerResult { 55 | Ok(EmptyResult {}) 56 | } 57 | 58 | pub async fn logging_set_level(_request: SetLevelRequest) -> HandlerResult { 59 | Ok(LoggingResponse {}) 60 | } 61 | 62 | pub async fn roots_list(_request: Option) -> HandlerResult { 63 | let response = ListRootsResult { 64 | roots: vec![Root { 65 | name: "my project".to_string(), 66 | url: "file:///home/user/projects/my-project".to_string(), 67 | }], 68 | }; 69 | Ok(response) 70 | } 71 | 72 | /// send notification to client 73 | #[allow(dead_code)] 74 | pub fn notify(method: &str, params: Option) { 75 | let notification = json!({ 76 | "jsonrpc": "2.0", 77 | "method": method, 78 | "params": params, 79 | }); 80 | println!("{}", serde_json::to_string(¬ification).unwrap()); 81 | } 82 | 83 | pub fn is_path_allowed(path: &Path) -> bool { 84 | let allowed_dirs = get_allowed_directories(); 85 | if allowed_dirs.is_empty() { 86 | return false; // If no directories are explicitly allowed, deny all access 87 | } 88 | 89 | // Get all parent directories of the path, including itself 90 | let mut check_path = path.to_path_buf(); 91 | loop { 92 | // Try to canonicalize the current path if it exists 93 | let canonical_check = if check_path.exists() { 94 | match check_path.canonicalize() { 95 | Ok(p) => p, 96 | Err(_) => check_path.clone(), 97 | } 98 | } else { 99 | check_path.clone() 100 | }; 101 | 102 | // Check if this path or parent is allowed 103 | for allowed_dir in &allowed_dirs { 104 | let allowed_path = Path::new(allowed_dir); 105 | let canonical_allowed = if allowed_path.exists() { 106 | match allowed_path.canonicalize() { 107 | Ok(p) => p, 108 | Err(_) => continue, 109 | } 110 | } else { 111 | continue; 112 | }; 113 | 114 | if canonical_check.starts_with(&canonical_allowed) { 115 | return true; 116 | } 117 | } 118 | 119 | // Move up to parent directory 120 | match check_path.parent() { 121 | Some(parent) => check_path = parent.to_path_buf(), 122 | None => break, 123 | } 124 | } 125 | 126 | false 127 | } 128 | 129 | pub fn validate_path_or_error(path: &Path) -> Result<(), String> { 130 | if !is_path_allowed(path) { 131 | Err(format!( 132 | "Access denied: {} is not within allowed directories. Use the allowed_directories resource to view permitted locations.", 133 | path.display() 134 | )) 135 | } else { 136 | Ok(()) 137 | } 138 | } 139 | 140 | // For operations that involve two paths (like move/rename) 141 | pub fn validate_paths_or_error(source: &Path, target: &Path) -> Result<(), String> { 142 | if !is_path_allowed(source) { 143 | Err(format!( 144 | "Access denied: source path {} is not within allowed directories", 145 | source.display() 146 | )) 147 | } else if !is_path_allowed(target) { 148 | Err(format!( 149 | "Access denied: target path {} is not within allowed directories", 150 | target.display() 151 | )) 152 | } else { 153 | Ok(()) 154 | } 155 | } -------------------------------------------------------------------------------- /src/mcp/templates/tools.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "get_current_time_in_city", 4 | "description": "Get the current time in the city", 5 | "inputSchema": { 6 | "type": "object", 7 | "properties": { 8 | "city": { 9 | "type": "string", 10 | "description": "city name" 11 | } 12 | }, 13 | "required": ["city"] 14 | } 15 | }, 16 | { 17 | "name": "get_local_time", 18 | "description": "Get the current local time", 19 | "inputSchema": { 20 | "type": "object", 21 | "properties": {}, 22 | "required": [] 23 | } 24 | }, 25 | { 26 | "name": "file_edit", 27 | "description": "Replace exact text content in a file with optional git commit. Returns error if content not found or if there are multiple matches.", 28 | "inputSchema": { 29 | "type": "object", 30 | "properties": { 31 | "file_path": { 32 | "type": "string", 33 | "description": "Path to the file to edit" 34 | }, 35 | "old_content": { 36 | "type": "string", 37 | "description": "Exact content to replace (must match uniquely)" 38 | }, 39 | "new_content": { 40 | "type": "string", 41 | "description": "Content to insert instead" 42 | }, 43 | "commit_message": { 44 | "type": "string", 45 | "description": "Message describing the purpose of this edit" 46 | } 47 | }, 48 | "required": ["file_path", "old_content", "new_content", "commit_message"] 49 | } 50 | }, 51 | { 52 | "name": "read_file", 53 | "description": "Read the contents of a file", 54 | "inputSchema": { 55 | "type": "object", 56 | "properties": { 57 | "file_path": { 58 | "type": "string", 59 | "description": "Path to the file to read" 60 | } 61 | }, 62 | "required": ["file_path"] 63 | } 64 | }, 65 | { 66 | "name": "list_directory", 67 | "description": "List contents of a directory", 68 | "inputSchema": { 69 | "type": "object", 70 | "properties": { 71 | "path": { 72 | "type": "string", 73 | "description": "Path to directory to list" 74 | } 75 | }, 76 | "required": ["path"] 77 | } 78 | }, 79 | { 80 | "name": "move_or_rename", 81 | "description": "Move or rename a file or directory", 82 | "inputSchema": { 83 | "type": "object", 84 | "properties": { 85 | "source_path": { 86 | "type": "string", 87 | "description": "Source path to move/rename from" 88 | }, 89 | "target_path": { 90 | "type": "string", 91 | "description": "Target path to move/rename to" 92 | }, 93 | "commit_message": { 94 | "type": "string", 95 | "description": "Message describing the purpose of this move/rename" 96 | } 97 | }, 98 | "required": ["source_path", "target_path", "commit_message"] 99 | } 100 | }, 101 | { 102 | "name": "get_file_info", 103 | "description": "Get metadata about a file", 104 | "inputSchema": { 105 | "type": "object", 106 | "properties": { 107 | "path": { 108 | "type": "string", 109 | "description": "Path to file to get info about" 110 | } 111 | }, 112 | "required": ["path"] 113 | } 114 | }, 115 | { 116 | "name": "create_directory", 117 | "description": "Create a new directory", 118 | "inputSchema": { 119 | "type": "object", 120 | "properties": { 121 | "path": { 122 | "type": "string", 123 | "description": "Path to the new directory" 124 | }, 125 | "commit_message": { 126 | "type": "string", 127 | "description": "Message describing the purpose of this directory creation" 128 | } 129 | }, 130 | "required": ["path", "commit_message"] 131 | } 132 | }, 133 | { 134 | "name": "overwrite_file", 135 | "description": "Overwrite the contents of a file", 136 | "inputSchema": { 137 | "type": "object", 138 | "properties": { 139 | "path": { 140 | "type": "string", 141 | "description": "Path to the file to overwrite" 142 | }, 143 | "content": { 144 | "type": "string", 145 | "description": "New content to write to the file" 146 | }, 147 | "commit_message": { 148 | "type": "string", 149 | "description": "Message describing the purpose of this file overwrite" 150 | } 151 | }, 152 | "required": ["path", "content", "commit_message"] 153 | } 154 | }, 155 | { 156 | "name": "grep_search", 157 | "description": "Search for a pattern in files or directories. For recursive searches, the path must be a directory. For non-recursive searches, the path must exist.", 158 | "inputSchema": { 159 | "type": "object", 160 | "properties": { 161 | "pattern": { 162 | "type": "string", 163 | "description": "Pattern to search for" 164 | }, 165 | "path": { 166 | "type": "string", 167 | "description": "Path to search in. For recursive searches this must be a directory." 168 | }, 169 | "recursive": { 170 | "type": "boolean", 171 | "description": "Whether to search recursively in subdirectories. Defaults to true.", 172 | "default": true 173 | }, 174 | "case_sensitive": { 175 | "type": "boolean", 176 | "description": "Whether the search should be case sensitive. Defaults to true.", 177 | "default": true 178 | } 179 | }, 180 | "required": ["pattern", "path"] 181 | } 182 | } 183 | ] 184 | -------------------------------------------------------------------------------- /src/mcp/prompts.rs: -------------------------------------------------------------------------------- 1 | use crate::mcp::types::*; 2 | use rpc_router::HandlerResult; 3 | use rpc_router::IntoHandlerError; 4 | use serde_json::json; 5 | 6 | pub async fn prompts_list( 7 | _request: Option, 8 | ) -> HandlerResult { 9 | //let prompts: Vec = serde_json::from_str(include_str!("./templates/prompts.json")).unwrap(); 10 | let response = ListPromptsResult { 11 | next_cursor: None, 12 | prompts: vec![ 13 | Prompt { 14 | name: "current_time".to_string(), 15 | description: Some("Display current time in the city".to_string()), 16 | arguments: Some(vec![PromptArgument { 17 | name: "city".to_string(), 18 | description: Some("city name".to_string()), 19 | required: Some(true), 20 | }]), 21 | }, 22 | Prompt { 23 | name: "get_local_time".to_string(), 24 | description: Some("Get the current local time".to_string()), 25 | arguments: None, 26 | }, 27 | Prompt { 28 | name: "file_edit".to_string(), 29 | description: Some("Make a targeted edit to a file".to_string()), 30 | arguments: Some(vec![ 31 | PromptArgument { 32 | name: "file_path".to_string(), 33 | description: Some("Path to file to edit".to_string()), 34 | required: Some(true), 35 | }, 36 | PromptArgument { 37 | name: "commit_message".to_string(), 38 | description: Some("Message describing the edit".to_string()), 39 | required: Some(true), 40 | }, 41 | ]), 42 | }, 43 | Prompt { 44 | name: "read_file".to_string(), 45 | description: Some("Read contents of a file".to_string()), 46 | arguments: Some(vec![PromptArgument { 47 | name: "file_path".to_string(), 48 | description: Some("Path to file to read".to_string()), 49 | required: Some(true), 50 | }]), 51 | }, 52 | Prompt { 53 | name: "list_directory".to_string(), 54 | description: Some("List contents of a directory".to_string()), 55 | arguments: Some(vec![PromptArgument { 56 | name: "path".to_string(), 57 | description: Some("Path to directory to list".to_string()), 58 | required: Some(true), 59 | }]), 60 | }, 61 | Prompt { 62 | name: "move_or_rename".to_string(), 63 | description: Some("Move or rename a file or directory".to_string()), 64 | arguments: Some(vec![ 65 | PromptArgument { 66 | name: "source_path".to_string(), 67 | description: Some("Source path to move/rename from".to_string()), 68 | required: Some(true), 69 | }, 70 | PromptArgument { 71 | name: "target_path".to_string(), 72 | description: Some("Target path to move/rename to".to_string()), 73 | required: Some(true), 74 | }, 75 | ]), 76 | }, 77 | Prompt { 78 | name: "get_file_info".to_string(), 79 | description: Some("Get metadata about a file".to_string()), 80 | arguments: Some(vec![PromptArgument { 81 | name: "path".to_string(), 82 | description: Some("Path to file to get info about".to_string()), 83 | required: Some(true), 84 | }]), 85 | }, 86 | Prompt { 87 | name: "create_directory".to_string(), 88 | description: Some("Create a new directory".to_string()), 89 | arguments: Some(vec![PromptArgument { 90 | name: "path".to_string(), 91 | description: Some("Path to directory to create".to_string()), 92 | required: Some(true), 93 | }]), 94 | }, 95 | Prompt { 96 | name: "overwrite_file".to_string(), 97 | description: Some("Overwrite contents of a file".to_string()), 98 | arguments: Some(vec![ 99 | PromptArgument { 100 | name: "file_path".to_string(), 101 | description: Some("Path to file to overwrite".to_string()), 102 | required: Some(true), 103 | }, 104 | PromptArgument { 105 | name: "content".to_string(), 106 | description: Some("New content for the file".to_string()), 107 | required: Some(true), 108 | }, 109 | ]), 110 | }, 111 | ], 112 | }; 113 | Ok(response) 114 | } 115 | 116 | pub async fn prompts_get(request: GetPromptRequest) -> HandlerResult { 117 | let response = match request.name.as_str() { 118 | "current_time" => PromptResult { 119 | description: "Get the current time in city".to_string(), 120 | messages: Some(vec![PromptMessage { 121 | role: "user".to_string(), 122 | content: PromptMessageContent { 123 | type_name: "text".to_string(), 124 | text: format!( 125 | "What's the time of {}?", 126 | request.arguments.as_ref().unwrap()["city"].as_str().unwrap() 127 | ), 128 | }, 129 | }]), 130 | }, 131 | "get_local_time" => PromptResult { 132 | description: "Get the current local time".to_string(), 133 | messages: Some(vec![PromptMessage { 134 | role: "user".to_string(), 135 | content: PromptMessageContent { 136 | type_name: "text".to_string(), 137 | text: "What's the current local time?".to_string(), 138 | }, 139 | }]), 140 | }, 141 | "file_edit" => PromptResult { 142 | description: "Edit a file".to_string(), 143 | messages: Some(vec![PromptMessage { 144 | role: "user".to_string(), 145 | content: PromptMessageContent { 146 | type_name: "text".to_string(), 147 | text: format!( 148 | "Edit file {} with message: {}", 149 | request.arguments.as_ref().unwrap()["file_path"].as_str().unwrap(), 150 | request.arguments.as_ref().unwrap()["commit_message"].as_str().unwrap() 151 | ), 152 | }, 153 | }]), 154 | }, 155 | "read_file" => PromptResult { 156 | description: "Read a file".to_string(), 157 | messages: Some(vec![PromptMessage { 158 | role: "user".to_string(), 159 | content: PromptMessageContent { 160 | type_name: "text".to_string(), 161 | text: format!( 162 | "Read file {}", 163 | request.arguments.as_ref().unwrap()["file_path"].as_str().unwrap() 164 | ), 165 | }, 166 | }]), 167 | }, 168 | "list_directory" => PromptResult { 169 | description: "List directory contents".to_string(), 170 | messages: Some(vec![PromptMessage { 171 | role: "user".to_string(), 172 | content: PromptMessageContent { 173 | type_name: "text".to_string(), 174 | text: format!( 175 | "List contents of directory {}", 176 | request.arguments.as_ref().unwrap()["path"].as_str().unwrap() 177 | ), 178 | }, 179 | }]), 180 | }, 181 | "move_or_rename" => PromptResult { 182 | description: "Move or rename file/directory".to_string(), 183 | messages: Some(vec![PromptMessage { 184 | role: "user".to_string(), 185 | content: PromptMessageContent { 186 | type_name: "text".to_string(), 187 | text: format!( 188 | "Move/rename {} to {}", 189 | request.arguments.as_ref().unwrap()["source_path"].as_str().unwrap(), 190 | request.arguments.as_ref().unwrap()["target_path"].as_str().unwrap() 191 | ), 192 | }, 193 | }]), 194 | }, 195 | "get_file_info" => PromptResult { 196 | description: "Get file metadata".to_string(), 197 | messages: Some(vec![PromptMessage { 198 | role: "user".to_string(), 199 | content: PromptMessageContent { 200 | type_name: "text".to_string(), 201 | text: format!( 202 | "Get info for {}", 203 | request.arguments.as_ref().unwrap()["path"].as_str().unwrap() 204 | ), 205 | }, 206 | }]), 207 | }, 208 | "create_directory" => PromptResult { 209 | description: "Create a new directory".to_string(), 210 | messages: Some(vec![PromptMessage { 211 | role: "user".to_string(), 212 | content: PromptMessageContent { 213 | type_name: "text".to_string(), 214 | text: format!( 215 | "Create directory {}", 216 | request.arguments.as_ref().unwrap()["path"].as_str().unwrap() 217 | ), 218 | }, 219 | }]), 220 | }, 221 | "overwrite_file" => PromptResult { 222 | description: "Overwrite file contents".to_string(), 223 | messages: Some(vec![PromptMessage { 224 | role: "user".to_string(), 225 | content: PromptMessageContent { 226 | type_name: "text".to_string(), 227 | text: format!( 228 | "Overwrite {} with new content", 229 | request.arguments.as_ref().unwrap()["file_path"].as_str().unwrap() 230 | ), 231 | }, 232 | }]), 233 | }, 234 | _ => { 235 | return Err(json!({"code": -32602, "message": "Prompt not found"}).into_handler_error()) 236 | } 237 | }; 238 | Ok(response) 239 | } 240 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod mcp; 2 | 3 | use crate::mcp::prompts::prompts_get; 4 | use crate::mcp::prompts::prompts_list; 5 | use crate::mcp::resources::resource_read; 6 | use crate::mcp::resources::resources_list; 7 | use crate::mcp::resources::{allowed_directories}; 8 | use crate::mcp::tools::register_tools; 9 | use crate::mcp::tools::tools_list; 10 | use crate::mcp::types::CancelledNotification; 11 | use crate::mcp::types::JsonRpcError; 12 | use crate::mcp::types::JsonRpcResponse; 13 | use crate::mcp::types::ToolCallRequestParams; 14 | use crate::mcp::utilities::*; 15 | use clap::Parser; 16 | use dirs::data_local_dir; 17 | use dirs::home_dir; 18 | use dirs::state_dir; 19 | use rpc_router::Error; 20 | use rpc_router::Handler; 21 | use rpc_router::Request; 22 | use rpc_router::Router; 23 | use rpc_router::RouterBuilder; 24 | use serde_json::json; 25 | use serde_json::Value; 26 | use std::env; 27 | use std::fs::OpenOptions; 28 | use std::io::Write; 29 | use std::path::PathBuf; 30 | use tokio::io::AsyncBufReadExt; 31 | use tokio::signal; 32 | 33 | fn build_rpc_router() -> Router { 34 | let builder = RouterBuilder::default() 35 | // append resources here 36 | .append_dyn("initialize", initialize.into_dyn()) 37 | .append_dyn("ping", ping.into_dyn()) 38 | .append_dyn("logging/setLevel", logging_set_level.into_dyn()) 39 | .append_dyn("roots/list", roots_list.into_dyn()) 40 | .append_dyn("prompts/list", prompts_list.into_dyn()) 41 | .append_dyn("prompts/get", prompts_get.into_dyn()) 42 | .append_dyn("resources/list", resources_list.into_dyn()) 43 | .append_dyn("resources/read", resource_read.into_dyn()) 44 | .append_dyn("resources/allowed_directories", allowed_directories.into_dyn()); 45 | let builder = register_tools(builder); 46 | builder.build() 47 | } 48 | 49 | fn get_log_directory() -> PathBuf { 50 | if cfg!(target_os = "macos") { 51 | // macOS: ~/Library/Logs/Claude 52 | home_dir() 53 | .unwrap_or_else(|| PathBuf::from(".")) 54 | .join("Library/Logs/Claude") 55 | } else if cfg!(target_os = "windows") { 56 | // Windows: %LOCALAPPDATA%\Claude\logs 57 | data_local_dir() 58 | .unwrap_or_else(|| PathBuf::from(".")) 59 | .join("Claude") 60 | .join("logs") 61 | } else { 62 | // Linux: ~/.local/state/claude/logs 63 | state_dir() 64 | .unwrap_or_else(|| { 65 | home_dir() 66 | .unwrap_or_else(|| PathBuf::from(".")) 67 | .join(".local/state") 68 | }) 69 | .join("claude") 70 | .join("logs") 71 | } 72 | } 73 | 74 | #[tokio::main] 75 | async fn main() { 76 | // Parse command-line arguments 77 | let args = Args::parse(); 78 | if !args.mcp { 79 | display_info(&args).await; 80 | return; 81 | } 82 | 83 | // Clone necessary variables for the shutdown task 84 | let shutdown_handle = tokio::spawn(async { 85 | // Create a shutdown signal future 86 | #[cfg(unix)] 87 | let shutdown = async { 88 | // Listen for SIGINT and SIGTERM on Unix 89 | let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()) 90 | .expect("Failed to set up SIGINT handler"); 91 | let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) 92 | .expect("Failed to set up SIGTERM handler"); 93 | 94 | tokio::select! { 95 | _ = sigint.recv() => {}, 96 | _ = sigterm.recv() => {}, 97 | } 98 | }; 99 | 100 | #[cfg(windows)] 101 | let shutdown = async { 102 | // Listen for Ctrl+C on Windows 103 | signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); 104 | }; 105 | 106 | shutdown.await; 107 | graceful_shutdown(); 108 | std::process::exit(0); 109 | }); 110 | 111 | // Process JSON-RPC from MCP client 112 | let router = build_rpc_router(); 113 | let log_path = env::var("MCP_LOG_FILE_PATH").map(PathBuf::from).unwrap_or_else(|_| { 114 | get_log_directory().join("rs_filesystem.logs.jsonl") 115 | }); 116 | 117 | let mut logging_file = OpenOptions::new() 118 | .create(true) 119 | .write(true) 120 | .append(true) 121 | .open(&log_path) 122 | .unwrap(); 123 | 124 | // Spawn a task to read lines from stdin 125 | let rpc_handle = tokio::spawn(async move { 126 | let mut reader = tokio::io::BufReader::new(tokio::io::stdin()).lines(); 127 | 128 | while let Ok(Some(line)) = reader.next_line().await { 129 | writeln!(logging_file, "{}", line).unwrap(); 130 | if !line.is_empty() { 131 | if let Ok(json_value) = serde_json::from_str::(&line) { 132 | // Notifications, no response required 133 | if json_value.is_object() && json_value.get("id").is_none() { 134 | if let Some(method) = json_value.get("method") { 135 | if method == "notifications/initialized" { 136 | notifications_initialized(); 137 | } else if method == "notifications/cancelled" { 138 | let params_value = json_value.get("params").unwrap(); 139 | let cancel_params: CancelledNotification = 140 | serde_json::from_value(params_value.clone()).unwrap(); 141 | notifications_cancelled(cancel_params); 142 | } 143 | } 144 | } else if let Ok(mut rpc_request) = Request::from_value(json_value) { 145 | // Normal JSON-RPC message, and response expected 146 | let id = rpc_request.id.clone(); 147 | if rpc_request.method == "tools/call" { 148 | let params = serde_json::from_value::( 149 | rpc_request.params.unwrap(), 150 | ) 151 | .unwrap(); 152 | rpc_request = Request { 153 | id: id.clone(), 154 | method: params.name, 155 | params: params.arguments, 156 | } 157 | } 158 | match router.call(rpc_request).await { 159 | Ok(call_response) => { 160 | if !call_response.value.is_null() { 161 | let response = 162 | JsonRpcResponse::new(id, call_response.value.clone()); 163 | let response_json = serde_json::to_string(&response).unwrap(); 164 | writeln!(logging_file, "{}\n", response_json).unwrap(); 165 | println!("{}", response_json); 166 | } 167 | } 168 | Err(error) => match &error.error { 169 | // Error from JSON-RPC call 170 | Error::Handler(handler) => { 171 | if let Some(error_value) = handler.get::() { 172 | let json_error = json!({ 173 | "jsonrpc": "2.0", 174 | "error": error_value, 175 | "id": id 176 | }); 177 | let response = serde_json::to_string(&json_error).unwrap(); 178 | writeln!(logging_file, "{}\n", response).unwrap(); 179 | println!("{}", response); 180 | } 181 | } 182 | _ => { 183 | let json_error = JsonRpcError::new( 184 | id, 185 | -1, 186 | format!( 187 | "Invalid json-rpc call, error: {}", 188 | error.error.to_string() 189 | ) 190 | .as_str(), 191 | ); 192 | let response = serde_json::to_string(&json_error).unwrap(); 193 | writeln!(logging_file, "{}\n", response).unwrap(); 194 | println!("{}", response); 195 | } 196 | }, 197 | } 198 | } 199 | } 200 | } 201 | } 202 | }); 203 | 204 | // Wait for either the RPC handling or shutdown to complete 205 | tokio::select! { 206 | _ = rpc_handle => {}, 207 | _ = shutdown_handle => {}, 208 | } 209 | } 210 | 211 | #[derive(Parser, Debug)] 212 | #[command(version, about, long_about = None)] 213 | struct Args { 214 | /// List resources 215 | #[arg(long, default_value = "false")] 216 | resources: bool, 217 | /// List prompts 218 | #[arg(long, default_value = "false")] 219 | prompts: bool, 220 | /// List tools 221 | #[arg(long, default_value = "false")] 222 | tools: bool, 223 | /// Start MCP server 224 | #[arg(long, default_value = "false")] 225 | mcp: bool, 226 | } 227 | 228 | impl Args { 229 | fn is_args_available(&self) -> bool { 230 | self.prompts || self.resources || self.tools 231 | } 232 | } 233 | 234 | async fn display_info(args: &Args) { 235 | if !args.is_args_available() { 236 | println!("Please use --help to see available options"); 237 | return; 238 | } 239 | 240 | if args.prompts { 241 | if let Ok(result) = prompts_list(None).await { 242 | println!("prompts:"); 243 | for prompt in result.prompts { 244 | println!(" - {}: {}", 245 | prompt.name, 246 | prompt.description.unwrap_or_default() 247 | ); 248 | } 249 | } 250 | } 251 | 252 | if args.resources { 253 | if let Ok(result) = resources_list(None).await { 254 | println!("resources:"); 255 | for resource in result.resources { 256 | println!(" - {}: {}", 257 | resource.name, 258 | resource.uri 259 | ); 260 | } 261 | } 262 | } 263 | 264 | if args.tools { 265 | if let Ok(result) = tools_list(None).await { 266 | println!("tools:"); 267 | for tool in result.tools { 268 | println!(" - {}: {}", 269 | tool.name, 270 | tool.description.unwrap_or_default() 271 | ); 272 | } 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/mcp/types.rs: -------------------------------------------------------------------------------- 1 | use crate::mcp::JSONRPC_VERSION; 2 | use rpc_router::RpcParams; 3 | use serde::Deserialize; 4 | use serde::Serialize; 5 | use serde_json::Value; 6 | use std::collections::HashMap; 7 | use url::Url; 8 | 9 | #[derive(Debug, Deserialize, Serialize, RpcParams, Clone)] 10 | pub struct InitializeRequest { 11 | #[serde(rename = "protocolVersion")] 12 | pub protocol_version: String, 13 | pub capabilities: ClientCapabilities, 14 | #[serde(rename = "clientInfo")] 15 | pub client_info: Implementation, 16 | } 17 | 18 | #[derive(Debug, Deserialize, Serialize, Clone, Default)] 19 | #[serde(default)] 20 | pub struct ServerCapabilities { 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub experimental: Option, 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub prompts: Option, 25 | #[serde(skip_serializing_if = "Option::is_none")] 26 | pub resources: Option, 27 | #[serde(skip_serializing_if = "Option::is_none")] 28 | pub tools: Option, 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | pub roots: Option, 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | pub sampling: Option, 33 | #[serde(skip_serializing_if = "Option::is_none")] 34 | pub logging: Option, 35 | } 36 | 37 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 38 | #[serde(rename_all = "camelCase")] 39 | #[serde(default)] 40 | pub struct PromptCapabilities { 41 | #[serde(skip_serializing_if = "Option::is_none")] 42 | pub list_changed: Option, 43 | } 44 | 45 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 46 | #[serde(rename_all = "camelCase")] 47 | #[serde(default)] 48 | pub struct ResourceCapabilities { 49 | #[serde(skip_serializing_if = "Option::is_none")] 50 | pub subscribe: Option, 51 | #[serde(skip_serializing_if = "Option::is_none")] 52 | pub list_changed: Option, 53 | } 54 | 55 | #[derive(Debug, Deserialize, Serialize, Clone, Default)] 56 | #[serde(default)] 57 | pub struct ClientCapabilities { 58 | #[serde(skip_serializing_if = "Option::is_none")] 59 | pub experimental: Option, 60 | #[serde(skip_serializing_if = "Option::is_none")] 61 | pub roots: Option, 62 | #[serde(skip_serializing_if = "Option::is_none")] 63 | pub sampling: Option, 64 | } 65 | 66 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 67 | #[serde(rename_all = "camelCase")] 68 | #[serde(default)] 69 | pub struct RootCapabilities { 70 | pub list_changed: Option, 71 | } 72 | 73 | #[derive(Debug, Deserialize, Serialize, Clone)] 74 | pub struct Implementation { 75 | pub name: String, 76 | pub version: String, 77 | } 78 | 79 | #[derive(Debug, Deserialize, Serialize)] 80 | #[serde(rename_all = "camelCase")] 81 | pub struct InitializeResult { 82 | pub protocol_version: String, 83 | pub capabilities: ServerCapabilities, 84 | pub server_info: Implementation, 85 | #[serde(skip_serializing_if = "Option::is_none")] 86 | pub instructions: Option, 87 | } 88 | 89 | // --------- resource ------- 90 | 91 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 92 | pub struct ListResourcesRequest { 93 | pub cursor: Option, 94 | } 95 | 96 | #[derive(Debug, Deserialize, Serialize)] 97 | #[serde(rename_all = "camelCase")] 98 | pub struct ListResourcesResult { 99 | pub resources: Vec, 100 | #[serde(skip_serializing_if = "Option::is_none")] 101 | pub next_cursor: Option, 102 | } 103 | 104 | #[derive(Debug, Deserialize, Serialize)] 105 | #[serde(rename_all = "camelCase")] 106 | pub struct Resource { 107 | pub uri: Url, 108 | pub name: String, 109 | #[serde(skip_serializing_if = "Option::is_none")] 110 | pub description: Option, 111 | pub mime_type: Option, 112 | } 113 | 114 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 115 | pub struct ReadResourceRequest { 116 | pub uri: Url, 117 | #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] 118 | pub meta: Option, 119 | } 120 | 121 | #[derive(Debug, Deserialize, Serialize)] 122 | pub struct ReadResourceResult { 123 | pub contents: Vec, 124 | } 125 | 126 | #[derive(Debug, Deserialize, Serialize)] 127 | pub struct TextResourceContents { 128 | pub uri: Url, 129 | #[serde(skip_serializing_if = "Option::is_none")] 130 | pub mime_type: Option, 131 | pub text: String, 132 | } 133 | 134 | #[derive(Debug, Deserialize, Serialize)] 135 | #[serde(rename_all = "camelCase")] 136 | pub struct ResourceContent { 137 | pub uri: Url, // The URI of the resource 138 | #[serde(skip_serializing_if = "Option::is_none")] 139 | pub mime_type: Option, // Optional MIME type 140 | pub text: Option, // For text resources 141 | pub blob: Option, // For binary resources (base64 encoded) 142 | } 143 | 144 | // --------- prompt ------- 145 | #[derive(Debug, Deserialize, Serialize)] 146 | pub struct Prompt { 147 | pub name: String, 148 | #[serde(skip_serializing_if = "Option::is_none")] 149 | pub description: Option, 150 | #[serde(skip_serializing_if = "Option::is_none")] 151 | pub arguments: Option>, 152 | } 153 | 154 | #[derive(Debug, Deserialize, Serialize)] 155 | pub struct PromptArgument { 156 | pub name: String, 157 | #[serde(skip_serializing_if = "Option::is_none")] 158 | pub description: Option, 159 | #[serde(skip_serializing_if = "Option::is_none")] 160 | pub required: Option, 161 | } 162 | 163 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 164 | pub struct ListPromptsRequest { 165 | pub cursor: Option, 166 | } 167 | 168 | #[derive(Debug, Deserialize, Serialize)] 169 | #[serde(rename_all = "camelCase")] 170 | pub struct ListPromptsResult { 171 | pub prompts: Vec, 172 | #[serde(skip_serializing_if = "Option::is_none")] 173 | pub next_cursor: Option, 174 | } 175 | 176 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 177 | pub struct GetPromptRequest { 178 | pub name: String, 179 | #[serde(skip_serializing_if = "Option::is_none")] 180 | pub arguments: Option>, 181 | } 182 | 183 | #[derive(Debug, Deserialize, Serialize)] 184 | pub struct PromptResult { 185 | pub description: String, 186 | #[serde(skip_serializing_if = "Option::is_none")] 187 | pub messages: Option>, 188 | } 189 | 190 | #[derive(Debug, Deserialize, Serialize)] 191 | pub struct PromptMessage { 192 | pub role: String, 193 | pub content: PromptMessageContent, 194 | } 195 | 196 | #[derive(Debug, Deserialize, Serialize)] 197 | pub struct PromptMessageContent { 198 | #[serde(rename = "type")] 199 | pub type_name: String, 200 | pub text: String, 201 | } 202 | 203 | // --------- tool ------- 204 | 205 | #[derive(Deserialize, Serialize)] 206 | #[serde(rename_all = "camelCase")] 207 | pub struct Tool { 208 | pub name: String, 209 | #[serde(skip_serializing_if = "Option::is_none")] 210 | pub description: Option, 211 | pub input_schema: ToolInputSchema, 212 | } 213 | 214 | #[derive(Deserialize, Serialize)] 215 | pub struct ToolInputSchema { 216 | #[serde(rename = "type")] 217 | pub type_name: String, 218 | pub properties: HashMap, 219 | pub required: Vec, 220 | } 221 | 222 | #[derive(Deserialize, Serialize)] 223 | pub struct ToolInputSchemaProperty { 224 | #[serde(skip_serializing_if = "Option::is_none")] 225 | #[serde(rename = "type")] 226 | pub type_name: Option, 227 | #[serde(skip_serializing_if = "Option::is_none")] 228 | #[serde(rename = "enum")] 229 | pub enum_values: Option>, 230 | #[serde(skip_serializing_if = "Option::is_none")] 231 | pub description: Option, 232 | } 233 | 234 | #[derive(Deserialize, Serialize, RpcParams)] 235 | pub struct CallToolRequest { 236 | pub params: ToolCallRequestParams, 237 | #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] 238 | pub meta: Option, 239 | } 240 | 241 | #[derive(Deserialize, Serialize)] 242 | pub struct ToolCallRequestParams { 243 | pub name: String, 244 | #[serde(skip_serializing_if = "Option::is_none")] 245 | pub arguments: Option, 246 | } 247 | 248 | #[derive(Deserialize, Serialize, RpcParams)] 249 | #[serde(rename_all = "camelCase")] 250 | pub struct CallToolResult { 251 | pub content: Vec, 252 | pub is_error: bool, 253 | } 254 | 255 | #[derive(Debug, Serialize, Deserialize)] 256 | #[serde(tag = "type")] 257 | pub enum CallToolResultContent { 258 | #[serde(rename = "text")] 259 | Text { text: String }, 260 | #[serde(rename = "image")] 261 | Image { data: String, mime_type: String }, 262 | #[serde(rename = "resource")] 263 | Resource { resource: ResourceContent }, 264 | } 265 | 266 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 267 | pub struct ListToolsRequest { 268 | pub cursor: Option, 269 | } 270 | 271 | #[derive(Deserialize, Serialize)] 272 | #[serde(rename_all = "camelCase")] 273 | pub struct ListToolsResult { 274 | pub tools: Vec, 275 | #[serde(skip_serializing_if = "Option::is_none")] 276 | pub next_cursor: Option, 277 | } 278 | 279 | // ----- misc --- 280 | #[derive(Deserialize, Serialize)] 281 | pub struct EmptyResult {} 282 | 283 | #[derive(Deserialize, Serialize, RpcParams)] 284 | pub struct PingRequest {} 285 | 286 | #[derive(Deserialize, Serialize)] 287 | #[serde(rename_all = "camelCase")] 288 | pub struct CancelledNotification { 289 | pub request_id: String, 290 | pub reason: Option, 291 | } 292 | 293 | #[derive(Debug, Deserialize, Serialize)] 294 | #[serde(rename_all = "camelCase")] 295 | pub struct MetaParams { 296 | pub progress_token: String, 297 | } 298 | 299 | #[derive(Debug, Deserialize, Serialize)] 300 | #[serde(rename_all = "camelCase")] 301 | pub struct Progress { 302 | pub progress_token: String, 303 | pub progress: i32, 304 | pub total: i32, 305 | } 306 | 307 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 308 | pub struct SetLevelRequest { 309 | pub level: String, 310 | } 311 | 312 | #[derive(Debug, Deserialize, Serialize)] 313 | pub struct LoggingResponse {} 314 | 315 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 316 | pub struct LoggingMessageNotification { 317 | pub level: String, 318 | pub logger: String, 319 | pub data: Value, 320 | } 321 | 322 | #[derive(Debug, Deserialize, Serialize, RpcParams)] 323 | pub struct ListRootsRequest {} 324 | 325 | #[derive(Debug, Deserialize, Serialize)] 326 | pub struct ListRootsResult { 327 | pub roots: Vec, 328 | } 329 | 330 | #[derive(Debug, Deserialize, Serialize)] 331 | pub struct Root { 332 | pub name: String, 333 | pub url: String, 334 | } 335 | 336 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 337 | #[allow(dead_code)] 338 | pub enum ErrorCode { 339 | // MCP SDK error codes 340 | ConnectionClosed = -1, 341 | RequestTimeout = -2, 342 | // Standard JSON-RPC error codes 343 | ParseError = -32700, 344 | InvalidRequest = -32600, 345 | MethodNotFound = -32601, 346 | InvalidParams = -32602, 347 | InternalError = -32603, 348 | } 349 | 350 | // ----- json-rpc ----- 351 | #[derive(Debug, Deserialize, Serialize)] 352 | pub struct JsonRpcResponse { 353 | pub jsonrpc: String, 354 | pub id: Value, 355 | pub result: Value, 356 | } 357 | 358 | impl JsonRpcResponse { 359 | pub fn new(id: Value, result: Value) -> Self { 360 | JsonRpcResponse { 361 | jsonrpc: JSONRPC_VERSION.to_string(), 362 | id, 363 | result, 364 | } 365 | } 366 | } 367 | 368 | #[derive(Debug, Deserialize, Serialize)] 369 | pub struct JsonRpcNotification { 370 | pub jsonrpc: String, 371 | pub method: String, 372 | pub params: Value, 373 | } 374 | 375 | #[derive(Debug, Deserialize, Serialize)] 376 | pub struct JsonRpcError { 377 | pub jsonrpc: String, 378 | pub id: Value, 379 | pub error: Error, 380 | } 381 | 382 | #[derive(Debug, Deserialize, Serialize, Clone)] 383 | pub struct Error { 384 | pub code: i32, 385 | pub message: String, 386 | pub data: Option, 387 | } 388 | 389 | impl JsonRpcError { 390 | pub fn new(id: Value, code: i32, message: &str) -> Self { 391 | JsonRpcError { 392 | jsonrpc: JSONRPC_VERSION.to_string(), 393 | id, 394 | error: Error { 395 | code, 396 | message: message.to_string(), 397 | data: None, 398 | }, 399 | } 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /src/mcp/tools.rs: -------------------------------------------------------------------------------- 1 | use crate::mcp::types::*; 2 | use maplit::hashmap; 3 | use rpc_router::RouterBuilder; 4 | use rpc_router::HandlerResult; 5 | use rpc_router::Handler; 6 | use rpc_router::RpcParams; 7 | use serde::Deserialize; 8 | use serde::Serialize; 9 | use std::fs; 10 | use std::path::Path; 11 | use git2::{Repository, Signature}; 12 | use crate::mcp::utilities::{validate_path_or_error, validate_paths_or_error, is_path_allowed}; 13 | use chrono::Local; 14 | use serde_json::json; 15 | use crate::notify; 16 | 17 | /// register all tools to the router 18 | pub fn register_tools(router_builder: RouterBuilder) -> RouterBuilder { 19 | router_builder 20 | .append_dyn("tools/list", tools_list.into_dyn()) 21 | .append_dyn("get_current_time_in_city", current_time.into_dyn()) 22 | .append_dyn("get_local_time", get_local_time.into_dyn()) 23 | .append_dyn("file_edit", file_edit.into_dyn()) 24 | .append_dyn("read_file", read_file.into_dyn()) 25 | .append_dyn("list_directory", list_directory.into_dyn()) 26 | .append_dyn("move_or_rename", move_or_rename.into_dyn()) 27 | .append_dyn("get_file_info", get_file_info.into_dyn()) 28 | .append_dyn("create_directory", create_directory.into_dyn()) 29 | .append_dyn("overwrite_file", overwrite_file.into_dyn()) 30 | .append_dyn("grep_search", grep_search.into_dyn()) 31 | } 32 | 33 | pub async fn tools_list(_request: Option) -> HandlerResult { 34 | //let tools: Vec = serde_json::from_str(include_str!("./templates/tools.json")).unwrap(); 35 | let response = ListToolsResult { 36 | tools: vec![ 37 | Tool { 38 | name: "get_current_time_in_city".to_string(), 39 | description: Some("Get the current time in the city".to_string()), 40 | input_schema: ToolInputSchema { 41 | type_name: "object".to_string(), 42 | properties: hashmap! { 43 | "city".to_string() => ToolInputSchemaProperty { 44 | type_name: Some("string".to_owned()), 45 | description: Some("city name".to_owned()), 46 | enum_values: None, 47 | } 48 | }, 49 | required: vec!["city".to_string()], 50 | }, 51 | }, 52 | Tool { 53 | name: "get_local_time".to_string(), 54 | description: Some("Get the current local time".to_string()), 55 | input_schema: ToolInputSchema { 56 | type_name: "object".to_string(), 57 | properties: hashmap!{}, 58 | required: vec![], 59 | }, 60 | }, 61 | 62 | Tool { 63 | name: "file_edit".to_string(), 64 | description: Some("Replace exact text content in a file with optional git commit. Returns error if content not found or if there are multiple matches.".to_string()), 65 | input_schema: ToolInputSchema { 66 | type_name: "object".to_string(), 67 | properties: hashmap! { 68 | "file_path".to_string() => ToolInputSchemaProperty { 69 | type_name: Some("string".to_string()), 70 | description: Some("Path to the file to edit".to_string()), 71 | enum_values: None, 72 | }, 73 | "old_content".to_string() => ToolInputSchemaProperty { 74 | type_name: Some("string".to_string()), 75 | description: Some("Exact content to replace (must match uniquely)".to_string()), 76 | enum_values: None, 77 | }, 78 | "new_content".to_string() => ToolInputSchemaProperty { 79 | type_name: Some("string".to_string()), 80 | description: Some("Content to insert instead".to_string()), 81 | enum_values: None, 82 | }, 83 | "commit_message".to_string() => ToolInputSchemaProperty { 84 | type_name: Some("string".to_string()), 85 | description: Some("Message describing the purpose of this edit".to_string()), 86 | enum_values: None, 87 | } 88 | }, 89 | required: vec![ 90 | "file_path".to_string(), 91 | "old_content".to_string(), 92 | "new_content".to_string(), 93 | "commit_message".to_string() 94 | ], 95 | }, 96 | }, 97 | Tool { 98 | name: "read_file".to_string(), 99 | description: Some("Read the contents of a file".to_string()), 100 | input_schema: ToolInputSchema { 101 | type_name: "object".to_string(), 102 | properties: hashmap! { 103 | "file_path".to_string() => ToolInputSchemaProperty { 104 | type_name: Some("string".to_owned()), 105 | description: Some("Path to the file to read".to_owned()), 106 | enum_values: None, 107 | } 108 | }, 109 | required: vec!["file_path".to_string()], 110 | }, 111 | }, 112 | Tool { 113 | name: "list_directory".to_string(), 114 | description: Some("List contents of a directory".to_string()), 115 | input_schema: ToolInputSchema { 116 | type_name: "object".to_string(), 117 | properties: hashmap! { 118 | "path".to_string() => ToolInputSchemaProperty { 119 | type_name: Some("string".to_owned()), 120 | description: Some("Path to directory to list".to_owned()), 121 | enum_values: None, 122 | } 123 | }, 124 | required: vec!["path".to_string()], 125 | }, 126 | }, 127 | Tool { 128 | name: "move_or_rename".to_string(), 129 | description: Some("Move or rename a file or directory".to_string()), 130 | input_schema: ToolInputSchema { 131 | type_name: "object".to_string(), 132 | properties: hashmap! { 133 | "source_path".to_string() => ToolInputSchemaProperty { 134 | type_name: Some("string".to_owned()), 135 | description: Some("Source path to move/rename from".to_owned()), 136 | enum_values: None, 137 | }, 138 | "target_path".to_string() => ToolInputSchemaProperty { 139 | type_name: Some("string".to_owned()), 140 | description: Some("Target path to move/rename to".to_owned()), 141 | enum_values: None, 142 | }, 143 | "commit_message".to_string() => ToolInputSchemaProperty { 144 | type_name: Some("string".to_owned()), 145 | description: Some("Message describing the purpose of this move/rename".to_owned()), 146 | enum_values: None, 147 | } 148 | }, 149 | required: vec!["source_path".to_string(), "target_path".to_string(), "commit_message".to_string()], 150 | }, 151 | }, 152 | Tool { 153 | name: "get_file_info".to_string(), 154 | description: Some("Get metadata about a file".to_string()), 155 | input_schema: ToolInputSchema { 156 | type_name: "object".to_string(), 157 | properties: hashmap! { 158 | "path".to_string() => ToolInputSchemaProperty { 159 | type_name: Some("string".to_owned()), 160 | description: Some("Path to file to get info about".to_owned()), 161 | enum_values: None, 162 | } 163 | }, 164 | required: vec!["path".to_string()], 165 | }, 166 | }, 167 | Tool { 168 | name: "create_directory".to_string(), 169 | description: Some("Create a new directory".to_string()), 170 | input_schema: ToolInputSchema { 171 | type_name: "object".to_string(), 172 | properties: hashmap! { 173 | "path".to_string() => ToolInputSchemaProperty { 174 | type_name: Some("string".to_owned()), 175 | description: Some("Path to the new directory".to_owned()), 176 | enum_values: None, 177 | }, 178 | "commit_message".to_string() => ToolInputSchemaProperty { 179 | type_name: Some("string".to_owned()), 180 | description: Some("Message describing the purpose of this directory creation".to_owned()), 181 | enum_values: None, 182 | } 183 | }, 184 | required: vec!["path".to_string(), "commit_message".to_string()], 185 | }, 186 | }, 187 | Tool { 188 | name: "overwrite_file".to_string(), 189 | description: Some("Overwrite the contents of a file".to_string()), 190 | input_schema: ToolInputSchema { 191 | type_name: "object".to_string(), 192 | properties: hashmap! { 193 | "path".to_string() => ToolInputSchemaProperty { 194 | type_name: Some("string".to_owned()), 195 | description: Some("Path to the file to overwrite".to_owned()), 196 | enum_values: None, 197 | }, 198 | "content".to_string() => ToolInputSchemaProperty { 199 | type_name: Some("string".to_owned()), 200 | description: Some("New content to write".to_owned()), 201 | enum_values: None, 202 | } 203 | }, 204 | required: vec!["path".to_string(), "content".to_string()], 205 | }, 206 | }, 207 | Tool { 208 | name: "grep_search".to_string(), 209 | description: Some("Search for a pattern in files or directories. For recursive searches, the path must be a directory. For non-recursive searches, the path must exist.".to_string()), 210 | input_schema: ToolInputSchema { 211 | type_name: "object".to_string(), 212 | properties: hashmap! { 213 | "pattern".to_string() => ToolInputSchemaProperty { 214 | type_name: Some("string".to_owned()), 215 | description: Some("Pattern to search for".to_owned()), 216 | enum_values: None, 217 | }, 218 | "path".to_string() => ToolInputSchemaProperty { 219 | type_name: Some("string".to_owned()), 220 | description: Some("Path to search in. For recursive searches this must be a directory.".to_owned()), 221 | enum_values: None, 222 | }, 223 | "recursive".to_string() => ToolInputSchemaProperty { 224 | type_name: Some("boolean".to_owned()), 225 | description: Some("Whether to search recursively in subdirectories. Defaults to true.".to_owned()), 226 | enum_values: None, 227 | }, 228 | "case_sensitive".to_string() => ToolInputSchemaProperty { 229 | type_name: Some("boolean".to_owned()), 230 | description: Some("Whether the search should be case sensitive. Defaults to true.".to_owned()), 231 | enum_values: None, 232 | }, 233 | }, 234 | required: vec!["pattern".to_string(), "path".to_string()], 235 | }, 236 | } 237 | ], 238 | next_cursor: None, 239 | }; 240 | Ok(response) 241 | } 242 | 243 | #[derive(Deserialize, Serialize, RpcParams)] 244 | pub struct CurrentTimeRequest { 245 | pub city: Option, 246 | } 247 | 248 | pub async fn current_time(_request: CurrentTimeRequest) -> HandlerResult { 249 | let result = format!("Now: {}!", Local::now().to_rfc2822()); 250 | Ok(CallToolResult { 251 | content: vec![CallToolResultContent::Text { text: result }], 252 | is_error: false, 253 | }) 254 | } 255 | 256 | #[derive(Deserialize, Serialize, RpcParams)] 257 | pub struct GetLocalTimeRequest {} 258 | 259 | pub async fn get_local_time(_request: GetLocalTimeRequest) -> HandlerResult { 260 | let result = format!("Local time: {}", Local::now().to_rfc2822()); 261 | Ok(CallToolResult { 262 | content: vec![CallToolResultContent::Text { text: result }], 263 | is_error: false, 264 | }) 265 | } 266 | 267 | #[derive(Deserialize, Serialize, RpcParams)] 268 | pub struct FileEditRequest { 269 | pub file_path: String, 270 | pub old_content: String, 271 | pub new_content: String, 272 | pub commit_message: String, 273 | } 274 | 275 | pub async fn file_edit(request: FileEditRequest) -> HandlerResult { 276 | // Validate path is within allowed directories 277 | let path = Path::new(&request.file_path); 278 | if let Err(msg) = validate_path_or_error(path) { 279 | return Ok(CallToolResult { 280 | content: vec![CallToolResultContent::Text { text: msg }], 281 | is_error: true, 282 | }); 283 | } 284 | 285 | // Read the file 286 | let content = match fs::read_to_string(path) { 287 | Ok(content) => content, 288 | Err(e) => return Ok(CallToolResult { 289 | content: vec![CallToolResultContent::Text { 290 | text: format!("Error reading file: {}", e) 291 | }], 292 | is_error: true, 293 | }), 294 | }; 295 | 296 | // Before attempting to edit, use grep to check for uniqueness of the pattern 297 | // This will give better context in case of ambiguity and ensure the pattern is truly unique 298 | let pattern = &request.old_content; 299 | let file_path_str = path.to_str().unwrap_or_default(); 300 | 301 | // First check the number of matches using simple string matching 302 | let matches = content.matches(pattern).count(); 303 | if matches == 0 { 304 | return Ok(CallToolResult { 305 | content: vec![CallToolResultContent::Text { 306 | text: format!("Pattern not found in file: No matches for the specified content") 307 | }], 308 | is_error: true, 309 | }); 310 | } else if matches > 1 { 311 | // If there are multiple matches, use grep to show them with context 312 | let mut grep_cmd = std::process::Command::new("grep"); 313 | grep_cmd 314 | .arg("-n") // Show line numbers 315 | .arg("--color=never") // No color codes in output 316 | .arg("-F") // Fixed strings (literal match, not regex) 317 | .arg("-e") // Pattern follows 318 | .arg(pattern) 319 | .arg(file_path_str); 320 | 321 | match grep_cmd.output() { 322 | Ok(output) => { 323 | let grep_result = String::from_utf8_lossy(&output.stdout).to_string(); 324 | return Ok(CallToolResult { 325 | content: vec![CallToolResultContent::Text { 326 | text: format!("Found {} matches of content - must match exactly once. Here are the matches:\n{}", 327 | matches, grep_result) 328 | }], 329 | is_error: true, 330 | }); 331 | }, 332 | Err(_) => { 333 | // Fallback if grep fails 334 | return Ok(CallToolResult { 335 | content: vec![CallToolResultContent::Text { 336 | text: format!("Found {} matches of content - must match exactly once.", matches) 337 | }], 338 | is_error: true, 339 | }); 340 | } 341 | } 342 | } 343 | 344 | // Replace content 345 | let new_content = content.replace(&request.old_content, &request.new_content); 346 | 347 | // Write back to file 348 | if let Err(e) = fs::write(path, new_content) { 349 | return Ok(CallToolResult { 350 | content: vec![CallToolResultContent::Text { 351 | text: format!("Error writing file: {}", e) 352 | }], 353 | is_error: true, 354 | }); 355 | } 356 | 357 | // Handle git commit if requested 358 | let mut message = String::from("File edited successfully"); 359 | if let Some(repo_path) = find_git_repo(path) { 360 | match commit_to_git(&repo_path, path, &request.commit_message) { 361 | Ok(_) => message.push_str(". Changes committed to git"), 362 | Err(e) => message.push_str(&format!(". Git commit failed: {}", e)), 363 | } 364 | } 365 | 366 | Ok(CallToolResult { 367 | content: vec![CallToolResultContent::Text { text: message }], 368 | is_error: false, 369 | }) 370 | } 371 | 372 | #[derive(Deserialize, Serialize, RpcParams)] 373 | pub struct CreateDirectoryRequest { 374 | pub path: String, 375 | pub commit_message: String, 376 | } 377 | 378 | pub async fn create_directory(request: CreateDirectoryRequest) -> HandlerResult { 379 | let path = Path::new(&request.path); 380 | if let Err(msg) = validate_path_or_error(path) { 381 | return Ok(CallToolResult { 382 | content: vec![CallToolResultContent::Text { text: msg }], 383 | is_error: true, 384 | }); 385 | } 386 | 387 | match fs::create_dir_all(path) { 388 | Ok(_) => { 389 | let mut message = format!("Created directory: {}", path.display()); 390 | 391 | // Handle git commit if in a repo 392 | if let Some(repo_path) = find_git_repo(path) { 393 | match commit_to_git(&repo_path, path, &request.commit_message) { 394 | Ok(_) => message.push_str(". Changes committed to git"), 395 | Err(e) => message.push_str(&format!(". Git commit failed: {}", e)), 396 | } 397 | } 398 | 399 | Ok(CallToolResult { 400 | content: vec![CallToolResultContent::Text { text: message }], 401 | is_error: false, 402 | }) 403 | }, 404 | Err(e) => Ok(CallToolResult { 405 | content: vec![CallToolResultContent::Text { 406 | text: format!("Failed to create directory: {}", e) 407 | }], 408 | is_error: true, 409 | }), 410 | } 411 | } 412 | 413 | #[derive(Deserialize, Serialize, RpcParams)] 414 | pub struct OverwriteFileRequest { 415 | pub path: String, 416 | pub content: String, 417 | } 418 | 419 | pub async fn overwrite_file(request: OverwriteFileRequest) -> HandlerResult { 420 | let path = Path::new(&request.path); 421 | if let Err(msg) = validate_path_or_error(path) { 422 | return Ok(CallToolResult { 423 | content: vec![CallToolResultContent::Text { text: msg }], 424 | is_error: true, 425 | }); 426 | } 427 | 428 | match std::fs::write(path, &request.content) { 429 | Ok(_) => Ok(CallToolResult { 430 | content: vec![CallToolResultContent::Text { 431 | text: format!("File written successfully: {}", path.display()) 432 | }], 433 | is_error: false, 434 | }), 435 | Err(e) => Ok(CallToolResult { 436 | content: vec![CallToolResultContent::Text { 437 | text: format!("Failed to write file: {}", e) 438 | }], 439 | is_error: true, 440 | }), 441 | } 442 | } 443 | 444 | #[derive(Deserialize, Serialize, RpcParams)] 445 | pub struct ReadFileRequest { 446 | pub file_path: String, 447 | } 448 | 449 | pub async fn read_file(request: ReadFileRequest) -> HandlerResult { 450 | let path = Path::new(&request.file_path); 451 | if let Err(msg) = validate_path_or_error(path) { 452 | return Ok(CallToolResult { 453 | content: vec![CallToolResultContent::Text { text: msg }], 454 | is_error: true, 455 | }); 456 | } 457 | 458 | match fs::read_to_string(path) { 459 | Ok(content) => Ok(CallToolResult { 460 | content: vec![CallToolResultContent::Text { text: content }], 461 | is_error: false, 462 | }), 463 | Err(e) => Ok(CallToolResult { 464 | content: vec![CallToolResultContent::Text { 465 | text: format!("Error reading file: {}", e) 466 | }], 467 | is_error: true, 468 | }), 469 | } 470 | } 471 | 472 | #[derive(Deserialize, Serialize, RpcParams)] 473 | pub struct ListDirectoryRequest { 474 | pub path: String, 475 | } 476 | 477 | pub async fn list_directory(request: ListDirectoryRequest) -> HandlerResult { 478 | let path = Path::new(&request.path); 479 | if let Err(msg) = validate_path_or_error(path) { 480 | return Ok(CallToolResult { 481 | content: vec![CallToolResultContent::Text { text: msg }], 482 | is_error: true, 483 | }); 484 | } 485 | 486 | match fs::read_dir(path) { 487 | Ok(dir) => { 488 | let mut content = String::new(); 489 | for entry in dir { 490 | if let Ok(entry) = entry { 491 | // Also validate each entry is within allowed directories 492 | if is_path_allowed(&entry.path()) { 493 | content.push_str(&format!("{}\n", entry.file_name().to_string_lossy())); 494 | } 495 | } 496 | } 497 | Ok(CallToolResult { 498 | content: vec![CallToolResultContent::Text { text: content }], 499 | is_error: false, 500 | }) 501 | }, 502 | Err(e) => Ok(CallToolResult { 503 | content: vec![CallToolResultContent::Text { 504 | text: format!("Error listing directory: {}", e) 505 | }], 506 | is_error: true, 507 | }), 508 | } 509 | } 510 | 511 | #[derive(Deserialize, Serialize, RpcParams)] 512 | pub struct MoveOrRenameRequest { 513 | pub source_path: String, 514 | pub target_path: String, 515 | pub commit_message: String, 516 | } 517 | 518 | pub async fn move_or_rename(request: MoveOrRenameRequest) -> HandlerResult { 519 | let source_path = Path::new(&request.source_path); 520 | let target_path = Path::new(&request.target_path); 521 | 522 | if let Err(msg) = validate_paths_or_error(source_path, target_path) { 523 | return Ok(CallToolResult { 524 | content: vec![CallToolResultContent::Text { text: msg }], 525 | is_error: true, 526 | }); 527 | } 528 | 529 | match fs::rename(source_path, target_path) { 530 | Ok(_) => { 531 | let mut message = format!("Moved or renamed successfully: {} to {}", source_path.display(), target_path.display()); 532 | 533 | // Handle git commit if in a repo 534 | if let Some(repo_path) = find_git_repo(target_path) { 535 | match commit_to_git(&repo_path, target_path, &request.commit_message) { 536 | Ok(_) => message.push_str(". Changes committed to git"), 537 | Err(e) => message.push_str(&format!(". Git commit failed: {}", e)), 538 | } 539 | } 540 | 541 | Ok(CallToolResult { 542 | content: vec![CallToolResultContent::Text { text: message }], 543 | is_error: false, 544 | }) 545 | }, 546 | Err(e) => Ok(CallToolResult { 547 | content: vec![CallToolResultContent::Text { 548 | text: format!("Failed to move or rename: {}", e) 549 | }], 550 | is_error: true, 551 | }), 552 | } 553 | } 554 | 555 | #[derive(Deserialize, Serialize, RpcParams)] 556 | pub struct GetFileInfoRequest { 557 | pub path: String, 558 | } 559 | 560 | pub async fn get_file_info(request: GetFileInfoRequest) -> HandlerResult { 561 | let path = Path::new(&request.path); 562 | if let Err(msg) = validate_path_or_error(path) { 563 | return Ok(CallToolResult { 564 | content: vec![CallToolResultContent::Text { text: msg }], 565 | is_error: true, 566 | }); 567 | } 568 | 569 | match fs::metadata(path) { 570 | Ok(metadata) => { 571 | let mut content = String::new(); 572 | content.push_str(&format!("File size: {}\n", metadata.len())); 573 | content.push_str(&format!("File type: {:?}\n", metadata.file_type())); 574 | if let Ok(modified) = metadata.modified() { 575 | content.push_str(&format!("Last modified: {:?}\n", modified)); 576 | } 577 | Ok(CallToolResult { 578 | content: vec![CallToolResultContent::Text { text: content }], 579 | is_error: false, 580 | }) 581 | }, 582 | Err(e) => Ok(CallToolResult { 583 | content: vec![CallToolResultContent::Text { 584 | text: format!("Error getting file info: {}", e) 585 | }], 586 | is_error: true, 587 | }), 588 | } 589 | } 590 | 591 | #[derive(Deserialize, Serialize, RpcParams)] 592 | pub struct GrepSearchRequest { 593 | pub pattern: String, 594 | pub path: String, 595 | #[serde(default = "default_recursive", deserialize_with = "deserialize_bool_from_string_or_bool")] 596 | pub recursive: Option, 597 | #[serde(default = "default_case_sensitive", deserialize_with = "deserialize_bool_from_string_or_bool")] 598 | pub case_sensitive: Option, 599 | } 600 | 601 | fn default_recursive() -> Option { 602 | Some(true) 603 | } 604 | 605 | fn default_case_sensitive() -> Option { 606 | Some(true) 607 | } 608 | 609 | struct BoolOrStringVisitor; 610 | 611 | impl<'de> serde::de::Visitor<'de> for BoolOrStringVisitor { 612 | type Value = Option; 613 | 614 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 615 | formatter.write_str("a boolean or string representing a boolean") 616 | } 617 | 618 | fn visit_bool(self, value: bool) -> Result 619 | where 620 | E: serde::de::Error, 621 | { 622 | Ok(Some(value)) 623 | } 624 | 625 | fn visit_str(self, value: &str) -> Result 626 | where 627 | E: serde::de::Error, 628 | { 629 | match value.to_lowercase().as_str() { 630 | "true" | "1" | "yes" | "on" => Ok(Some(true)), 631 | "false" | "0" | "no" | "off" => Ok(Some(false)), 632 | other => Err(E::custom(format!( 633 | "Invalid boolean string value: {}", other 634 | ))), 635 | } 636 | } 637 | 638 | fn visit_string(self, value: String) -> Result 639 | where 640 | E: serde::de::Error, 641 | { 642 | self.visit_str(&value) 643 | } 644 | } 645 | 646 | fn deserialize_bool_from_string_or_bool<'de, D>(deserializer: D) -> Result, D::Error> 647 | where 648 | D: serde::Deserializer<'de>, 649 | { 650 | deserializer.deserialize_any(BoolOrStringVisitor) 651 | } 652 | 653 | pub async fn grep_search(request: GrepSearchRequest) -> HandlerResult { 654 | // First check if grep is available 655 | if let Err(_) = std::process::Command::new("grep").arg("--version").output() { 656 | notify("logging/message", Some(json!({ 657 | "message": "grep command not found on system", 658 | "level": "error" 659 | }))); 660 | return Ok(CallToolResult { 661 | content: vec![CallToolResultContent::Text { 662 | text: "grep command not found on system".to_string(), 663 | }], 664 | is_error: true, 665 | }); 666 | } 667 | 668 | let path = Path::new(&request.path); 669 | 670 | // Validate the search path is allowed 671 | if let Err(e) = validate_path_or_error(path) { 672 | notify("logging/message", Some(json!({ 673 | "message": format!("Path validation error: {}", e), 674 | "level": "error" 675 | }))); 676 | return Ok(CallToolResult { 677 | content: vec![CallToolResultContent::Text { 678 | text: e.to_string(), 679 | }], 680 | is_error: true, 681 | }); 682 | } 683 | 684 | // For recursive searches, the path must be a directory and must exist 685 | let recursive = request.recursive.unwrap_or(true); 686 | if recursive { 687 | if !path.is_dir() { 688 | notify("logging/message", Some(json!({ 689 | "message": "Path must be a directory for recursive search", 690 | "level": "error" 691 | }))); 692 | return Ok(CallToolResult { 693 | content: vec![CallToolResultContent::Text { 694 | text: "Path must be a directory for recursive search".to_string(), 695 | }], 696 | is_error: true, 697 | }); 698 | } 699 | } else if !path.exists() { 700 | notify("logging/message", Some(json!({ 701 | "message": "Path does not exist", 702 | "level": "error" 703 | }))); 704 | return Ok(CallToolResult { 705 | content: vec![CallToolResultContent::Text { 706 | text: "Path does not exist".to_string(), 707 | }], 708 | is_error: true, 709 | }); 710 | } 711 | 712 | let case_sensitive = request.case_sensitive.unwrap_or(true); 713 | 714 | let mut cmd = std::process::Command::new("grep"); 715 | cmd.arg("-n") // Show line numbers 716 | .arg("-H"); // Always show filename 717 | 718 | if !case_sensitive { 719 | cmd.arg("-i"); 720 | } 721 | 722 | if recursive { 723 | cmd.arg("-r"); 724 | } 725 | 726 | cmd.arg(&request.pattern) 727 | .arg(path); 728 | 729 | notify("logging/message", Some(json!({ 730 | "message": format!("Running grep command: {:?}", cmd), 731 | "level": "debug" 732 | }))); 733 | 734 | match cmd.output() { 735 | Ok(output) => { 736 | let stdout = String::from_utf8_lossy(&output.stdout); 737 | let stderr = String::from_utf8_lossy(&output.stderr); 738 | 739 | notify("logging/message", Some(json!({ 740 | "message": format!("grep stdout: {}", stdout), 741 | "level": "debug" 742 | }))); 743 | 744 | if !stderr.is_empty() { 745 | notify("logging/message", Some(json!({ 746 | "message": format!("grep stderr: {}", stderr), 747 | "level": "debug" 748 | }))); 749 | } 750 | 751 | if output.status.success() { 752 | Ok(CallToolResult { 753 | content: vec![CallToolResultContent::Text { 754 | text: stdout.into_owned(), 755 | }], 756 | is_error: false, 757 | }) 758 | } else { 759 | notify("logging/message", Some(json!({ 760 | "message": format!("grep error: {}", stderr), 761 | "level": "error" 762 | }))); 763 | Ok(CallToolResult { 764 | content: vec![CallToolResultContent::Text { 765 | text: format!("Grep error: {}", stderr), 766 | }], 767 | is_error: true, 768 | }) 769 | } 770 | } 771 | Err(e) => { 772 | notify("logging/message", Some(json!({ 773 | "message": format!("Failed to execute grep: {}", e), 774 | "level": "error" 775 | }))); 776 | Ok(CallToolResult { 777 | content: vec![CallToolResultContent::Text { 778 | text: format!("Failed to execute grep: {}", e), 779 | }], 780 | is_error: true, 781 | }) 782 | }, 783 | } 784 | } 785 | 786 | fn find_git_repo(path: &Path) -> Option { 787 | let mut current = path.to_path_buf(); 788 | while let Some(parent) = current.parent() { 789 | if parent.join(".git").exists() { 790 | return Some(parent.to_string_lossy().into_owned()); 791 | } 792 | current = parent.to_path_buf(); 793 | } 794 | None 795 | } 796 | 797 | fn commit_to_git(repo_path: &str, file_path: &Path, message: &str) -> Result<(), git2::Error> { 798 | let repo = Repository::open(repo_path)?; 799 | let mut index = repo.index()?; 800 | 801 | let relative_path = file_path.strip_prefix(repo_path) 802 | .unwrap_or(file_path) 803 | .to_string_lossy() 804 | .into_owned(); 805 | 806 | index.add_path(Path::new(&relative_path))?; 807 | index.write()?; 808 | 809 | let tree_id = index.write_tree()?; 810 | let tree = repo.find_tree(tree_id)?; 811 | 812 | let signature = Signature::now("MCP Server", "mcp@example.com")?; 813 | let parent = repo.head()?.peel_to_commit()?; 814 | 815 | repo.commit( 816 | Some("HEAD"), 817 | &signature, 818 | &signature, 819 | message, 820 | &tree, 821 | &[&parent] 822 | )?; 823 | 824 | Ok(()) 825 | } 826 | 827 | #[cfg(test)] 828 | mod tests { 829 | use super::*; 830 | use std::env; 831 | use std::fs; 832 | use tempfile::TempDir; 833 | use serde_json::json; 834 | use crate::mcp::utilities::notify; 835 | 836 | fn setup_test_env() -> (TempDir, String) { 837 | let _temp_dir = TempDir::new().unwrap(); 838 | let canonical_path = _temp_dir.path().canonicalize().unwrap(); 839 | let _temp_path = canonical_path.to_str().unwrap().to_string(); 840 | (_temp_dir, _temp_path) 841 | } 842 | 843 | fn setup_git_repo() -> (TempDir, String) { 844 | let (_temp_dir, _temp_path) = setup_test_env(); 845 | 846 | // Initialize git repo using the temp directory path 847 | let repo = git2::Repository::init(_temp_dir.path()).unwrap(); 848 | 849 | // Create test file in the temp directory 850 | let test_file_path = _temp_dir.path().join("test.txt"); 851 | fs::write(&test_file_path, "initial content\n").unwrap(); 852 | 853 | // Make initial commit 854 | let mut index = repo.index().unwrap(); 855 | index.add_path(Path::new("test.txt")).unwrap(); 856 | index.write().unwrap(); 857 | let tree_id = index.write_tree().unwrap(); 858 | let tree = repo.find_tree(tree_id).unwrap(); 859 | let signature = git2::Signature::now("Test User", "test@example.com").unwrap(); 860 | repo.commit(Some("HEAD"), &signature, &signature, "Initial commit", &tree, &[]).unwrap(); 861 | 862 | // Canonicalize the file path after creating it 863 | let canonical_file_path = test_file_path.canonicalize().unwrap(); 864 | (_temp_dir, canonical_file_path.to_str().unwrap().to_string()) 865 | } 866 | 867 | #[tokio::test] 868 | async fn test_file_edit_with_git() { 869 | let (_temp_dir, file_path) = setup_git_repo(); 870 | 871 | // Set up allowed directories 872 | #[cfg(target_os = "macos")] 873 | { 874 | let temp_path = _temp_dir.path().canonicalize().unwrap().to_str().unwrap().to_string(); 875 | let (var_path, private_var_path) = if temp_path.starts_with("/private/var") { 876 | (temp_path.strip_prefix("/private").unwrap().to_string(), temp_path.clone()) 877 | } else if temp_path.starts_with("/var") { 878 | (temp_path.clone(), format!("/private{}", temp_path)) 879 | } else { 880 | (temp_path.clone(), temp_path.clone()) 881 | }; 882 | 883 | notify("logging/message", Some(json!({ 884 | "message": format!("Var path: {}, Private var path: {}", var_path, private_var_path), 885 | "level": "debug" 886 | }))); 887 | 888 | let allowed_dirs = if var_path != private_var_path { 889 | format!("{}:{}", var_path, private_var_path) 890 | } else { 891 | var_path 892 | }; 893 | notify("logging/message", Some(json!({ 894 | "message": format!("Setting allowed directories: {}", allowed_dirs), 895 | "level": "debug" 896 | }))); 897 | env::set_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES", allowed_dirs); 898 | } 899 | 900 | #[cfg(not(target_os = "macos"))] 901 | { 902 | let temp_path = _temp_dir.path().canonicalize().unwrap().to_str().unwrap().to_string(); 903 | env::set_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES", &temp_path); 904 | } 905 | 906 | let request = FileEditRequest { 907 | file_path: file_path.clone(), 908 | old_content: "initial content\n".to_string(), 909 | new_content: "modified content".to_string(), 910 | commit_message: "Test commit".to_string(), 911 | }; 912 | 913 | let result = file_edit(request).await.unwrap(); 914 | if result.is_error { 915 | notify("logging/message", Some(json!({ 916 | "message": format!("File edit error: {:?}", result.content), 917 | "level": "error" 918 | }))); 919 | } 920 | assert!(!result.is_error, "file_edit failed: {:?}", result.content); 921 | 922 | // Verify file content 923 | let content = fs::read_to_string(file_path).unwrap(); 924 | assert_eq!(content, "modified content"); 925 | 926 | // Verify git commit happened 927 | let repo = git2::Repository::open(_temp_dir.path()).unwrap(); 928 | let head_commit = repo.head().unwrap().peel_to_commit().unwrap(); 929 | assert_eq!(head_commit.message().unwrap(), "Test commit"); 930 | } 931 | 932 | #[tokio::test] 933 | async fn test_file_edit_without_git() { 934 | let (_temp_dir, _temp_path) = setup_test_env(); 935 | 936 | // Set up allowed directories 937 | #[cfg(target_os = "macos")] 938 | { 939 | let temp_path = _temp_dir.path().canonicalize().unwrap().to_str().unwrap().to_string(); 940 | let (var_path, private_var_path) = if temp_path.starts_with("/private/var") { 941 | (temp_path.strip_prefix("/private").unwrap().to_string(), temp_path.clone()) 942 | } else if temp_path.starts_with("/var") { 943 | (temp_path.clone(), format!("/private{}", temp_path)) 944 | } else { 945 | (temp_path.clone(), temp_path.clone()) 946 | }; 947 | 948 | notify("logging/message", Some(json!({ 949 | "message": format!("Var path: {}, Private var path: {}", var_path, private_var_path), 950 | "level": "debug" 951 | }))); 952 | 953 | env::set_var( 954 | "MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES", 955 | format!("{}:{}", var_path, private_var_path) 956 | ); 957 | 958 | notify("logging/message", Some(json!({ 959 | "message": format!("Setting allowed directories: {}", format!("{}:{}", var_path, private_var_path)), 960 | "level": "debug" 961 | }))); 962 | } 963 | 964 | #[cfg(not(target_os = "macos"))] 965 | { 966 | let temp_path = _temp_dir.path().canonicalize().unwrap().to_str().unwrap().to_string(); 967 | env::set_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES", &temp_path); 968 | } 969 | 970 | // Create test file in the temp directory 971 | let test_file = _temp_dir.path().join("test.txt"); 972 | fs::write(&test_file, "initial content").unwrap(); 973 | 974 | notify("logging/message", Some(json!({ 975 | "message": format!("Test file path: {}", test_file.to_str().unwrap()), 976 | "level": "debug" 977 | }))); 978 | 979 | let canonical_file_path = test_file.canonicalize().unwrap(); 980 | 981 | notify("logging/message", Some(json!({ 982 | "message": format!("Canonical file path: {}", canonical_file_path.to_str().unwrap()), 983 | "level": "debug" 984 | }))); 985 | 986 | notify("logging/message", Some(json!({ 987 | "message": format!("Allowed directories: {}", env::var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES").unwrap_or_default()), 988 | "level": "debug" 989 | }))); 990 | 991 | let request = FileEditRequest { 992 | file_path: canonical_file_path.to_str().unwrap().to_string(), 993 | old_content: "initial content".to_string(), 994 | new_content: "modified content".to_string(), 995 | commit_message: "".to_string(), 996 | }; 997 | 998 | let result = file_edit(request).await.unwrap(); 999 | if result.is_error { 1000 | notify("logging/message", Some(json!({ 1001 | "message": format!("File edit error: {:?}", result.content), 1002 | "level": "error" 1003 | }))); 1004 | } 1005 | assert!(!result.is_error, "file_edit failed: {:?}", result.content); 1006 | 1007 | // Verify file content 1008 | let content = fs::read_to_string(&canonical_file_path).unwrap(); 1009 | assert_eq!(content, "modified content"); 1010 | 1011 | // Clean up 1012 | env::remove_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES"); 1013 | } 1014 | 1015 | #[tokio::test] 1016 | async fn test_grep_search() { 1017 | // First check if grep is available 1018 | if let Err(_) = std::process::Command::new("grep").arg("--version").output() { 1019 | notify("logging/message", Some(json!({ 1020 | "message": "Skipping grep_search test: grep command not available", 1021 | "level": "info" 1022 | }))); 1023 | return; 1024 | } 1025 | 1026 | let (temp_dir, temp_path) = setup_test_env(); 1027 | 1028 | notify("logging/message", Some(json!({ 1029 | "message": format!("Test directory: {}", temp_path), 1030 | "level": "debug" 1031 | }))); 1032 | 1033 | // Set up allowed directories 1034 | #[cfg(target_os = "macos")] 1035 | env::set_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES", &temp_path); 1036 | #[cfg(target_os = "linux")] 1037 | env::set_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES", &temp_path); 1038 | #[cfg(target_os = "windows")] 1039 | env::set_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES", &temp_path); 1040 | 1041 | notify("logging/message", Some(json!({ 1042 | "message": format!("Allowed directories: {}", env::var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES").unwrap_or_default()), 1043 | "level": "debug" 1044 | }))); 1045 | 1046 | // Create test files with specific content we can verify 1047 | let test1_content = "Hello World\nTEST_MARKER line\nAnother test"; 1048 | let test2_content = "Different content\nTEST_MARKER pattern here"; 1049 | let test3_content = "More test content\nTEST_MARKER final"; 1050 | 1051 | fs::write(temp_dir.path().join("test1.txt"), test1_content).unwrap(); 1052 | fs::write(temp_dir.path().join("test2.txt"), test2_content).unwrap(); 1053 | fs::create_dir(temp_dir.path().join("subdir")).unwrap(); 1054 | fs::write(temp_dir.path().join("subdir/test3.txt"), test3_content).unwrap(); 1055 | 1056 | // Test recursive search 1057 | let request = GrepSearchRequest { 1058 | pattern: "TEST_MARKER".to_string(), 1059 | path: temp_path.clone(), 1060 | recursive: Some(true), 1061 | case_sensitive: Some(true), 1062 | }; 1063 | 1064 | let result = grep_search(request).await.unwrap(); 1065 | if result.is_error { 1066 | if let CallToolResultContent::Text { text } = &result.content[0] { 1067 | notify("logging/message", Some(json!({ 1068 | "message": format!("Error content: {}", text), 1069 | "level": "error" 1070 | }))); 1071 | } 1072 | } 1073 | assert!(!result.is_error, "Grep search failed"); 1074 | assert_eq!(result.content.len(), 1); 1075 | if let CallToolResultContent::Text { text } = &result.content[0] { 1076 | notify("logging/message", Some(json!({ 1077 | "message": format!("Grep output: {}", text), 1078 | "level": "debug" 1079 | }))); 1080 | assert!(text.contains("test1.txt"), "Output should contain test1.txt"); 1081 | assert!(text.contains("test2.txt"), "Output should contain test2.txt"); 1082 | assert!(text.contains("test3.txt"), "Output should contain test3.txt"); 1083 | } 1084 | 1085 | // Clean up 1086 | env::remove_var("MCP_RS_FILESYSTEM_ALLOWED_DIRECTORIES"); 1087 | } 1088 | } 1089 | --------------------------------------------------------------------------------