├── .gitignore ├── README.md ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── devoxx │ │ └── mcp │ │ └── filesystem │ │ ├── McpServerApplication.java │ │ └── tools │ │ ├── AbstractToolService.java │ │ ├── BashService.java │ │ ├── CreateDirectoryService.java │ │ ├── EditFileService.java │ │ ├── FetchWebpageService.java │ │ ├── GrepFilesService.java │ │ ├── ListDirectoryService.java │ │ ├── ReadFileService.java │ │ ├── SearchFilesService.java │ │ └── WriteFileService.java └── resources │ ├── META-INF │ └── native-image │ │ ├── native-image.properties │ │ ├── reflect-config.json │ │ └── resource-config.json │ ├── application.properties │ └── mcp-servers-config.json └── test └── java └── com └── devoxx └── mcp └── filesystem ├── ClientSse.java ├── ClientStdio.java └── tools ├── BashServiceTest.java ├── CreateDirectoryServiceTest.java ├── EditFileServiceTest.java ├── FetchWebpageServiceTest.java ├── GrepFilesServiceTest.java ├── ListDirectoryServiceTest.java ├── ReadFileServiceTest.java ├── SearchFilesServiceTest.java └── WriteFileServiceTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /.idea/ 3 | /.vscode/ 4 | *.DS_Store 5 | .idea 6 | /.mvn/ 7 | .DS_Store 8 | .DS_Store? 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Java Filesystem & Web MCP Server 2 | 3 | This project implements a Model Context Protocol (MCP) server that provides filesystem operations and web access tools for Large Language Model (LLM) agents. It enables AI assistants to interact with both the local filesystem and web resources through a set of well-defined operations. 4 | 5 | ## Features 6 | 7 | The server provides the following operations: 8 | 9 | ### Filesystem Operations 10 | - **Reading Files**: Read the complete contents of a file with proper encoding detection 11 | - **Writing Files**: Create or overwrite files with new content 12 | - **Editing Files**: Make line-based edits with git-style diff generation 13 | - **Searching Files**: Recursively search for files and directories using glob patterns 14 | - **Listing Directories**: Get detailed listings of directory contents 15 | - **Directory Creation**: Create directories and nested directory structures 16 | - **Grep Files**: Search for text patterns within files with line numbers and context, similar to Unix grep command 17 | - **Bash Command**: Execute bash commands in the system shell and capture their output 18 | 19 | ### Web Operations 20 | - **Web Page Fetching**: Retrieve content from web pages with configurable timeouts 21 | - **HTML Content Extraction**: Extract text content from HTML documents 22 | 23 | These operations are exposed as tools for Large Language Models using the Model Context Protocol (MCP), allowing AI systems to safely interact with the filesystem and access web resources. 24 | 25 | ## Example of the MCP Java tool with [DevoxxGenie](https://github.com/devoxx/DevoxxGenieIDEAPlugin) 26 | Screenshot 2025-03-26 at 09 52 26 27 | 28 | ## Getting Started 29 | 30 | ### Prerequisites 31 | 32 | - Java 17 or higher 33 | - Maven 3.6+ 34 | - Spring Boot 3.3.6 35 | - Spring AI MCP Server components 36 | 37 | ### Building the Project 38 | 39 | Build the project using Maven: 40 | 41 | ```bash 42 | mvn clean package 43 | ``` 44 | 45 | ### Running the Server 46 | 47 | Run the server using the following command: 48 | 49 | ```bash 50 | java -jar target/devoxx-filesystem-0.0.1-SNAPSHOT.jar 51 | ``` 52 | 53 | The server uses STDIO for communication, so it doesn't expose any HTTP endpoints. It's designed to be launched by an MCP client. 54 | 55 | ## Tool Services 56 | 57 | ### Filesystem Tools 58 | 59 | #### ReadFileService 60 | 61 | ```java 62 | readFile(String fullPathFile) 63 | ``` 64 | Reads the complete contents of a file from the file system. Handles various text encodings and provides detailed error messages if the file cannot be read. 65 | 66 | #### WriteFileService 67 | 68 | ```java 69 | writeFile(String path, String content) 70 | ``` 71 | Creates a new file or completely overwrites an existing file with new content. Creates parent directories if they don't exist. 72 | 73 | #### EditFileService 74 | 75 | ```java 76 | editFile(String path, String edits, Boolean dryRun) 77 | ``` 78 | Makes line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. The `dryRun` parameter allows viewing changes without applying them. 79 | 80 | #### SearchFilesService 81 | 82 | ```java 83 | searchFiles(String path, String pattern) 84 | ``` 85 | Recursively searches for files and directories matching a pattern. Searches through all subdirectories from the starting path. The search is case-insensitive and matches partial names. 86 | 87 | #### ListDirectoryService 88 | 89 | ```java 90 | listDirectory(String path) 91 | ``` 92 | Gets a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with additional metadata. 93 | 94 | #### GrepFilesService 95 | 96 | ```java 97 | grepFiles(String directory, String pattern, String fileExtension, Boolean useRegex, Integer contextLines, Integer maxResults, Boolean ignoreCase) 98 | ``` 99 | Searches for text patterns within files. Returns matching files with line numbers and context. Similar to the Unix 'grep' command but with additional features for context display. Supports regex patterns, case-insensitive search, and context lines before/after matches. 100 | 101 | #### CreateDirectoryService 102 | 103 | ```java 104 | createDirectory(List directories) 105 | ``` 106 | Creates new directories or ensures that directories exist. Can create multiple directories in one operation. If a directory already exists, the operation succeeds silently. Perfect for setting up directory structures for projects or ensuring required paths exist. 107 | 108 | #### BashService 109 | 110 | ```java 111 | executeBash(String command, String workingDirectory, Integer timeoutSeconds) 112 | ``` 113 | Execute a Bash command in the system shell and return the output. This tool allows running system commands and capturing their standard output and error streams. Use with caution as some commands may have system-wide effects. 114 | 115 | ### Web Tools 116 | 117 | #### FetchWebpageService 118 | 119 | ```java 120 | fetchWebpage(String url, Integer timeoutMs) 121 | ``` 122 | Fetches or reads a webpage from a URL and returns its content. The service uses jsoup to connect to the webpage and retrieve its content. The optional `timeoutMs` parameter allows setting a custom connection timeout. 123 | 124 | ## Testing 125 | 126 | ### Running the Tests 127 | 128 | A comprehensive set of unit tests is provided for all service classes. Run them using: 129 | 130 | ```bash 131 | mvn test 132 | ``` 133 | 134 | The tests use JUnit 5 and Mockito for mocking external dependencies like the jsoup library for web requests. 135 | 136 | ### Test Client 137 | 138 | A test client is provided in `ClientStdio.java` which demonstrates how to invoke the tools using the MCP protocol. 139 | 140 | ## Configuration 141 | 142 | The application is configured via `application.properties`: 143 | 144 | ```properties 145 | spring.main.web-application-type=none 146 | spring.main.banner-mode=off 147 | logging.pattern.console= 148 | 149 | spring.ai.mcp.server.name=filesystem-server 150 | spring.ai.mcp.server.version=0.0.1 151 | 152 | logging.file.name=,/JavaFileSystemMCP/target/filesystem-server.log 153 | ``` 154 | 155 | ## Project Structure 156 | 157 | ``` 158 | JavaFileSystemMCP/ 159 | src/ 160 | main/ 161 | java/ 162 | com/ 163 | devoxx/ 164 | mcp/ 165 | filesystem/ 166 | tools/ 167 | EditFileService.java 168 | ReadFileService.java 169 | WriteFileService.java 170 | SearchFilesService.java 171 | FetchWebpageService.java 172 | ListDirectoryService.java 173 | CreateDirectoryService.java 174 | GrepFilesService.java 175 | BashService.java 176 | McpServerApplication.java 177 | resources/ 178 | application.properties 179 | test/ 180 | java/ 181 | com/ 182 | devoxx/ 183 | mcp/ 184 | filesystem/ 185 | tools/ 186 | ReadFileServiceTest.java 187 | WriteFileServiceTest.java 188 | EditFileServiceTest.java 189 | SearchFilesServiceTest.java 190 | FetchWebpageServiceTest.java 191 | ListDirectoryServiceTest.java 192 | CreateDirectoryServiceTest.java 193 | GrepFilesServiceTest.java 194 | ClientStdio.java 195 | pom.xml 196 | README.md 197 | ``` 198 | 199 | ## Dependencies 200 | 201 | The project uses: 202 | - Spring Boot 3.3.6 203 | - Spring AI MCP Server Components 204 | - Jackson for JSON processing 205 | - jsoup for HTML parsing and web content retrieval 206 | - JUnit 5 and Mockito for testing 207 | 208 | ## Implementation Notes 209 | 210 | - The server is designed to operate using the STDIO transport mechanism 211 | - Banner mode and console logging are disabled to allow the STDIO transport to work properly 212 | - Error handling provides detailed information about issues encountered during operations 213 | - Each tool service includes comprehensive error handling and returns results in a standardized JSON format 214 | - The `EditFileService` includes sophisticated diff generation for tracking changes 215 | - The `SearchFilesService` supports glob patterns for flexible file matching 216 | - The `FetchWebpageService` includes configurable timeouts and robust error handling for web requests 217 | 218 | ## Integration with DevoxxGenie MCP Support 219 | 220 | This server can be easily integrated with DevoxxGenie using the MCP (Model Context Protocol) support. Here's how to set it up: 221 | 222 | ### Configuration in DevoxxGenie 223 | 224 | 1. In DevoxxGenie, access the MCP Server configuration screen 225 | 2. Configure the server with the following settings: 226 | - **Name**: `JavaFilesystem` (or any descriptive name) 227 | - **Transport Type**: `STDIO` 228 | - **Command**: Full path to your Java executable (e.g., `/Library/Java/JavaVirtualMachines/liberica-jdk-23.jdk/Contents/Home/bin/java`) 229 | - **Arguments**: 230 | ``` 231 | -Dspring.ai.mcp.server.stdio=true 232 | -Dspring.main.web-application-type=none 233 | -Dlogging.pattern.console= 234 | -jar 235 | ~/JavaFileSystemMCP/target/devoxx-filesystem-0.0.1-SNAPSHOT.jar 236 | ``` 237 | 238 | Enter each argument on a new line. You may need to change the path for -jar to point to where you've built the jar. 239 | 240 | ### Usage with DevoxxGenie 241 | 242 | Once configured, DevoxxGenie will automatically discover the tools provided by this MCP server. The AI assistant can then use these tools to: 243 | 244 | 1. Read and write files on the local system 245 | 2. Search for files and directories 246 | 3. List directory contents 247 | 4. Make edits to existing files 248 | 5. Search for text patterns within files (grep) 249 | 6. Create directories and nested directory structures 250 | 7. Execute bash commands in the system shell 251 | 8. Fetch web pages and extract content 252 | 253 | All operations will be performed with the permissions of the user running the DevoxxGenie application. 254 | 255 | ### Using with Claude Desktop 256 | 257 | Edit your claude_desktop_config.json file with the following: 258 | 259 | ```json 260 | { 261 | "mcpServers": { 262 | "filesystem": { 263 | "command": "/Library/Java/JavaVirtualMachines/liberica-jdk-23.jdk/Contents/Home/bin/java", 264 | "args": [ 265 | "-Dspring.ai.mcp.server.stdio=true", 266 | "-Dspring.main.web-application-type=none", 267 | "-Dlogging.pattern.console=", 268 | "-jar", 269 | "~/JavaFileSystemMCP/target/devoxx-filesystem-0.0.1-SNAPSHOT.jar" 270 | ] 271 | } 272 | } 273 | } 274 | ``` 275 | 276 | You may need to change the path for -jar to point to where you've built the jar. 277 | 278 | image 279 | 280 | 281 | ## Security Considerations 282 | 283 | When using this server, be aware that: 284 | - The LLM agent will have access to read and write files on the host system 285 | - The agent can execute bash commands with the permissions of the user running the application 286 | - The agent can fetch content from any accessible web URL 287 | - Consider running the server with appropriate permissions and in a controlled environment 288 | - The server does not implement authentication or authorization mechanisms 289 | - Consider network firewall rules if restricting web access is required 290 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 3.3.6 10 | 11 | 12 | 13 | com.devoxx.mcp 14 | 15 | devoxx-filesystem 16 | 0.0.1-SNAPSHOT 17 | 18 | Devoxx FileSystem MCP STDIO server 19 | Get access to the filesystem using several related tools 20 | 21 | 22 | 23 | 24 | org.springframework.ai 25 | spring-ai-bom 26 | 1.0.0-SNAPSHOT 27 | pom 28 | import 29 | 30 | 31 | 32 | 33 | 34 | 35 | org.springframework.ai 36 | spring-ai-starter-mcp-server-webmvc 37 | 38 | 39 | 40 | org.springframework 41 | spring-web 42 | 43 | 44 | 45 | com.fasterxml.jackson.core 46 | jackson-databind 47 | 2.15.2 48 | 49 | 50 | 51 | 52 | org.jsoup 53 | jsoup 54 | 1.17.2 55 | 56 | 57 | 58 | nl.basjes.gitignore 59 | gitignore-reader 60 | 1.6.0 61 | 62 | 63 | 64 | 65 | org.junit.jupiter 66 | junit-jupiter 67 | test 68 | 69 | 70 | 71 | org.mockito 72 | mockito-junit-jupiter 73 | test 74 | 75 | 76 | 77 | org.mockito 78 | mockito-core 79 | test 80 | 81 | 82 | 83 | 84 | 85 | 86 | org.springframework.boot 87 | spring-boot-maven-plugin 88 | 89 | 90 | org.apache.maven.plugins 91 | maven-surefire-plugin 92 | 93 | 94 | 95 | 96 | 97 | 98 | Central Portal Snapshots 99 | central-portal-snapshots 100 | https://central.sonatype.com/repository/maven-snapshots/ 101 | 102 | false 103 | 104 | 105 | true 106 | 107 | 108 | 109 | spring-milestones 110 | Spring Milestones 111 | https://repo.spring.io/milestone 112 | 113 | false 114 | 115 | 116 | 117 | spring-snapshots 118 | Spring Snapshots 119 | https://repo.spring.io/snapshot 120 | 121 | false 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/McpServerApplication.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem; 2 | 3 | import com.devoxx.mcp.filesystem.tools.*; 4 | import org.springframework.ai.tool.ToolCallbackProvider; 5 | import org.springframework.ai.tool.method.MethodToolCallbackProvider; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.context.annotation.Bean; 9 | 10 | @SpringBootApplication 11 | public class McpServerApplication { 12 | 13 | public static void main(String[] args) { 14 | SpringApplication.run(McpServerApplication.class, args); 15 | } 16 | 17 | @Bean 18 | public ToolCallbackProvider mcpServices(SearchFilesService searchFilesService, 19 | ReadFileService readFileService, 20 | EditFileService editFileService, 21 | ListDirectoryService listDirectoryService, 22 | WriteFileService writeFileService, 23 | CreateDirectoryService createDirectoryService, 24 | GrepFilesService grepFilesService, 25 | FetchWebpageService fetchWebpageService, 26 | BashService bashService) { 27 | return MethodToolCallbackProvider.builder() 28 | .toolObjects(searchFilesService, 29 | listDirectoryService, 30 | editFileService, 31 | readFileService, 32 | writeFileService, 33 | createDirectoryService, 34 | grepFilesService, 35 | fetchWebpageService, 36 | bashService) 37 | .build(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/AbstractToolService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | 8 | public class AbstractToolService { 9 | 10 | protected static final String SUCCESS = "success"; 11 | protected static final String ERROR = "error"; 12 | 13 | protected final ObjectMapper mapper = new ObjectMapper(); 14 | 15 | protected String errorMessage(String errorMessage) { 16 | Map result = new HashMap<>(); 17 | result.put(SUCCESS, false); 18 | result.put(ERROR, errorMessage); 19 | try { 20 | return mapper.writeValueAsString(result); 21 | } catch (Exception ex) { 22 | return "{\"success\": false, \"error\": \"Failed to serialize error result\"}"; 23 | } 24 | } 25 | 26 | protected String successMessage(Map result) { 27 | result.put(SUCCESS, true); 28 | try { 29 | return mapper.writeValueAsString(result); 30 | } catch (Exception ex) { 31 | return "{\"success\": false, \"error\": \"Failed to serialize error result\"}"; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/BashService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.springframework.ai.tool.annotation.Tool; 4 | import org.springframework.ai.tool.annotation.ToolParam; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.io.BufferedReader; 8 | import java.io.IOException; 9 | import java.io.InputStreamReader; 10 | import java.util.*; 11 | import java.util.concurrent.TimeUnit; 12 | 13 | @Service 14 | public class BashService extends AbstractToolService { 15 | 16 | private static final int TIMEOUT_SECONDS = 30; 17 | private static final Set DISALLOWED_COMMANDS = 18 | new HashSet<>(List.of("rm", "rmdir", "mv", "del", "erase", "dd", "mkfs", "format")); 19 | 20 | @Tool(description = """ 21 | Execute a Bash command in the system shell and return the output. 22 | This tool allows running system commands and capturing their standard output and error streams. 23 | Use with caution as some commands may have system-wide effects. DO NOT USE REMOVE OR DELETE COMMANDS! 24 | """) 25 | public String executeBash(@ToolParam(description = "The Bash command to execute") String command, 26 | @ToolParam(description = "Optional working directory for the command execution", required = false) String workingDirectory, 27 | @ToolParam(description = "Maximum time in seconds to wait for the command to complete (default: 30)", required = false) Integer timeoutSeconds) { 28 | Map result = new HashMap<>(); 29 | 30 | try { 31 | // Validate input 32 | if (command == null || command.trim().isEmpty()) { 33 | return errorMessage("Command cannot be empty"); 34 | } 35 | 36 | String commandName = command.split(" ")[0]; 37 | if (DISALLOWED_COMMANDS.contains(commandName)) { 38 | return errorMessage("Command '" + commandName + "' is not allowed because it is potentially dangerous."); 39 | } 40 | 41 | // Set execution parameters 42 | ProcessBuilder processBuilder = new ProcessBuilder("/bin/bash", "-c", command); 43 | 44 | // Set working directory if provided 45 | if (workingDirectory != null && !workingDirectory.trim().isEmpty()) { 46 | processBuilder.directory(new java.io.File(workingDirectory)); 47 | } 48 | 49 | // Merge standard output and error 50 | processBuilder.redirectErrorStream(true); 51 | 52 | // Start the process 53 | Process process = processBuilder.start(); 54 | 55 | // Set timeout 56 | int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : TIMEOUT_SECONDS; 57 | boolean completed = process.waitFor(timeout, TimeUnit.SECONDS); 58 | 59 | // Read the output 60 | List outputLines = new ArrayList<>(); 61 | try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { 62 | String line; 63 | while ((line = reader.readLine()) != null) { 64 | outputLines.add(line); 65 | } 66 | } 67 | 68 | // Check if the process completed or timed out 69 | if (!completed) { 70 | process.destroyForcibly(); 71 | return errorMessage("Command execution timed out after " + timeout + " seconds"); 72 | } 73 | 74 | // Get exit code 75 | int exitCode = process.exitValue(); 76 | 77 | // Prepare the result 78 | result.put("command", command); 79 | result.put("exitCode", exitCode); 80 | result.put("output", String.join("\n", outputLines)); 81 | 82 | return successMessage(result); 83 | 84 | } catch (IOException e) { 85 | return errorMessage("IO error occurred: " + e.getMessage()); 86 | } catch (InterruptedException e) { 87 | Thread.currentThread().interrupt(); 88 | return errorMessage("Command execution was interrupted: " + e.getMessage()); 89 | } catch (Exception e) { 90 | return errorMessage("Unexpected error: " + e.getMessage()); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/CreateDirectoryService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.springframework.ai.tool.annotation.Tool; 4 | import org.springframework.ai.tool.annotation.ToolParam; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Files; 9 | import java.nio.file.Path; 10 | import java.nio.file.Paths; 11 | import java.util.HashMap; 12 | import java.util.Map; 13 | 14 | @Service 15 | public class CreateDirectoryService extends AbstractToolService { 16 | 17 | @Tool(description = """ 18 | Create a new directory or ensure a directory exists. 19 | Can create multiple directories in one go. 20 | If the directory already exists,this operation will succeed silently. 21 | Perfect for setting up directory structures for projects or ensuring required paths exist. 22 | """) 23 | public String createDirectory( 24 | @ToolParam(description = "The list of directories to create") String[] directories 25 | ) { 26 | Map result = new HashMap<>(); 27 | 28 | try { 29 | for (String directory : directories) { 30 | Path dirPath = Paths.get(directory); 31 | 32 | if (!Files.exists(dirPath)) { 33 | try { 34 | Files.createDirectories(dirPath); 35 | } catch (IOException e) { 36 | return errorMessage( "Failed to create directory: " + e.getMessage()); 37 | } 38 | } 39 | } 40 | return successMessage(result); 41 | } catch (Exception e) { 42 | return errorMessage("Unexpected error: " + e.getMessage()); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/EditFileService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.springframework.ai.tool.annotation.Tool; 4 | import org.springframework.ai.tool.annotation.ToolParam; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Files; 9 | import java.nio.file.Path; 10 | import java.nio.file.Paths; 11 | import java.util.ArrayList; 12 | import java.util.HashMap; 13 | import java.util.List; 14 | import java.util.Map; 15 | import java.util.regex.Pattern; 16 | import java.util.regex.PatternSyntaxException; 17 | 18 | @Service 19 | public class EditFileService extends AbstractToolService { 20 | 21 | public static final String OLD_TEXT = "oldText"; 22 | public static final String NEW_TEXT = "newText"; 23 | 24 | @Tool(description = """ 25 | Make line-based edits to a text file. Each edit replaces exact line sequences with new content. 26 | Returns a git-style diff showing the changes made. 27 | 28 | The edits parameter can be provided in three formats: 29 | 1. JSON object: {"oldText": "text to replace", "newText": "replacement text"} 30 | 2. JSON array: [{"oldText": "text1", "newText": "replacement1"}, {"oldText": "text2", "newText": "replacement2"}] 31 | 3. Simple format: "oldText----newText" where ---- is the separator 32 | """) 33 | public String editFile( 34 | @ToolParam(description = "The path to the file to edit") String path, 35 | @ToolParam(description = "List of edits to apply, each containing oldText and newText") String edits, 36 | @ToolParam(description = "If true, only show diff without changing the file", required = false) Boolean dryRun 37 | ) { 38 | Map result = new HashMap<>(); 39 | 40 | // Default dryRun to false if not provided 41 | boolean isDryRun = dryRun != null && dryRun; 42 | 43 | try { 44 | Path filePath = Paths.get(path); 45 | 46 | if (!Files.exists(filePath)) { 47 | result.put(SUCCESS, false); 48 | result.put(ERROR, "File does not exist: " + path); 49 | return mapper.writeValueAsString(result); 50 | } 51 | 52 | if (!Files.isRegularFile(filePath)) { 53 | result.put(SUCCESS, false); 54 | result.put(ERROR, "Path is not a regular file: " + path); 55 | return mapper.writeValueAsString(result); 56 | } 57 | 58 | // Read the original file content 59 | String originalContent = Files.readString(filePath); 60 | String newContent = originalContent; 61 | 62 | // Debug the received edits parameter 63 | result.put("debug_received_edits", edits); 64 | 65 | // Parse the edits 66 | List> editsList = parseEdits(edits, result); 67 | if (editsList == null) { 68 | return mapper.writeValueAsString(result); 69 | } 70 | 71 | // Apply each edit 72 | List appliedEdits = new ArrayList<>(); 73 | for (Map edit : editsList) { 74 | String oldText = edit.get(OLD_TEXT); 75 | String newText = edit.get(NEW_TEXT); 76 | boolean useRegex = Boolean.parseBoolean(edit.getOrDefault("useRegex", "false")); 77 | 78 | if (oldText == null || newText == null) { 79 | result.put(SUCCESS, false); 80 | result.put(ERROR, "Each edit must contain 'oldText' and 'newText'"); 81 | return mapper.writeValueAsString(result); 82 | } 83 | 84 | if (useRegex) { 85 | try { 86 | Pattern pattern = Pattern.compile(oldText, Pattern.DOTALL); 87 | String tempContent = pattern.matcher(newContent).replaceFirst(newText); 88 | 89 | // Only count it as applied if a change was made 90 | if (!tempContent.equals(newContent)) { 91 | newContent = tempContent; 92 | appliedEdits.add(oldText); 93 | } else { 94 | reportTextNotFound(oldText, originalContent, path, result); 95 | return mapper.writeValueAsString(result); 96 | } 97 | } catch (PatternSyntaxException e) { 98 | result.put(SUCCESS, false); 99 | result.put(ERROR, "Invalid regex pattern: " + e.getMessage()); 100 | return mapper.writeValueAsString(result); 101 | } 102 | } else { 103 | // Simple text replacement (normalize line endings) 104 | String normalizedContent = normalizeLineEndings(newContent); 105 | String normalizedOldText = normalizeLineEndings(oldText); 106 | 107 | if (!normalizedContent.contains(normalizedOldText)) { 108 | reportTextNotFound(oldText, originalContent, path, result); 109 | return mapper.writeValueAsString(result); 110 | } 111 | 112 | newContent = normalizedContent.replace(normalizedOldText, newText); 113 | appliedEdits.add(oldText); 114 | } 115 | } 116 | 117 | // Generate diff 118 | String diff = generateDiff(path, originalContent, newContent); 119 | 120 | // Write changes to file if not a dry run 121 | if (!isDryRun && !originalContent.equals(newContent)) { 122 | Files.writeString(filePath, newContent); 123 | } 124 | 125 | result.put(SUCCESS, true); 126 | result.put("diff", diff); 127 | result.put("dryRun", isDryRun); 128 | result.put("editsApplied", appliedEdits.size()); 129 | result.put("appliedEdits", appliedEdits); 130 | 131 | return mapper.writeValueAsString(result); 132 | 133 | } catch (IOException e) { 134 | return errorMessage("Failed to edit file: " + e.getMessage()); 135 | } catch (Exception e) { 136 | return errorMessage("Unexpected error: " + e.getMessage()); 137 | } 138 | } 139 | 140 | /** 141 | * Parse edits in various formats into a unified list. 142 | */ 143 | private List> parseEdits(String edits, Map result) { 144 | List> editsList = new ArrayList<>(); 145 | 146 | try { 147 | // Try to parse as a JSON array 148 | if (edits.startsWith("[")) { 149 | editsList = mapper.readValue(edits, new com.fasterxml.jackson.core.type.TypeReference>>() {}); 150 | } else if (edits.startsWith("{")) { 151 | // Parse as a single JSON object 152 | Map singleEdit = mapper.readValue(edits, new com.fasterxml.jackson.core.type.TypeReference>() {}); 153 | 154 | if (!singleEdit.containsKey(OLD_TEXT) || !singleEdit.containsKey(NEW_TEXT)) { 155 | result.put(SUCCESS, false); 156 | result.put(ERROR, "Edit must contain 'oldText' and 'newText' fields"); 157 | return null; 158 | } 159 | 160 | editsList.add(singleEdit); 161 | } else { 162 | // Try to parse as a simple format: "oldText----newText" 163 | result.put("debug_edits_parsing", "Attempting fallback parsing"); 164 | 165 | String[] parts = edits.split("----", 2); 166 | if (parts.length == 2) { 167 | Map singleEdit = new HashMap<>(); 168 | singleEdit.put(OLD_TEXT, parts[0]); 169 | singleEdit.put(NEW_TEXT, parts[1]); 170 | editsList.add(singleEdit); 171 | } else { 172 | result.put(SUCCESS, false); 173 | result.put(ERROR, "Could not parse edits. Expected JSON format or 'oldText----newText' format."); 174 | return List.of(); 175 | } 176 | } 177 | 178 | return editsList; 179 | } catch (Exception e) { 180 | result.put("success", false); 181 | result.put("error", "Failed to parse edits: " + e.getMessage()); 182 | result.put("debug_edits_error", e.toString()); 183 | return List.of(); 184 | } 185 | } 186 | 187 | /** 188 | * Report detailed information when text to replace is not found 189 | */ 190 | private void reportTextNotFound(String oldText, String originalContent, String path, Map result) { 191 | result.put("success", false); 192 | result.put("error", "Could not find text to replace: " + (oldText.length() > 50 ? oldText.substring(0, 47) + "..." : oldText)); 193 | 194 | // Add helpful debugging information 195 | if (originalContent.length() > 1000) { 196 | result.put("filePreview", originalContent.substring(0, 1000) + "..."); 197 | } else { 198 | result.put("filePreview", originalContent); 199 | } 200 | 201 | // Check for whitespace issues 202 | result.put("oldTextLength", oldText.length()); 203 | result.put("containsNewlines", oldText.contains("\n")); 204 | result.put("containsCarriageReturns", oldText.contains("\r")); 205 | result.put("containsSpaces", oldText.contains(" ")); 206 | result.put("containsTabs", oldText.contains("\t")); 207 | 208 | // Special checks for XML/POM files 209 | if (path.endsWith(".xml") || path.endsWith(".pom")) { 210 | String nameTag = ""; 211 | String nTag = ""; 212 | 213 | // Check for common Maven POM issues 214 | if (oldText.contains(nameTag) && originalContent.contains(nTag)) { 215 | String suggestion = "File uses tags instead of tags. Try replacing with in your search text."; 216 | result.put("suggestion", suggestion); 217 | } 218 | } 219 | 220 | // Check for partial matches 221 | if (oldText.length() > 30) { 222 | String searchSample = oldText.substring(0, Math.min(30, oldText.length())); 223 | int idx = originalContent.indexOf(searchSample); 224 | if (idx >= 0) { 225 | // Found a partial match, extract context 226 | int start = Math.max(0, idx - 20); 227 | int end = Math.min(originalContent.length(), idx + searchSample.length() + 50); 228 | result.put("partialMatch", "Found similar text: " + originalContent.substring(start, end)); 229 | } 230 | } 231 | } 232 | 233 | /** 234 | * Normalize line endings to make replacements more consistent 235 | */ 236 | private String normalizeLineEndings(String text) { 237 | // First convert all CRLF to LF 238 | String normalized = text.replace("\r\n", "\n"); 239 | // Then convert any remaining CR to LF (for Mac old format) 240 | return normalized.replace("\r", "\n"); 241 | } 242 | 243 | /** 244 | * Generate a git-style diff between original and modified content 245 | */ 246 | private String generateDiff(String filePath, String originalContent, String modifiedContent) { 247 | // Normalize line endings 248 | originalContent = normalizeLineEndings(originalContent); 249 | modifiedContent = normalizeLineEndings(modifiedContent); 250 | 251 | // Simple line-based diff implementation 252 | StringBuilder diff = new StringBuilder(); 253 | diff.append("--- ").append(filePath).append("\t(original)\n"); 254 | diff.append("+++ ").append(filePath).append("\t(modified)\n"); 255 | 256 | String[] originalLines = originalContent.split("\n"); 257 | String[] modifiedLines = modifiedContent.split("\n"); 258 | 259 | if (!originalContent.equals(modifiedContent)) { 260 | diff.append("@@ -1,").append(originalLines.length).append(" +1,").append(modifiedLines.length).append(" @@\n"); 261 | 262 | // Show removed lines (original content) 263 | for (String line : originalLines) { 264 | diff.append("-").append(line).append("\n"); 265 | } 266 | 267 | // Show added lines (modified content) 268 | for (String line : modifiedLines) { 269 | diff.append("+").append(line).append("\n"); 270 | } 271 | } else { 272 | diff.append("No changes\n"); 273 | } 274 | 275 | return diff.toString(); 276 | } 277 | } -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/FetchWebpageService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.jsoup.Jsoup; 4 | import org.jsoup.nodes.Document; 5 | import org.springframework.ai.tool.annotation.Tool; 6 | import org.springframework.ai.tool.annotation.ToolParam; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | 12 | @Service 13 | public class FetchWebpageService extends AbstractToolService { 14 | 15 | @Tool(description = """ 16 | Fetch or read a webpage from a URL and returns its HTML content. The service uses jsoup to connect to the webpage 17 | and retrieve its content. Options include retrieving just the text content, filtering by CSS 18 | selectors, and setting a timeout for the connection. 19 | """) 20 | public String fetchWebpage( 21 | @ToolParam(description = "The URL of the webpage to fetch or read") String url, 22 | @ToolParam(description = "Optional: Connection timeout in milliseconds (default: 10000)", required = false) Integer timeoutMs) { 23 | 24 | Map result = new HashMap<>(); 25 | 26 | try { 27 | // Set default timeout if not provided 28 | int timeout = (timeoutMs != null) ? timeoutMs : 10000; 29 | 30 | // Connect to the URL and get the document 31 | Document doc = Jsoup.connect(url) 32 | .timeout(timeout) 33 | .get(); 34 | 35 | result.put("url", url); 36 | result.put("content", doc.text()); 37 | result.put("title", doc.title()); 38 | return successMessage(result); 39 | 40 | } catch (Exception e) { 41 | return errorMessage("Failed to access url : " + url); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/GrepFilesService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.springframework.ai.tool.annotation.Tool; 4 | import org.springframework.ai.tool.annotation.ToolParam; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.io.File; 8 | import java.io.IOException; 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.nio.file.Paths; 12 | import java.util.ArrayList; 13 | import java.util.HashMap; 14 | import java.util.List; 15 | import java.util.Map; 16 | import java.util.regex.Matcher; 17 | import java.util.regex.Pattern; 18 | import java.util.stream.Stream; 19 | 20 | @Service 21 | public class GrepFilesService extends AbstractToolService { 22 | 23 | public static final int DEFAULT_MAX_RESULTS = 100; 24 | 25 | @Tool(description = """ 26 | Search for text patterns within files. Returns matching files with line numbers and snippets. 27 | Similar to the Unix 'grep' command with optimized output for LLM processing. 28 | """) 29 | public String grepFiles( 30 | @ToolParam(description = "The base directory to search in") String directory, 31 | @ToolParam(description = "The pattern to search for in file contents") String pattern, 32 | @ToolParam(description = "Optional file extension filter (e.g., '.java', '.txt')", required = false) String fileExtension, 33 | @ToolParam(description = "Whether to use regex for pattern matching", required = false) Boolean useRegex, 34 | @ToolParam(description = "Number of context lines to include before/after matches", required = false) Integer contextLines, 35 | @ToolParam(description = "Maximum number of results to return", required = false) Integer maxResults 36 | ) { 37 | Map result = new HashMap<>(); 38 | List matchingSummaries = new ArrayList<>(); 39 | int totalMatches = 0; 40 | int filesWithMatches = 0; 41 | 42 | // Default values 43 | boolean useRegexValue = useRegex != null && useRegex; 44 | int contextLinesValue = contextLines != null ? contextLines : 0; 45 | int maxResultsValue = maxResults != null ? maxResults : DEFAULT_MAX_RESULTS; 46 | 47 | try { 48 | Path basePath = Paths.get(directory); 49 | 50 | if (!Files.exists(basePath)) { 51 | result.put(SUCCESS, false); 52 | result.put(ERROR, "Directory does not exist: " + directory); 53 | return mapper.writeValueAsString(result); 54 | } 55 | 56 | if (!Files.isDirectory(basePath)) { 57 | result.put(SUCCESS, false); 58 | result.put(ERROR, "Path is not a directory: " + directory); 59 | return mapper.writeValueAsString(result); 60 | } 61 | 62 | // Prepare pattern for matching 63 | Pattern searchPattern; 64 | int flags = Pattern.CASE_INSENSITIVE; 65 | if (useRegexValue) { 66 | searchPattern = Pattern.compile(pattern, flags); 67 | } else { 68 | String escapedPattern = Pattern.quote(pattern); 69 | searchPattern = Pattern.compile(escapedPattern, flags); 70 | } 71 | 72 | // Initialize gitignore utility if needed 73 | // TODO GitignoreUtil gitignoreUtil = respectGitignoreValue ? new GitignoreUtil(directory) : null; 74 | 75 | // Find all files that match the extension filter and respect gitignore if enabled 76 | List filesToSearch = new ArrayList<>(); 77 | try (Stream paths = Files.walk(basePath)) { 78 | paths.filter(path -> !path.toString().contains(File.separator + "target" + File.separator) && 79 | Files.isRegularFile(path) && 80 | (fileExtension == null || fileExtension.isEmpty() || path.toString().endsWith(fileExtension))) 81 | .forEach(filesToSearch::add); 82 | } 83 | 84 | // Search each file for the pattern 85 | for (Path filePath : filesToSearch) { 86 | 87 | if (totalMatches >= maxResultsValue) { 88 | break; 89 | } 90 | 91 | try { 92 | List lines = Files.readAllLines(filePath); 93 | List fileMatches = new ArrayList<>(); 94 | 95 | for (int i = 0; i < lines.size(); i++) { 96 | String line = lines.get(i); 97 | Matcher matcher = searchPattern.matcher(line); 98 | 99 | if (matcher.find()) { 100 | StringBuilder matchInfo = new StringBuilder(); 101 | matchInfo.append(filePath.getFileName()).append(":").append(i + 1).append(": "); 102 | 103 | // Add concise context if requested 104 | if (contextLinesValue > 0) { 105 | matchInfo.append("\n"); 106 | 107 | // Context before 108 | int startLine = Math.max(0, i - contextLinesValue); 109 | if (startLine < i) { 110 | matchInfo.append("...\n"); 111 | } 112 | 113 | for (int j = startLine; j < i; j++) { 114 | matchInfo.append(" ").append(lines.get(j)).append("\n"); 115 | } 116 | 117 | // The matching line (highlighted) 118 | matchInfo.append("→ ").append(line).append("\n"); 119 | 120 | // Context after 121 | int endLine = Math.min(lines.size(), i + 1 + contextLinesValue); 122 | for (int j = i + 1; j < endLine; j++) { 123 | matchInfo.append(" ").append(lines.get(j)).append("\n"); 124 | } 125 | 126 | if (endLine < lines.size()) { 127 | matchInfo.append("..."); 128 | } 129 | } else { 130 | // Just the matching line without context 131 | matchInfo.append(line); 132 | } 133 | 134 | fileMatches.add(matchInfo.toString()); 135 | totalMatches++; 136 | 137 | if (totalMatches >= maxResultsValue) { 138 | break; 139 | } 140 | } 141 | } 142 | 143 | if (!fileMatches.isEmpty()) { 144 | matchingSummaries.add(String.format("%s (%d matches)", 145 | filePath.toString(), fileMatches.size())); 146 | 147 | // Limit the number of matches per file to keep response size manageable 148 | int maxMatchesPerFile = Math.min(fileMatches.size(), 5); 149 | for (int i = 0; i < maxMatchesPerFile; i++) { 150 | matchingSummaries.add(fileMatches.get(i)); 151 | } 152 | 153 | if (fileMatches.size() > maxMatchesPerFile) { 154 | matchingSummaries.add(String.format("... and %d more matches in this file", 155 | fileMatches.size() - maxMatchesPerFile)); 156 | } 157 | 158 | // Add a separator between files 159 | matchingSummaries.add("---"); 160 | filesWithMatches++; 161 | } 162 | } catch (IOException e) { 163 | // Skip files that can't be read (binary files, etc.) 164 | } 165 | } 166 | 167 | // Remove the last separator if it exists 168 | if (!matchingSummaries.isEmpty() && matchingSummaries.get(matchingSummaries.size() - 1).equals("---")) { 169 | matchingSummaries.remove(matchingSummaries.size() - 1); 170 | } 171 | 172 | // Create a summary with gitignore information if applicable 173 | String summaryText = String.format("Found %d matches in %d files", totalMatches, filesWithMatches); 174 | 175 | result.put("summary", summaryText); 176 | result.put("results", matchingSummaries); 177 | result.put("limitReached", totalMatches >= maxResultsValue); 178 | 179 | return successMessage(result); 180 | 181 | } catch (Exception e) { 182 | return errorMessage("Unexpected error: " + e.getMessage()); 183 | } 184 | } 185 | } -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/ListDirectoryService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.springframework.ai.tool.annotation.Tool; 5 | import org.springframework.ai.tool.annotation.ToolParam; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.io.IOException; 9 | import java.nio.file.DirectoryStream; 10 | import java.nio.file.Files; 11 | import java.nio.file.Path; 12 | import java.nio.file.Paths; 13 | import java.util.ArrayList; 14 | import java.util.HashMap; 15 | import java.util.List; 16 | import java.util.Map; 17 | 18 | @Service 19 | public class ListDirectoryService extends AbstractToolService { 20 | 21 | @Tool(description = """ 22 | Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with 23 | [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. 24 | """) 25 | public String listDirectory(@ToolParam(description = "The path to list contents of") String path) { 26 | Map result = new HashMap<>(); 27 | List> entries = new ArrayList<>(); 28 | 29 | try { 30 | Path dirPath = Paths.get(path); 31 | 32 | if (!Files.exists(dirPath)) { 33 | result.put(SUCCESS, false); 34 | result.put(ERROR, "Path does not exist: " + path); 35 | return mapper.writeValueAsString(result); 36 | } 37 | 38 | if (!Files.isDirectory(dirPath)) { 39 | result.put(SUCCESS, false); 40 | result.put(ERROR, "Path is not a directory: " + path); 41 | return mapper.writeValueAsString(result); 42 | } 43 | 44 | try (DirectoryStream stream = Files.newDirectoryStream(dirPath)) { 45 | for (Path entry : stream) { 46 | 47 | // Exclude hidden directories 48 | if (entry.getFileName().toString().startsWith(".")) { 49 | continue; 50 | } 51 | 52 | Map fileInfo = new HashMap<>(); 53 | boolean isDirectory = Files.isDirectory(entry); 54 | 55 | fileInfo.put("name", entry.getFileName().toString()); 56 | fileInfo.put("type", isDirectory ? "DIR" : "FILE"); 57 | fileInfo.put("path", entry.toString()); 58 | 59 | // Add size for files 60 | if (!isDirectory) { 61 | try { 62 | fileInfo.put("size", Files.size(entry)); 63 | } catch (IOException e) { 64 | fileInfo.put("size", "unknown"); 65 | } 66 | } 67 | 68 | // Add last modified time 69 | try { 70 | fileInfo.put("lastModified", Files.getLastModifiedTime(entry).toMillis()); 71 | } catch (IOException e) { 72 | fileInfo.put("lastModified", "unknown"); 73 | } 74 | 75 | entries.add(fileInfo); 76 | } 77 | } 78 | 79 | sortResults(entries); 80 | 81 | result.put("path", path); 82 | result.put("entries", entries); 83 | result.put("count", entries.size()); 84 | 85 | return successMessage(result); 86 | 87 | } catch (IOException e) { 88 | return errorMessage("Failed to list directory: " + e.getMessage()); 89 | } catch (Exception e) { 90 | return errorMessage("Unexpected error: " + e.getMessage()); 91 | } 92 | } 93 | 94 | private static void sortResults(List> entries) { 95 | // Sort entries: directories first, then files, both alphabetically 96 | entries.sort((a, b) -> { 97 | String typeA = (String) a.get("type"); 98 | String typeB = (String) b.get("type"); 99 | 100 | if (typeA.equals(typeB)) { 101 | // Same type, sort by name 102 | return ((String) a.get("name")).compareTo((String) b.get("name")); 103 | } else { 104 | // Different types, directories come first 105 | return typeA.equals("DIR") ? -1 : 1; 106 | } 107 | }); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/ReadFileService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.springframework.ai.tool.annotation.Tool; 4 | import org.springframework.ai.tool.annotation.ToolParam; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.Files; 9 | import java.nio.file.Path; 10 | import java.nio.file.Paths; 11 | import java.util.HashMap; 12 | import java.util.Map; 13 | 14 | @Service 15 | public class ReadFileService extends AbstractToolService { 16 | 17 | @Tool(description = """ 18 | Read the complete contents of a file from the file system. Handles various text encodings and provides detailed 19 | error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. 20 | """) 21 | public String readFile(@ToolParam(description = "The full path to the file") String fullPathFile) { 22 | Map result = new HashMap<>(); 23 | 24 | try { 25 | Path path = Paths.get(fullPathFile); 26 | 27 | if (!Files.exists(path)) { 28 | result.put(SUCCESS, false); 29 | result.put(ERROR, "File does not exist: " + fullPathFile); 30 | return mapper.writeValueAsString(result); 31 | } 32 | 33 | if (!Files.isRegularFile(path)) { 34 | result.put(SUCCESS, false); 35 | result.put(ERROR, "Path is not a regular file: " + fullPathFile); 36 | return mapper.writeValueAsString(result); 37 | } 38 | 39 | // Try to detect the file encoding (simplified here, uses default charset) 40 | String content = Files.readString(path); 41 | 42 | result.put("content", content); 43 | result.put("path", fullPathFile); 44 | result.put("size", Files.size(path)); 45 | 46 | return successMessage(result); 47 | 48 | } catch (IOException e) { 49 | return errorMessage("Failed to read file: " + e.getMessage()); 50 | } catch (Exception e) { 51 | return errorMessage("Unexpected error: " + e.getMessage()); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/SearchFilesService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.springframework.ai.tool.annotation.Tool; 4 | import org.springframework.ai.tool.annotation.ToolParam; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.io.IOException; 8 | import java.nio.file.*; 9 | import java.nio.file.attribute.BasicFileAttributes; 10 | import java.util.ArrayList; 11 | import java.util.HashMap; 12 | import java.util.List; 13 | import java.util.Map; 14 | import java.util.regex.PatternSyntaxException; 15 | 16 | @Service 17 | public class SearchFilesService extends AbstractToolService { 18 | 19 | @Tool(description = """ 20 | Recursively search for files and directories matching a pattern. Searches through all subdirectories from the 21 | starting path. The search is case-insensitive and matches partial names. Returns full paths to all matching 22 | items. Great for finding files when you don't know their exact location.""") 23 | public String searchFiles(@ToolParam(description = "The base path to search in") String path, 24 | @ToolParam(description = "The pattern to search for") String pattern) { 25 | 26 | Map result = new HashMap<>(); 27 | List matchingPaths = new ArrayList<>(); 28 | 29 | try { 30 | Path basePath = Paths.get(path); 31 | if (!Files.exists(basePath)) { 32 | result.put(SUCCESS, false); 33 | result.put(ERROR, "Path does not exist: " + path); 34 | return mapper.writeValueAsString(result); 35 | } 36 | 37 | // Create the PathMatcher with the provided pattern 38 | String normalizedPattern = pattern; 39 | // If it doesn't start with glob:, add it 40 | if (!normalizedPattern.startsWith("glob:")) { 41 | normalizedPattern = "glob:" + normalizedPattern; 42 | } 43 | 44 | final PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher(normalizedPattern); 45 | 46 | // Walk the file tree starting from the base path 47 | Files.walkFileTree(basePath, new SimpleFileVisitor<>() { 48 | @Override 49 | public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { 50 | Path relativePath = basePath.relativize(file); 51 | if (pathMatcher.matches(relativePath) || 52 | pathMatcher.matches(file.getFileName())) { 53 | matchingPaths.add(file.toString()); 54 | } 55 | return FileVisitResult.CONTINUE; 56 | } 57 | 58 | @Override 59 | public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { 60 | // Exclude hidden directories (starting with .) 61 | if (dir.getNameCount() > 0 && 62 | (dir.getName(dir.getNameCount() - 1).startsWith(".") || 63 | dir.getName(dir.getNameCount() - 1).startsWith("target"))) { 64 | return FileVisitResult.SKIP_SUBTREE; 65 | } 66 | 67 | Path relativePath = basePath.relativize(dir); 68 | if (pathMatcher.matches(relativePath) || 69 | pathMatcher.matches(dir.getFileName())) { 70 | matchingPaths.add(dir.toString()); 71 | } 72 | return FileVisitResult.CONTINUE; 73 | } 74 | }); 75 | 76 | // Return the matched paths as JSON 77 | 78 | result.put("matches", matchingPaths); 79 | result.put("count", matchingPaths.size()); 80 | 81 | return successMessage(result); 82 | 83 | } catch (PatternSyntaxException e) { 84 | return errorMessage("Invalid pattern syntax: " + e.getMessage()); 85 | 86 | } catch (IOException e) { 87 | return errorMessage("Failed to serialize error result"); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/devoxx/mcp/filesystem/tools/WriteFileService.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.springframework.ai.tool.annotation.Tool; 4 | import org.springframework.ai.tool.annotation.ToolParam; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.io.IOException; 8 | import java.nio.charset.StandardCharsets; 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.nio.file.Paths; 12 | import java.nio.file.StandardOpenOption; 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | 16 | @Service 17 | public class WriteFileService extends AbstractToolService { 18 | 19 | @Tool(description = """ 20 | Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. 21 | Handles text content with proper encoding. 22 | """) 23 | public String writeFile( 24 | @ToolParam(description = "The path to the file to create or overwrite") String path, 25 | @ToolParam(description = "The content to write to the file") String content 26 | ) { 27 | Map result = new HashMap<>(); 28 | 29 | try { 30 | Path filePath = Paths.get(path); 31 | 32 | // Create parent directories if they don't exist 33 | Path parent = filePath.getParent(); 34 | if (parent != null && !Files.exists(parent)) { 35 | Files.createDirectories(parent); 36 | result.put("createdDirectories", parent.toString()); 37 | } 38 | 39 | // Write the content to the file 40 | boolean fileExisted = Files.exists(filePath); 41 | Files.writeString(filePath, content, StandardCharsets.UTF_8, 42 | StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); 43 | 44 | result.put("path", path); 45 | result.put("bytesWritten", content.getBytes(StandardCharsets.UTF_8).length); 46 | result.put("action", fileExisted ? "overwritten" : "created"); 47 | 48 | return successMessage(result); 49 | 50 | } catch (IOException e) { 51 | return errorMessage("Failed to write file: " + e.getMessage()); 52 | } catch (Exception e) { 53 | return errorMessage("Failed to serialize error result"); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/native-image/native-image.properties: -------------------------------------------------------------------------------- 1 | Args = \ 2 | --no-fallback \ 3 | --enable-url-protocols=https \ 4 | --enable-http \ 5 | --enable-https \ 6 | --report-unsupported-elements-at-runtime \ 7 | --initialize-at-build-time=org.slf4j,ch.qos.logback \ 8 | -H:+ReportExceptionStackTraces 9 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/native-image/reflect-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "com.devoxx.mcp.filesystem.McpServerApplication", 4 | "allDeclaredConstructors": true, 5 | "allPublicConstructors": true, 6 | "allDeclaredMethods": true, 7 | "allPublicMethods": true 8 | }, 9 | { 10 | "name": "com.devoxx.mcp.filesystem.tools.EditFileService", 11 | "allDeclaredConstructors": true, 12 | "allPublicConstructors": true, 13 | "allDeclaredMethods": true, 14 | "allPublicMethods": true 15 | }, 16 | { 17 | "name": "com.devoxx.mcp.filesystem.tools.WriteFileService", 18 | "allDeclaredConstructors": true, 19 | "allPublicConstructors": true, 20 | "allDeclaredMethods": true, 21 | "allPublicMethods": true 22 | }, 23 | { 24 | "name": "com.devoxx.mcp.filesystem.tools.ReadFileService", 25 | "allDeclaredConstructors": true, 26 | "allPublicConstructors": true, 27 | "allDeclaredMethods": true, 28 | "allPublicMethods": true 29 | }, 30 | { 31 | "name": "com.devoxx.mcp.filesystem.tools.FetchWebpageService", 32 | "allDeclaredConstructors": true, 33 | "allPublicConstructors": true, 34 | "allDeclaredMethods": true, 35 | "allPublicMethods": true 36 | }, 37 | { 38 | "name": "com.devoxx.mcp.filesystem.tools.SearchFilesService", 39 | "allDeclaredConstructors": true, 40 | "allPublicConstructors": true, 41 | "allDeclaredMethods": true, 42 | "allPublicMethods": true 43 | }, 44 | { 45 | "name": "com.devoxx.mcp.filesystem.tools.ListDirectoryService", 46 | "allDeclaredConstructors": true, 47 | "allPublicConstructors": true, 48 | "allDeclaredMethods": true, 49 | "allPublicMethods": true 50 | } 51 | ] -------------------------------------------------------------------------------- /src/main/resources/META-INF/native-image/resource-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": { 3 | "includes": [ 4 | {"pattern": "\\QMETA-INF/services/.*\\E"}, 5 | {"pattern": "\\Qapplication.properties\\E"}, 6 | {"pattern": "\\Qmcp-servers-config.json\\E"} 7 | ] 8 | } 9 | } -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # spring.main.web-application-type=none 2 | 3 | # NOTE: You must disable the banner and the console logging 4 | # to allow the STDIO transport to work !!! 5 | spring.main.banner-mode=off 6 | logging.pattern.console= 7 | 8 | # spring.ai.mcp.server.stdio=true 9 | 10 | spring.ai.mcp.server.name=filesystem-server 11 | spring.ai.mcp.server.version=0.0.1 12 | 13 | logging.file.name=./target/filesystem-server.log 14 | 15 | -------------------------------------------------------------------------------- /src/main/resources/mcp-servers-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "devoxx-filesystem": { 4 | "command": "java", 5 | "args": [ 6 | "-Dspring.ai.mcp.server.stdio=true", 7 | "-Dspring.main.web-application-type=none", 8 | "-Dlogging.pattern.console=", 9 | "-jar", 10 | "/Users/stephan/IdeaProjects/JavaFileSystemMCP/target/target/devoxx-filesystem-0.0.1-SNAPSHOT.jar" 11 | ], 12 | "env": {} 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/ClientSse.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 - 2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.devoxx.mcp.filesystem; 17 | 18 | import io.modelcontextprotocol.client.McpClient; 19 | import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; 20 | import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; 21 | 22 | /** 23 | * @author Christian Tzolov 24 | */ 25 | public class ClientSse { 26 | 27 | public static void main(String[] args) { 28 | var transport = new HttpClientSseClientTransport("http://localhost:8080"); 29 | 30 | var client = McpClient.sync(transport).build(); 31 | 32 | client.initialize(); 33 | 34 | // List and demonstrate tools 35 | ListToolsResult toolsList = client.listTools(); 36 | System.out.println("Available Tools = " + toolsList); 37 | 38 | client.closeGracefully(); 39 | 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/ClientStdio.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem; 2 | 3 | import java.util.Map; 4 | 5 | import io.modelcontextprotocol.client.McpClient; 6 | import io.modelcontextprotocol.client.transport.ServerParameters; 7 | import io.modelcontextprotocol.client.transport.StdioClientTransport; 8 | import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; 9 | import io.modelcontextprotocol.spec.McpSchema.CallToolResult; 10 | import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; 11 | 12 | /** 13 | * With stdio transport, the MCP server is automatically started by the client. 14 | * But you 15 | * have to build the server jar first: 16 | * 17 | *
18 |  * ./mvnw clean install -DskipTests
19 |  * 
20 | */ 21 | public class ClientStdio { 22 | 23 | public static void main(String[] args) throws InterruptedException { 24 | 25 | var stdioParams = ServerParameters.builder("java") 26 | .args("-Dspring.ai.mcp.server.stdio=true", "-Dspring.main.web-application-type=none", 27 | "-Dlogging.pattern.console=", "-jar", 28 | "/Users/christiantzolov/Dev/projects/demo/MCPJavaFileSystem/target/devoxx-filesystem-0.0.1-SNAPSHOT.jar") 29 | .build(); 30 | 31 | var transport = new StdioClientTransport(stdioParams); 32 | var client = McpClient.sync(transport).build(); 33 | 34 | client.initialize(); 35 | 36 | // List and demonstrate tools 37 | ListToolsResult toolsList = client.listTools(); 38 | System.out.println("Available Tools = " + toolsList); 39 | 40 | client.closeGracefully(); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/BashServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.io.TempDir; 6 | 7 | import java.io.File; 8 | import java.io.IOException; 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | 12 | import static org.junit.jupiter.api.Assertions.*; 13 | 14 | class BashServiceTest { 15 | 16 | private BashService bashService; 17 | 18 | @TempDir 19 | Path tempDir; 20 | 21 | @BeforeEach 22 | void setUp() { 23 | bashService = new BashService(); 24 | } 25 | 26 | @Test 27 | void shouldExecuteSimpleCommand() { 28 | // When 29 | String result = bashService.executeBash("echo 'Hello World'", null, null); 30 | 31 | // Then 32 | assertTrue(result.contains("Hello World")); 33 | assertTrue(result.contains("\"exitCode\":0")); 34 | assertTrue(result.contains("\"success\":true")); 35 | } 36 | 37 | @Test 38 | void shouldHandleWorkingDirectory() throws IOException { 39 | // Given 40 | File testFile = new File(tempDir.toFile(), "test.txt"); 41 | Files.writeString(testFile.toPath(), "test content"); 42 | 43 | // When 44 | String result = bashService.executeBash("cat test.txt", tempDir.toString(), null); 45 | 46 | // Then 47 | assertTrue(result.contains("test content")); 48 | assertTrue(result.contains("\"exitCode\":0")); 49 | } 50 | 51 | @Test 52 | void shouldHandleCommandTimeout() { 53 | // When 54 | String result = bashService.executeBash("sleep 3", null, 1); 55 | 56 | // Then 57 | assertTrue(result.contains("timed out")); 58 | assertTrue(result.contains("\"success\":false")); 59 | } 60 | 61 | @Test 62 | void shouldHandleCommandFailure() { 63 | // When 64 | String result = bashService.executeBash("ls /nonexistent_directory", null, null); 65 | 66 | // Then 67 | assertTrue(result.contains("\"exitCode\":")); 68 | assertFalse(result.contains("\"exitCode\":0")); 69 | } 70 | 71 | @Test 72 | void shouldHandleEmptyCommand() { 73 | // When 74 | String result = bashService.executeBash("", null, null); 75 | 76 | // Then 77 | assertTrue(result.contains("\"success\":false")); 78 | assertTrue(result.contains("Command cannot be empty")); 79 | } 80 | 81 | @Test 82 | void shouldCaptureCommandOutput() { 83 | // When 84 | String result = bashService.executeBash("echo 'Line 1' && echo 'Line 2'", null, null); 85 | 86 | // Then 87 | assertTrue(result.contains("Line 1")); 88 | assertTrue(result.contains("Line 2")); 89 | assertTrue(result.contains("\"exitCode\":0")); 90 | } 91 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/CreateDirectoryServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.io.TempDir; 8 | 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | 12 | import static org.junit.jupiter.api.Assertions.*; 13 | 14 | class CreateDirectoryServiceTest { 15 | 16 | private CreateDirectoryService createDirectoryService; 17 | private ObjectMapper objectMapper; 18 | 19 | @TempDir 20 | Path tempDir; 21 | 22 | @BeforeEach 23 | void setUp() { 24 | createDirectoryService = new CreateDirectoryService(); 25 | objectMapper = new ObjectMapper(); 26 | } 27 | 28 | @Test 29 | void shouldSuccessfullyCreateSingleDirectory() throws Exception { 30 | // Given 31 | Path testDir = tempDir.resolve("test-directory"); 32 | String testDirPath = testDir.toString(); 33 | String[] directories = new String[] { testDirPath }; 34 | 35 | // When 36 | String result = createDirectoryService.createDirectory(directories); 37 | JsonNode jsonResult = objectMapper.readTree(result); 38 | 39 | // Then 40 | assertTrue(jsonResult.get("success").asBoolean()); 41 | assertTrue(Files.exists(testDir)); 42 | assertTrue(Files.isDirectory(testDir)); 43 | } 44 | 45 | @Test 46 | void shouldSuccessfullyCreateMultipleDirectories() throws Exception { 47 | // Given 48 | Path testDir1 = tempDir.resolve("test-dir-1"); 49 | Path testDir2 = tempDir.resolve("test-dir-2"); 50 | Path testDir3 = tempDir.resolve("test-dir-3"); 51 | 52 | String[] directories = new String[] { 53 | testDir1.toString(), 54 | testDir2.toString(), 55 | testDir3.toString() 56 | }; 57 | 58 | // When 59 | String result = createDirectoryService.createDirectory(directories); 60 | JsonNode jsonResult = objectMapper.readTree(result); 61 | 62 | // Then 63 | assertTrue(jsonResult.get("success").asBoolean()); 64 | assertTrue(Files.exists(testDir1)); 65 | assertTrue(Files.exists(testDir2)); 66 | assertTrue(Files.exists(testDir3)); 67 | assertTrue(Files.isDirectory(testDir1)); 68 | assertTrue(Files.isDirectory(testDir2)); 69 | assertTrue(Files.isDirectory(testDir3)); 70 | } 71 | 72 | @Test 73 | void shouldCreateNestedDirectoryStructure() throws Exception { 74 | // Given 75 | Path nestedDir = tempDir.resolve("parent/child/grandchild"); 76 | String[] directories = new String[] { nestedDir.toString() }; 77 | 78 | // When 79 | String result = createDirectoryService.createDirectory(directories); 80 | JsonNode jsonResult = objectMapper.readTree(result); 81 | 82 | // Then 83 | assertTrue(jsonResult.get("success").asBoolean()); 84 | assertTrue(Files.exists(nestedDir)); 85 | assertTrue(Files.isDirectory(nestedDir)); 86 | assertTrue(Files.exists(nestedDir.getParent())); 87 | assertTrue(Files.exists(nestedDir.getParent().getParent())); 88 | } 89 | 90 | @Test 91 | void shouldSucceedWhenDirectoryAlreadyExists() throws Exception { 92 | // Given 93 | Path existingDir = tempDir.resolve("existing-directory"); 94 | Files.createDirectory(existingDir); 95 | String[] directories = new String[] { existingDir.toString() }; 96 | 97 | // When 98 | String result = createDirectoryService.createDirectory(directories); 99 | JsonNode jsonResult = objectMapper.readTree(result); 100 | 101 | // Then 102 | assertTrue(jsonResult.get("success").asBoolean()); 103 | assertTrue(Files.exists(existingDir)); 104 | assertTrue(Files.isDirectory(existingDir)); 105 | } 106 | 107 | @Test 108 | void shouldHandleEmptyDirectoryList() throws Exception { 109 | // Given 110 | String[] directories = new String[0]; 111 | 112 | // When 113 | String result = createDirectoryService.createDirectory(directories); 114 | JsonNode jsonResult = objectMapper.readTree(result); 115 | 116 | // Then 117 | assertTrue(jsonResult.get("success").asBoolean()); 118 | } 119 | 120 | @Test 121 | void shouldHandleSpecialCharactersInDirectoryName() throws Exception { 122 | // Given 123 | Path specialNameDir = tempDir.resolve("special_char-dir-ñäöü"); 124 | String[] directories = new String[] { specialNameDir.toString() }; 125 | 126 | // When 127 | String result = createDirectoryService.createDirectory(directories); 128 | JsonNode jsonResult = objectMapper.readTree(result); 129 | 130 | // Then 131 | assertTrue(jsonResult.get("success").asBoolean()); 132 | assertTrue(Files.exists(specialNameDir)); 133 | assertTrue(Files.isDirectory(specialNameDir)); 134 | } 135 | 136 | @Test 137 | void shouldHandlePathsWithSpaces() throws Exception { 138 | // Given 139 | Path dirWithSpaces = tempDir.resolve("dir with spaces in name"); 140 | String[] directories = new String[] { dirWithSpaces.toString() }; 141 | 142 | // When 143 | String result = createDirectoryService.createDirectory(directories); 144 | JsonNode jsonResult = objectMapper.readTree(result); 145 | 146 | // Then 147 | assertTrue(jsonResult.get("success").asBoolean()); 148 | assertTrue(Files.exists(dirWithSpaces)); 149 | assertTrue(Files.isDirectory(dirWithSpaces)); 150 | } 151 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/EditFileServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.Nested; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.io.TempDir; 10 | 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | 14 | import static org.junit.jupiter.api.Assertions.*; 15 | 16 | class EditFileServiceTest { 17 | 18 | private EditFileService editFileService; 19 | private ObjectMapper objectMapper; 20 | 21 | @TempDir 22 | Path tempDir; 23 | 24 | @BeforeEach 25 | void setUp() { 26 | editFileService = new EditFileService(); 27 | objectMapper = new ObjectMapper(); 28 | } 29 | 30 | @Nested 31 | @DisplayName("Basic Functionality Tests") 32 | class BasicFunctionalityTests { 33 | @Test 34 | void shouldSuccessfullyApplySingleEdit() throws Exception { 35 | // Given 36 | String initialContent = "Line 1\nLine 2\nLine 3\nLine 4"; 37 | Path testFile = tempDir.resolve("test-edit.txt"); 38 | Files.writeString(testFile, initialContent); 39 | 40 | String edits = "[{\"oldText\":\"Line 2\",\"newText\":\"Modified Line 2\"}]"; 41 | 42 | // When 43 | String result = editFileService.editFile(testFile.toString(), edits, false); 44 | JsonNode jsonResult = objectMapper.readTree(result); 45 | 46 | // Then 47 | assertTrue(jsonResult.get("success").asBoolean()); 48 | assertEquals(1, jsonResult.get("editsApplied").asInt()); 49 | assertFalse(jsonResult.get("dryRun").asBoolean()); 50 | 51 | // Verify file was actually modified 52 | String expectedContent = "Line 1\nModified Line 2\nLine 3\nLine 4"; 53 | assertEquals(expectedContent, Files.readString(testFile)); 54 | } 55 | 56 | @Test 57 | void shouldSuccessfullyApplyMultipleEdits() throws Exception { 58 | // Given 59 | String initialContent = "Line 1\nLine 2\nLine 3\nLine 4"; 60 | Path testFile = tempDir.resolve("test-multiple-edits.txt"); 61 | Files.writeString(testFile, initialContent); 62 | 63 | String edits = "[" + 64 | "{\"oldText\":\"Line 1\",\"newText\":\"Modified Line 1\"}," + 65 | "{\"oldText\":\"Line 3\",\"newText\":\"Modified Line 3\"}" + 66 | "]"; 67 | 68 | // When 69 | String result = editFileService.editFile(testFile.toString(), edits, false); 70 | JsonNode jsonResult = objectMapper.readTree(result); 71 | 72 | // Then 73 | assertTrue(jsonResult.get("success").asBoolean()); 74 | assertEquals(2, jsonResult.get("editsApplied").asInt()); 75 | 76 | // Verify file was actually modified with both edits 77 | String expectedContent = "Modified Line 1\nLine 2\nModified Line 3\nLine 4"; 78 | assertEquals(expectedContent, Files.readString(testFile)); 79 | } 80 | 81 | @Test 82 | void shouldHandleDryRunMode() throws Exception { 83 | // Given 84 | String initialContent = "Line 1\nLine 2\nLine 3"; 85 | Path testFile = tempDir.resolve("test-dry-run.txt"); 86 | Files.writeString(testFile, initialContent); 87 | 88 | String edits = "[{\"oldText\":\"Line 2\",\"newText\":\"Should Not Change\"}]"; 89 | 90 | // When 91 | String result = editFileService.editFile(testFile.toString(), edits, true); 92 | JsonNode jsonResult = objectMapper.readTree(result); 93 | 94 | // Then 95 | assertTrue(jsonResult.get("success").asBoolean()); 96 | assertTrue(jsonResult.get("dryRun").asBoolean()); 97 | assertTrue(jsonResult.has("diff")); 98 | 99 | // Verify file was NOT modified since this was a dry run 100 | assertEquals(initialContent, Files.readString(testFile)); 101 | } 102 | 103 | @Test 104 | void shouldHandleNonExistentFile() throws Exception { 105 | // Given 106 | Path nonExistentFile = tempDir.resolve("non-existent.txt"); 107 | String edits = "[{\"oldText\":\"Any Text\",\"newText\":\"New Text\"}]"; 108 | 109 | // When 110 | String result = editFileService.editFile(nonExistentFile.toString(), edits, false); 111 | JsonNode jsonResult = objectMapper.readTree(result); 112 | 113 | // Then 114 | assertFalse(jsonResult.get("success").asBoolean()); 115 | assertTrue(jsonResult.get("error").asText().contains("File does not exist")); 116 | } 117 | 118 | @Test 119 | void shouldHandleNonMatchingText() throws Exception { 120 | // Given 121 | String initialContent = "Line 1\nLine 2\nLine 3"; 122 | Path testFile = tempDir.resolve("non-matching.txt"); 123 | Files.writeString(testFile, initialContent); 124 | 125 | String edits = "[{\"oldText\":\"This text does not exist\",\"newText\":\"Replacement\"}]"; 126 | 127 | // When 128 | String result = editFileService.editFile(testFile.toString(), edits, false); 129 | JsonNode jsonResult = objectMapper.readTree(result); 130 | 131 | // Then 132 | assertFalse(jsonResult.get("success").asBoolean()); 133 | assertTrue(jsonResult.get("error").asText().contains("Could not find text to replace")); 134 | 135 | // Verify file was not modified 136 | assertEquals(initialContent, Files.readString(testFile)); 137 | } 138 | 139 | @Test 140 | void shouldHandleMultilineTextReplacement() throws Exception { 141 | // Given 142 | String initialContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; 143 | Path testFile = tempDir.resolve("multiline.txt"); 144 | Files.writeString(testFile, initialContent); 145 | 146 | String edits = "[{\"oldText\":\"Line 2\\nLine 3\\nLine 4\",\"newText\":\"Replaced multiple lines\"}]"; 147 | 148 | // When 149 | String result = editFileService.editFile(testFile.toString(), edits, false); 150 | JsonNode jsonResult = objectMapper.readTree(result); 151 | 152 | // Then 153 | assertTrue(jsonResult.get("success").asBoolean()); 154 | 155 | // Verify multiline replacement worked 156 | String expectedContent = "Line 1\nReplaced multiple lines\nLine 5"; 157 | assertEquals(expectedContent, Files.readString(testFile)); 158 | } 159 | } 160 | 161 | @Nested 162 | @DisplayName("Input Format Tests") 163 | class InputFormatTests { 164 | 165 | @Test 166 | void shouldHandleSingleJsonObjectFormat() throws Exception { 167 | // Given 168 | String initialContent = "Test single object format"; 169 | Path testFile = tempDir.resolve("single-object.txt"); 170 | Files.writeString(testFile, initialContent); 171 | 172 | // Single JSON object format 173 | String edits = "{\"oldText\":\"single object\",\"newText\":\"JSON object\"}"; 174 | 175 | // When 176 | String result = editFileService.editFile(testFile.toString(), edits, false); 177 | JsonNode jsonResult = objectMapper.readTree(result); 178 | 179 | // Then 180 | assertTrue(jsonResult.get("success").asBoolean()); 181 | 182 | // Verify replacement worked 183 | String expectedContent = "Test JSON object format"; 184 | assertEquals(expectedContent, Files.readString(testFile)); 185 | } 186 | 187 | @Test 188 | void shouldHandleJsonArrayFormat() throws Exception { 189 | // Given 190 | String initialContent = "First text and second text"; 191 | Path testFile = tempDir.resolve("json-array.txt"); 192 | Files.writeString(testFile, initialContent); 193 | 194 | // JSON array format 195 | String edits = "[{\"oldText\":\"First\",\"newText\":\"1st\"},{\"oldText\":\"second\",\"newText\":\"2nd\"}]"; 196 | 197 | // When 198 | String result = editFileService.editFile(testFile.toString(), edits, false); 199 | JsonNode jsonResult = objectMapper.readTree(result); 200 | 201 | // Then 202 | assertTrue(jsonResult.get("success").asBoolean()); 203 | assertEquals(2, jsonResult.get("editsApplied").asInt()); 204 | 205 | // Verify both replacements worked 206 | String expectedContent = "1st text and 2nd text"; 207 | assertEquals(expectedContent, Files.readString(testFile)); 208 | } 209 | 210 | @Test 211 | void shouldHandleSimpleTextFormat() throws Exception { 212 | // Given 213 | String initialContent = "Simple format test"; 214 | Path testFile = tempDir.resolve("simple-format.txt"); 215 | Files.writeString(testFile, initialContent); 216 | 217 | // Simple text format with ---- separator 218 | String edits = "Simple format----Better format"; 219 | 220 | // When 221 | String result = editFileService.editFile(testFile.toString(), edits, false); 222 | JsonNode jsonResult = objectMapper.readTree(result); 223 | 224 | // Then 225 | assertTrue(jsonResult.get("success").asBoolean()); 226 | 227 | // Verify replacement worked 228 | String expectedContent = "Better format test"; 229 | assertEquals(expectedContent, Files.readString(testFile)); 230 | } 231 | 232 | // TODO 233 | void shouldHandleMalformedEditsJson() throws Exception { 234 | // Given 235 | String initialContent = "Test content"; 236 | Path testFile = tempDir.resolve("malformed-json.txt"); 237 | Files.writeString(testFile, initialContent); 238 | 239 | String edits = "This is not valid JSON or Simple format"; 240 | 241 | // When 242 | String result = editFileService.editFile(testFile.toString(), edits, false); 243 | JsonNode jsonResult = objectMapper.readTree(result); 244 | 245 | // Then 246 | assertFalse(jsonResult.get("success").asBoolean()); 247 | assertEquals("This is not valid JSON or Simple format", jsonResult.get("debug_received_edits").asText()); 248 | 249 | // Verify file was not modified 250 | assertEquals(initialContent, Files.readString(testFile)); 251 | } 252 | 253 | @Test 254 | void shouldHandleIncompleteEditsObject() throws Exception { 255 | // Given 256 | String initialContent = "Test content"; 257 | Path testFile = tempDir.resolve("incomplete-json.txt"); 258 | Files.writeString(testFile, initialContent); 259 | 260 | // Missing newText field 261 | String edits = "[{\"oldText\":\"Test content\"}]"; 262 | 263 | // When 264 | String result = editFileService.editFile(testFile.toString(), edits, false); 265 | JsonNode jsonResult = objectMapper.readTree(result); 266 | 267 | // Then 268 | assertFalse(jsonResult.get("success").asBoolean()); 269 | 270 | // Verify file was not modified 271 | assertEquals(initialContent, Files.readString(testFile)); 272 | } 273 | } 274 | 275 | @Nested 276 | @DisplayName("Line Ending and Whitespace Tests") 277 | class LineEndingTests { 278 | 279 | @Test 280 | void shouldHandleDifferentLineEndings() throws Exception { 281 | // Given 282 | String initialContent = "Windows\r\nLine\rEndings\nMixed"; 283 | Path testFile = tempDir.resolve("line-endings.txt"); 284 | Files.writeString(testFile, initialContent); 285 | 286 | // Test different line endings in the edit 287 | String edits = "{\"oldText\":\"Windows\\r\\nLine\\rEndings\",\"newText\":\"Normalized Line Endings\"}"; 288 | 289 | // When 290 | String result = editFileService.editFile(testFile.toString(), edits, false); 291 | JsonNode jsonResult = objectMapper.readTree(result); 292 | 293 | // Then 294 | assertTrue(jsonResult.get("success").asBoolean()); 295 | 296 | // Verify replacement worked despite different line endings 297 | String expectedContent = "Normalized Line Endings\nMixed"; 298 | assertEquals(expectedContent, Files.readString(testFile)); 299 | } 300 | 301 | @Test 302 | void shouldHandleLeadingAndTrailingWhitespace() throws Exception { 303 | // Given 304 | String initialContent = "Text with spaces and tabs\t\t"; 305 | Path testFile = tempDir.resolve("whitespace.txt"); 306 | Files.writeString(testFile, initialContent); 307 | 308 | String edits = "{\"oldText\":\"spaces and tabs\",\"newText\":\"minimal whitespace\"}"; 309 | 310 | // When 311 | String result = editFileService.editFile(testFile.toString(), edits, false); 312 | JsonNode jsonResult = objectMapper.readTree(result); 313 | 314 | // Then 315 | assertTrue(jsonResult.get("success").asBoolean()); 316 | 317 | // Verify replacement preserved whitespace correctly 318 | String expectedContent = "Text with minimal whitespace\t\t"; 319 | assertEquals(expectedContent, Files.readString(testFile)); 320 | } 321 | } 322 | 323 | @Nested 324 | @DisplayName("Regex Support Tests") 325 | class RegexTests { 326 | 327 | @Test 328 | void shouldHandleSimpleFormatWithRegex() throws Exception { 329 | // Given 330 | String initialContent = "User123, User456, User789"; 331 | Path testFile = tempDir.resolve("regex-simple-format.txt"); 332 | Files.writeString(testFile, initialContent); 333 | 334 | // Simple text format with regex enabled 335 | String edits = "{\"oldText\":\"User[0-9]{3}\",\"newText\":\"Member\",\"useRegex\":\"true\"}"; 336 | 337 | // When 338 | String result = editFileService.editFile(testFile.toString(), edits, false); 339 | JsonNode jsonResult = objectMapper.readTree(result); 340 | 341 | // Then 342 | assertTrue(jsonResult.get("success").asBoolean()); 343 | 344 | // Verify only the first match was replaced 345 | String expectedContent = "Member, User456, User789"; 346 | assertEquals(expectedContent, Files.readString(testFile)); 347 | } 348 | 349 | @Test 350 | void shouldReplaceOnlyFirstRegexMatch() throws Exception { 351 | // Given 352 | String initialContent = "User123, User456, User789"; 353 | Path testFile = tempDir.resolve("regex-basic.txt"); 354 | Files.writeString(testFile, initialContent); 355 | 356 | // Use regex to match pattern - should only replace first occurrence 357 | String edits = "{\"oldText\":\"User\\\\d+\",\"newText\":\"Member\",\"useRegex\":\"true\"}"; 358 | 359 | // When 360 | String result = editFileService.editFile(testFile.toString(), edits, false); 361 | JsonNode jsonResult = objectMapper.readTree(result); 362 | 363 | // Then 364 | assertTrue(jsonResult.get("success").asBoolean()); 365 | 366 | // Verify only the first match was replaced, not all matches 367 | String expectedContent = "Member, User456, User789"; 368 | assertEquals(expectedContent, Files.readString(testFile)); 369 | } 370 | 371 | @Test 372 | void shouldHandleInvalidRegexPattern() throws Exception { 373 | // Given 374 | String initialContent = "Test invalid regex"; 375 | Path testFile = tempDir.resolve("invalid-regex.txt"); 376 | Files.writeString(testFile, initialContent); 377 | 378 | // Invalid regex pattern (unclosed parenthesis) 379 | String edits = "{\"oldText\":\"Test (invalid\",\"newText\":\"Fixed\",\"useRegex\":\"true\"}"; 380 | 381 | // When 382 | String result = editFileService.editFile(testFile.toString(), edits, false); 383 | JsonNode jsonResult = objectMapper.readTree(result); 384 | 385 | // Then 386 | assertFalse(jsonResult.get("success").asBoolean()); 387 | assertTrue(jsonResult.get("error").asText().contains("Invalid regex pattern")); 388 | 389 | // Verify file was not modified 390 | assertEquals(initialContent, Files.readString(testFile)); 391 | } 392 | } 393 | 394 | @Nested 395 | @DisplayName("Edge Cases Tests") 396 | class EdgeCasesTests { 397 | 398 | @Test 399 | void shouldHandleEmptyFile() throws Exception { 400 | // Given 401 | String initialContent = ""; 402 | Path testFile = tempDir.resolve("empty-file.txt"); 403 | Files.writeString(testFile, initialContent); 404 | 405 | String edits = "{\"oldText\":\"\",\"newText\":\"Added content\"}"; 406 | 407 | // When 408 | String result = editFileService.editFile(testFile.toString(), edits, false); 409 | JsonNode jsonResult = objectMapper.readTree(result); 410 | 411 | // Then 412 | assertTrue(jsonResult.get("success").asBoolean()); 413 | 414 | // Verify content was added to empty file 415 | String expectedContent = "Added content"; 416 | assertEquals(expectedContent, Files.readString(testFile)); 417 | } 418 | 419 | @Test 420 | void shouldHandleLargeFile() throws Exception { 421 | // Given 422 | StringBuilder largeContent = new StringBuilder(); 423 | for (int i = 0; i < 10000; i++) { 424 | largeContent.append("Line ").append(i).append("\n"); 425 | } 426 | 427 | Path testFile = tempDir.resolve("large-file.txt"); 428 | Files.writeString(testFile, largeContent.toString()); 429 | 430 | // Find and replace a specific line 431 | String edits = "{\"oldText\":\"Line 5000\",\"newText\":\"MODIFIED LINE\"}"; 432 | 433 | // When 434 | String result = editFileService.editFile(testFile.toString(), edits, false); 435 | JsonNode jsonResult = objectMapper.readTree(result); 436 | 437 | // Then 438 | assertTrue(jsonResult.get("success").asBoolean()); 439 | 440 | // Verify the specific line was replaced 441 | assertTrue(Files.readString(testFile).contains("MODIFIED LINE")); 442 | } 443 | 444 | @Test 445 | void shouldHandleSpecialCharacters() throws Exception { 446 | // Given 447 | String initialContent = "Special chars: áéíóú ñÑ 你好 こんにちは"; 448 | Path testFile = tempDir.resolve("special-chars.txt"); 449 | Files.writeString(testFile, initialContent); 450 | 451 | String edits = "{\"oldText\":\"áéíóú ñÑ\",\"newText\":\"Unicode Works!\"}"; 452 | 453 | // When 454 | String result = editFileService.editFile(testFile.toString(), edits, false); 455 | JsonNode jsonResult = objectMapper.readTree(result); 456 | 457 | // Then 458 | assertTrue(jsonResult.get("success").asBoolean()); 459 | 460 | // Verify unicode characters were handled correctly 461 | String expectedContent = "Special chars: Unicode Works! 你好 こんにちは"; 462 | assertEquals(expectedContent, Files.readString(testFile)); 463 | } 464 | } 465 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/FetchWebpageServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.jsoup.Connection; 6 | import org.jsoup.Jsoup; 7 | import org.jsoup.nodes.Document; 8 | import org.junit.jupiter.api.BeforeEach; 9 | import org.junit.jupiter.api.Test; 10 | import org.junit.jupiter.api.extension.ExtendWith; 11 | import org.mockito.Mock; 12 | import org.mockito.MockedStatic; 13 | import org.mockito.Mockito; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | 16 | import java.io.IOException; 17 | 18 | import static org.junit.jupiter.api.Assertions.*; 19 | import static org.mockito.ArgumentMatchers.anyInt; 20 | import static org.mockito.ArgumentMatchers.anyString; 21 | import static org.mockito.Mockito.when; 22 | 23 | @ExtendWith(MockitoExtension.class) 24 | class FetchWebpageServiceTest { 25 | 26 | private FetchWebpageService fetchWebpageService; 27 | private ObjectMapper objectMapper; 28 | 29 | @Mock 30 | private Connection connectionMock; 31 | 32 | @Mock 33 | private Document documentMock; 34 | 35 | @BeforeEach 36 | void setUp() { 37 | fetchWebpageService = new FetchWebpageService(); 38 | objectMapper = new ObjectMapper(); 39 | } 40 | 41 | @Test 42 | void shouldFetchWebpageSuccessfully() throws Exception { 43 | // Given 44 | String url = "https://example.com"; 45 | String expectedTitle = "Example Domain"; 46 | String expectedContent = "This domain is for use in illustrative examples"; 47 | 48 | try (MockedStatic jsoupMock = Mockito.mockStatic(Jsoup.class)) { 49 | jsoupMock.when(() -> Jsoup.connect(anyString())).thenReturn(connectionMock); 50 | when(connectionMock.timeout(anyInt())).thenReturn(connectionMock); 51 | when(connectionMock.get()).thenReturn(documentMock); 52 | when(documentMock.title()).thenReturn(expectedTitle); 53 | when(documentMock.text()).thenReturn(expectedContent); 54 | 55 | // When 56 | String result = fetchWebpageService.fetchWebpage(url, 5000); 57 | JsonNode jsonResult = objectMapper.readTree(result); 58 | 59 | // Then 60 | assertTrue(jsonResult.get("success").asBoolean()); 61 | assertEquals(url, jsonResult.get("url").asText()); 62 | assertEquals(expectedTitle, jsonResult.get("title").asText()); 63 | assertEquals(expectedContent, jsonResult.get("content").asText()); 64 | } 65 | } 66 | 67 | @Test 68 | void shouldUseDefaultTimeoutWhenNotProvided() throws Exception { 69 | // Given 70 | String url = "https://example.com"; 71 | 72 | try (MockedStatic jsoupMock = Mockito.mockStatic(Jsoup.class)) { 73 | jsoupMock.when(() -> Jsoup.connect(anyString())).thenReturn(connectionMock); 74 | // Verify that the default timeout (10000ms) is used when not specified 75 | when(connectionMock.timeout(10000)).thenReturn(connectionMock); 76 | when(connectionMock.get()).thenReturn(documentMock); 77 | when(documentMock.title()).thenReturn("Title"); 78 | when(documentMock.text()).thenReturn("Content"); 79 | 80 | // When 81 | String result = fetchWebpageService.fetchWebpage(url, null); 82 | JsonNode jsonResult = objectMapper.readTree(result); 83 | 84 | // Then 85 | assertTrue(jsonResult.get("success").asBoolean()); 86 | } 87 | } 88 | 89 | @Test 90 | void shouldHandleIOException() throws Exception { 91 | // Given 92 | String url = "https://invalid-url.example"; 93 | 94 | try (MockedStatic jsoupMock = Mockito.mockStatic(Jsoup.class)) { 95 | jsoupMock.when(() -> Jsoup.connect(anyString())).thenReturn(connectionMock); 96 | when(connectionMock.timeout(anyInt())).thenReturn(connectionMock); 97 | when(connectionMock.get()).thenThrow(new IOException("Connection refused")); 98 | 99 | // When 100 | String result = fetchWebpageService.fetchWebpage(url, 5000); 101 | JsonNode jsonResult = objectMapper.readTree(result); 102 | 103 | // Then 104 | assertFalse(jsonResult.get("success").asBoolean()); 105 | assertTrue(jsonResult.get("error").asText().contains("Failed to access url")); 106 | } 107 | } 108 | 109 | @Test 110 | void shouldHandleUnexpectedException() throws Exception { 111 | // Given 112 | String url = "https://example.com"; 113 | 114 | try (MockedStatic jsoupMock = Mockito.mockStatic(Jsoup.class)) { 115 | jsoupMock.when(() -> Jsoup.connect(anyString())).thenReturn(connectionMock); 116 | when(connectionMock.timeout(anyInt())).thenReturn(connectionMock); 117 | when(connectionMock.get()).thenThrow(new RuntimeException("Unexpected error")); 118 | 119 | // When 120 | String result = fetchWebpageService.fetchWebpage(url, 5000); 121 | JsonNode jsonResult = objectMapper.readTree(result); 122 | 123 | // Then 124 | assertFalse(jsonResult.get("success").asBoolean()); 125 | assertTrue(jsonResult.get("error").asText().contains("Failed to access url")); 126 | } 127 | } 128 | 129 | @Test 130 | void shouldHandleNullUrl() throws Exception { 131 | // When 132 | String result = fetchWebpageService.fetchWebpage(null, 5000); 133 | JsonNode jsonResult = objectMapper.readTree(result); 134 | 135 | // Then 136 | assertFalse(jsonResult.get("success").asBoolean()); 137 | assertTrue(jsonResult.get("error").asText().contains("Failed to access")); 138 | } 139 | 140 | @Test 141 | void shouldHandleEmptyUrl() throws Exception { 142 | // When 143 | String result = fetchWebpageService.fetchWebpage("", 5000); 144 | JsonNode jsonResult = objectMapper.readTree(result); 145 | 146 | // Then 147 | assertFalse(jsonResult.get("success").asBoolean()); 148 | assertTrue(jsonResult.get("error").asText().contains("Failed")); 149 | } 150 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/GrepFilesServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.Nested; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.io.TempDir; 10 | 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | 14 | import static org.junit.jupiter.api.Assertions.*; 15 | 16 | class GrepFilesServiceTest { 17 | 18 | private GrepFilesService grepFilesService; 19 | private ObjectMapper objectMapper; 20 | 21 | @TempDir 22 | Path tempDir; 23 | 24 | @BeforeEach 25 | void setUp() { 26 | grepFilesService = new GrepFilesService(); 27 | objectMapper = new ObjectMapper(); 28 | } 29 | 30 | private void createTestFiles(Path directory) throws Exception { 31 | // Create a few test files with different content and extensions 32 | Path textFile1 = directory.resolve("file1.txt"); 33 | Files.writeString(textFile1, "This is a sample text file\nWith multiple lines\nAnd some important text here\nline 1\nline 2"); 34 | 35 | Path textFile2 = directory.resolve("file2.txt"); 36 | Files.writeString(textFile2, "Another sample file\nWith different content\nBut no important keywords"); 37 | 38 | Path javaFile = directory.resolve("Example.java"); 39 | Files.writeString(javaFile, """ 40 | public class Example { 41 | // Sample Java code 42 | public static void main(String[] args) { 43 | System.out.println("Hello, World!"); 44 | // line 10 of code 45 | } 46 | } 47 | """); 48 | 49 | Path xmlFile = directory.resolve("config.xml"); 50 | Files.writeString(xmlFile, """ 51 | 52 | value 53 | setting 54 | 55 | """); 56 | } 57 | 58 | @Nested 59 | @DisplayName("Basic Search Tests") 60 | class BasicSearchTests { 61 | 62 | @Test 63 | void shouldFindSimpleTextPattern() throws Exception { 64 | // Given 65 | createTestFiles(tempDir); 66 | String pattern = "important text"; 67 | 68 | // When 69 | String result = grepFilesService.grepFiles(tempDir.toString(), pattern, null, false, null, null); 70 | JsonNode jsonResult = objectMapper.readTree(result); 71 | 72 | // Then 73 | assertTrue(jsonResult.get("success").asBoolean()); 74 | assertTrue(jsonResult.get("results").size() > 0); 75 | } 76 | 77 | @Test 78 | void shouldRespectFileExtensionFilter() throws Exception { 79 | // Given 80 | createTestFiles(tempDir); 81 | String pattern = "sample"; 82 | 83 | // When - search only in .txt files 84 | String result = grepFilesService.grepFiles(tempDir.toString(), pattern, ".txt", false, null, null); 85 | JsonNode jsonResult = objectMapper.readTree(result); 86 | 87 | // Then 88 | assertTrue(jsonResult.get("success").asBoolean()); 89 | assertTrue(!jsonResult.get("results").isEmpty()); 90 | 91 | // All matches should be in .txt files 92 | for (JsonNode matchText : jsonResult.get("results")) { 93 | if (matchText.asText().contains("(") && matchText.asText().contains("matches)")) { 94 | assertTrue(matchText.asText().contains(".txt")); 95 | } 96 | } 97 | } 98 | } 99 | 100 | @Nested 101 | @DisplayName("Filter Tests") 102 | class FilterTests { 103 | 104 | @Test 105 | void shouldSearchAllFilesWhenExtensionFilterIsNull() throws Exception { 106 | // Given 107 | createTestFiles(tempDir); 108 | String pattern = "sample"; // Appears in both .txt and .xml files 109 | 110 | // When - extension filter is null 111 | String resultWithNullFilter = grepFilesService.grepFiles(tempDir.toString(), pattern, null, false, null, null); 112 | JsonNode jsonResultNullFilter = objectMapper.readTree(resultWithNullFilter); 113 | 114 | // When - extension filter is empty 115 | String resultWithEmptyFilter = grepFilesService.grepFiles(tempDir.toString(), pattern, "", false, null, null); 116 | JsonNode jsonResultEmptyFilter = objectMapper.readTree(resultWithEmptyFilter); 117 | 118 | // Then - both should search all files 119 | assertTrue(jsonResultNullFilter.get("success").asBoolean()); 120 | assertTrue(jsonResultEmptyFilter.get("success").asBoolean()); 121 | 122 | // The summary should have the same number of matches for both null and empty filter 123 | String summaryNull = jsonResultNullFilter.get("summary").asText(); 124 | String summaryEmpty = jsonResultEmptyFilter.get("summary").asText(); 125 | assertTrue(summaryNull.contains("Found") && summaryEmpty.contains("Found")); 126 | assertEquals(summaryNull, summaryEmpty); 127 | 128 | // We should have matches in files with different extensions 129 | boolean foundTxtFile = false; 130 | boolean foundXmlFile = false; 131 | 132 | for (JsonNode matchText : jsonResultNullFilter.get("results")) { 133 | String text = matchText.asText(); 134 | if (text.contains(".txt")) foundTxtFile = true; 135 | if (text.contains(".xml")) foundXmlFile = true; 136 | } 137 | 138 | assertTrue(foundTxtFile || foundXmlFile, "Should find matches in different file types"); 139 | } 140 | } 141 | 142 | @Nested 143 | @DisplayName("Advanced Search Tests") 144 | class AdvancedSearchTests { 145 | 146 | @Test 147 | void shouldUseRegexPatternMatching() throws Exception { 148 | // Given 149 | createTestFiles(tempDir); 150 | // Regex to match "line" followed by a number 151 | String pattern = "line\\s+[0-9]+"; 152 | 153 | // When 154 | String result = grepFilesService.grepFiles(tempDir.toString(), pattern, null, true, null, null); 155 | JsonNode jsonResult = objectMapper.readTree(result); 156 | 157 | // Then 158 | assertTrue(jsonResult.get("success").asBoolean()); 159 | assertTrue(jsonResult.get("results").size() > 0); 160 | } 161 | 162 | @Test 163 | void shouldProvideContextLines() throws Exception { 164 | // Given 165 | createTestFiles(tempDir); 166 | String pattern = "important text"; 167 | int contextLines = 2; 168 | 169 | // When 170 | String result = grepFilesService.grepFiles(tempDir.toString(), pattern, null, false, contextLines, null); 171 | JsonNode jsonResult = objectMapper.readTree(result); 172 | 173 | // Then 174 | assertTrue(jsonResult.get("success").asBoolean()); 175 | 176 | // Check that context lines are included 177 | // The context is included in the results as text with line breaks 178 | boolean foundContext = false; 179 | for (JsonNode aResult : jsonResult.get("results")) { 180 | String text = aResult.asText(); 181 | if (text.contains("→") && text.contains("important text")) { 182 | foundContext = true; 183 | break; 184 | } 185 | } 186 | assertTrue(foundContext, "Should include context lines with arrow symbol"); 187 | } 188 | 189 | } 190 | 191 | @Nested 192 | @DisplayName("Error Handling Tests") 193 | class ErrorHandlingTests { 194 | 195 | @Test 196 | void shouldHandleNonExistentDirectory() throws Exception { 197 | // Given 198 | String nonExistentDir = tempDir.resolve("non-existent").toString(); 199 | String pattern = "test"; 200 | 201 | // When 202 | String result = grepFilesService.grepFiles(nonExistentDir, pattern, null, false, null, null); 203 | JsonNode jsonResult = objectMapper.readTree(result); 204 | 205 | // Then 206 | assertFalse(jsonResult.get("success").asBoolean()); 207 | assertTrue(jsonResult.has("error")); 208 | assertTrue(jsonResult.get("error").asText().contains("Directory does not exist")); 209 | } 210 | 211 | @Test 212 | void shouldHandleInvalidPattern() throws Exception { 213 | // Given 214 | createTestFiles(tempDir); 215 | String invalidRegexPattern = "["; 216 | 217 | // When - trying to use the invalid pattern as regex 218 | String result = grepFilesService.grepFiles(tempDir.toString(), invalidRegexPattern, null, true, null, null); 219 | JsonNode jsonResult = objectMapper.readTree(result); 220 | 221 | // Then 222 | assertFalse(jsonResult.get("success").asBoolean()); 223 | assertTrue(jsonResult.has("error")); 224 | } 225 | } 226 | 227 | // TODO Make a test with the above grep search arguments because it should return a result 228 | @Test 229 | void basicGrepSearch() throws Exception { 230 | createTestFiles(tempDir); 231 | 232 | GrepFilesService grepFilesService = new GrepFilesService(); 233 | String result = grepFilesService.grepFiles(tempDir.toString(), "sample", ".java", false, null, 5); 234 | 235 | JsonNode jsonResult = objectMapper.readTree(result); 236 | 237 | String summary = jsonResult.get("summary").asText(); 238 | 239 | assertTrue(summary.contains("Found 1 matches in 1 files"), 240 | "Summary should mention 1 found file"); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/ListDirectoryServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.io.TempDir; 8 | 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | import java.util.Map; 14 | 15 | import static org.junit.jupiter.api.Assertions.*; 16 | 17 | class ListDirectoryServiceTest { 18 | 19 | private ListDirectoryService listDirectoryService; 20 | private ObjectMapper objectMapper; 21 | 22 | @TempDir 23 | Path tempDir; 24 | 25 | @BeforeEach 26 | void setUp() { 27 | listDirectoryService = new ListDirectoryService(); 28 | objectMapper = new ObjectMapper(); 29 | } 30 | 31 | @Test 32 | void shouldListDirectoryContents() throws Exception { 33 | // Given 34 | // Create a test directory structure 35 | Path file1 = tempDir.resolve("file1.txt"); 36 | Path file2 = tempDir.resolve("file2.log"); 37 | Path subDir = tempDir.resolve("subdir"); 38 | 39 | Files.writeString(file1, "Content of file 1"); 40 | Files.writeString(file2, "Content of file 2"); 41 | Files.createDirectory(subDir); 42 | 43 | // When 44 | String result = listDirectoryService.listDirectory(tempDir.toString()); 45 | JsonNode jsonResult = objectMapper.readTree(result); 46 | 47 | // Then 48 | assertTrue(jsonResult.get("success").asBoolean()); 49 | assertEquals(tempDir.toString(), jsonResult.get("path").asText()); 50 | assertEquals(3, jsonResult.get("count").asInt()); 51 | 52 | // Verify entries 53 | JsonNode entries = jsonResult.get("entries"); 54 | assertTrue(entries.isArray()); 55 | 56 | // Collect entry names and types for easier verification 57 | List> entryDetails = new ArrayList<>(); 58 | for (JsonNode entry : entries) { 59 | String name = entry.get("name").asText(); 60 | String type = entry.get("type").asText(); 61 | entryDetails.add(Map.of("name", name, "type", type)); 62 | } 63 | 64 | // Check if all our created files/dirs are in the results 65 | assertTrue(entryDetails.contains(Map.of("name", "file1.txt", "type", "FILE"))); 66 | assertTrue(entryDetails.contains(Map.of("name", "file2.log", "type", "FILE"))); 67 | assertTrue(entryDetails.contains(Map.of("name", "subdir", "type", "DIR"))); 68 | } 69 | 70 | @Test 71 | void shouldSortDirectoriesFirst() throws Exception { 72 | // Given 73 | // Create a mixed set of files and directories with names that would be out of order alphabetically 74 | Path fileA = tempDir.resolve("a_file.txt"); 75 | Path fileB = tempDir.resolve("b_file.txt"); 76 | Path dirZ = tempDir.resolve("z_dir"); 77 | Path dirY = tempDir.resolve("y_dir"); 78 | 79 | Files.writeString(fileA, "Content A"); 80 | Files.writeString(fileB, "Content B"); 81 | Files.createDirectory(dirZ); 82 | Files.createDirectory(dirY); 83 | 84 | // When 85 | String result = listDirectoryService.listDirectory(tempDir.toString()); 86 | JsonNode jsonResult = objectMapper.readTree(result); 87 | JsonNode entries = jsonResult.get("entries"); 88 | 89 | // Then 90 | // Directories should come first, then files, both alphabetically 91 | assertEquals("DIR", entries.get(0).get("type").asText()); 92 | assertEquals("DIR", entries.get(1).get("type").asText()); 93 | assertEquals("FILE", entries.get(2).get("type").asText()); 94 | assertEquals("FILE", entries.get(3).get("type").asText()); 95 | 96 | // Check alphabetical order within types 97 | assertEquals("y_dir", entries.get(0).get("name").asText()); 98 | assertEquals("z_dir", entries.get(1).get("name").asText()); 99 | assertEquals("a_file.txt", entries.get(2).get("name").asText()); 100 | assertEquals("b_file.txt", entries.get(3).get("name").asText()); 101 | } 102 | 103 | @Test 104 | void shouldHandleEmptyDirectory() throws Exception { 105 | // Given 106 | Path emptyDir = tempDir.resolve("empty-dir"); 107 | Files.createDirectory(emptyDir); 108 | 109 | // When 110 | String result = listDirectoryService.listDirectory(emptyDir.toString()); 111 | JsonNode jsonResult = objectMapper.readTree(result); 112 | 113 | // Then 114 | assertTrue(jsonResult.get("success").asBoolean()); 115 | assertEquals(0, jsonResult.get("count").asInt()); 116 | assertTrue(jsonResult.get("entries").isArray()); 117 | assertEquals(0, jsonResult.get("entries").size()); 118 | } 119 | 120 | @Test 121 | void shouldHandleNonExistentDirectory() throws Exception { 122 | // Given 123 | String nonExistentDir = tempDir.resolve("non-existent-dir").toString(); 124 | 125 | // When 126 | String result = listDirectoryService.listDirectory(nonExistentDir); 127 | JsonNode jsonResult = objectMapper.readTree(result); 128 | 129 | // Then 130 | assertFalse(jsonResult.get("success").asBoolean()); 131 | assertTrue(jsonResult.get("error").asText().contains("Path does not exist")); 132 | } 133 | 134 | @Test 135 | void shouldHandleFileAsDirectoryPath() throws Exception { 136 | // Given 137 | Path file = tempDir.resolve("not-a-directory.txt"); 138 | Files.writeString(file, "This is a file, not a directory"); 139 | 140 | // When 141 | String result = listDirectoryService.listDirectory(file.toString()); 142 | JsonNode jsonResult = objectMapper.readTree(result); 143 | 144 | // Then 145 | assertFalse(jsonResult.get("success").asBoolean()); 146 | assertTrue(jsonResult.get("error").asText().contains("Path is not a directory")); 147 | } 148 | 149 | @Test 150 | void shouldIncludeMetadataForEntries() throws Exception { 151 | // Given 152 | Path file = tempDir.resolve("file-with-metadata.txt"); 153 | String content = "Content to check size"; 154 | Files.writeString(file, content); 155 | 156 | // When 157 | String result = listDirectoryService.listDirectory(tempDir.toString()); 158 | JsonNode jsonResult = objectMapper.readTree(result); 159 | 160 | // Then 161 | JsonNode entries = jsonResult.get("entries"); 162 | 163 | // Find our specific file in the entries 164 | JsonNode fileEntry = null; 165 | for (JsonNode entry : entries) { 166 | if (entry.get("name").asText().equals("file-with-metadata.txt")) { 167 | fileEntry = entry; 168 | break; 169 | } 170 | } 171 | 172 | assertNotNull(fileEntry, "File entry should be found in results"); 173 | assertEquals("FILE", fileEntry.get("type").asText()); 174 | assertEquals(content.length(), fileEntry.get("size").asInt()); 175 | assertTrue(fileEntry.has("lastModified")); 176 | assertTrue(fileEntry.get("lastModified").asLong() > 0); 177 | } 178 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/ReadFileServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.io.TempDir; 8 | 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.nio.file.Paths; 12 | 13 | import static org.junit.jupiter.api.Assertions.*; 14 | 15 | class ReadFileServiceTest { 16 | 17 | private ReadFileService readFileService; 18 | private ObjectMapper objectMapper; 19 | 20 | @TempDir 21 | Path tempDir; 22 | 23 | @BeforeEach 24 | void setUp() { 25 | readFileService = new ReadFileService(); 26 | objectMapper = new ObjectMapper(); 27 | } 28 | 29 | @Test 30 | void shouldSuccessfullyReadFile() throws Exception { 31 | // Given 32 | String testContent = "This is test content\nLine 2\nLine 3"; 33 | Path testFile = tempDir.resolve("test-file.txt"); 34 | Files.writeString(testFile, testContent); 35 | 36 | // When 37 | String result = readFileService.readFile(testFile.toString()); 38 | JsonNode jsonResult = objectMapper.readTree(result); 39 | 40 | // Then 41 | assertTrue(jsonResult.get("success").asBoolean()); 42 | assertEquals(testContent, jsonResult.get("content").asText()); 43 | assertEquals(testFile.toString(), jsonResult.get("path").asText()); 44 | assertEquals(testContent.getBytes().length, jsonResult.get("size").asInt()); 45 | } 46 | 47 | @Test 48 | void shouldHandleNonExistentFile() throws Exception { 49 | // Given 50 | String nonExistentPath = tempDir.resolve("non-existent-file.txt").toString(); 51 | 52 | // When 53 | String result = readFileService.readFile(nonExistentPath); 54 | JsonNode jsonResult = objectMapper.readTree(result); 55 | 56 | // Then 57 | assertFalse(jsonResult.get("success").asBoolean()); 58 | assertTrue(jsonResult.get("error").asText().contains("File does not exist")); 59 | } 60 | 61 | @Test 62 | void shouldHandleDirectory() throws Exception { 63 | // Given 64 | Path directory = tempDir.resolve("test-directory"); 65 | Files.createDirectory(directory); 66 | 67 | // When 68 | String result = readFileService.readFile(directory.toString()); 69 | JsonNode jsonResult = objectMapper.readTree(result); 70 | 71 | // Then 72 | assertFalse(jsonResult.get("success").asBoolean()); 73 | assertTrue(jsonResult.get("error").asText().contains("Path is not a regular file")); 74 | } 75 | 76 | @Test 77 | void shouldHandleEmptyFile() throws Exception { 78 | // Given 79 | Path emptyFile = tempDir.resolve("empty-file.txt"); 80 | Files.createFile(emptyFile); 81 | 82 | // When 83 | String result = readFileService.readFile(emptyFile.toString()); 84 | JsonNode jsonResult = objectMapper.readTree(result); 85 | 86 | // Then 87 | assertTrue(jsonResult.get("success").asBoolean()); 88 | assertEquals("", jsonResult.get("content").asText()); 89 | assertEquals(0, jsonResult.get("size").asInt()); 90 | } 91 | 92 | @Test 93 | void shouldHandleLargeFile() throws Exception { 94 | // Given 95 | StringBuilder largeContent = new StringBuilder(); 96 | for (int i = 0; i < 10000; i++) { 97 | largeContent.append("Line ").append(i).append("\n"); 98 | } 99 | Path largeFile = tempDir.resolve("large-file.txt"); 100 | Files.writeString(largeFile, largeContent.toString()); 101 | 102 | // When 103 | String result = readFileService.readFile(largeFile.toString()); 104 | JsonNode jsonResult = objectMapper.readTree(result); 105 | 106 | // Then 107 | assertTrue(jsonResult.get("success").asBoolean()); 108 | assertEquals(largeContent.toString(), jsonResult.get("content").asText()); 109 | } 110 | 111 | @Test 112 | void shouldHandleSpecialCharacters() throws Exception { 113 | // Given 114 | String specialContent = "Special characters: çñüé€ñÑ\nSymbols: ©®™§±"; 115 | Path specialFile = tempDir.resolve("special-chars.txt"); 116 | Files.writeString(specialFile, specialContent); 117 | 118 | // When 119 | String result = readFileService.readFile(specialFile.toString()); 120 | JsonNode jsonResult = objectMapper.readTree(result); 121 | 122 | // Then 123 | assertTrue(jsonResult.get("success").asBoolean()); 124 | assertEquals(specialContent, jsonResult.get("content").asText()); 125 | } 126 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/SearchFilesServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.io.TempDir; 8 | 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | 14 | import static org.junit.jupiter.api.Assertions.*; 15 | 16 | class SearchFilesServiceTest { 17 | 18 | private SearchFilesService searchFilesService; 19 | private ObjectMapper objectMapper; 20 | 21 | @TempDir 22 | Path tempDir; 23 | 24 | @BeforeEach 25 | void setUp() { 26 | searchFilesService = new SearchFilesService(); 27 | objectMapper = new ObjectMapper(); 28 | 29 | // Create a standard directory structure for tests 30 | try { 31 | // Create files with different extensions 32 | Files.writeString(tempDir.resolve("file1.txt"), "Text file 1"); 33 | Files.writeString(tempDir.resolve("file2.txt"), "Text file 2"); 34 | Files.writeString(tempDir.resolve("document.pdf"), "PDF content"); 35 | Files.writeString(tempDir.resolve("image.jpg"), "Image content"); 36 | 37 | // Create subdirectories with files 38 | Path subDir1 = tempDir.resolve("subdir1"); 39 | Files.createDirectory(subDir1); 40 | Files.writeString(subDir1.resolve("subfile1.txt"), "Subdir text file"); 41 | Files.writeString(subDir1.resolve("subimage.png"), "Subdir image content"); 42 | 43 | Path subDir2 = tempDir.resolve("subdir2"); 44 | Files.createDirectory(subDir2); 45 | Files.writeString(subDir2.resolve("another.txt"), "Another text file"); 46 | 47 | // Create nested subdirectory 48 | Path nestedDir = subDir1.resolve("nested"); 49 | Files.createDirectory(nestedDir); 50 | Files.writeString(nestedDir.resolve("deep-file.txt"), "Deeply nested file"); 51 | } catch (Exception e) { 52 | fail("Failed to set up test directory structure: " + e.getMessage()); 53 | } 54 | } 55 | 56 | @Test 57 | void shouldFindFilesByExtension() throws Exception { 58 | // When 59 | String result = searchFilesService.searchFiles(tempDir.toString(), "*.txt"); 60 | JsonNode jsonResult = objectMapper.readTree(result); 61 | 62 | // Then 63 | assertTrue(jsonResult.get("success").asBoolean()); 64 | JsonNode matches = jsonResult.get("matches"); 65 | assertTrue(matches.isArray()); 66 | 67 | // Should find all 5 .txt files 68 | assertEquals(5, jsonResult.get("count").asInt()); 69 | 70 | // Extract just the filenames from the full paths for easier assertion 71 | List fileNames = new ArrayList<>(); 72 | for (JsonNode match : matches) { 73 | String path = match.asText(); 74 | fileNames.add(Path.of(path).getFileName().toString()); 75 | } 76 | 77 | assertTrue(fileNames.contains("file1.txt")); 78 | assertTrue(fileNames.contains("file2.txt")); 79 | assertTrue(fileNames.contains("subfile1.txt")); 80 | assertTrue(fileNames.contains("deep-file.txt")); 81 | } 82 | 83 | @Test 84 | void shouldFindFilesByPartialName() throws Exception { 85 | // When 86 | String result = searchFilesService.searchFiles(tempDir.toString(), "*file*"); 87 | JsonNode jsonResult = objectMapper.readTree(result); 88 | 89 | // Then 90 | assertTrue(jsonResult.get("success").asBoolean()); 91 | 92 | // Extract just the filenames from the full paths 93 | List fileNames = new ArrayList<>(); 94 | for (JsonNode match : jsonResult.get("matches")) { 95 | String path = match.asText(); 96 | fileNames.add(Path.of(path).getFileName().toString()); 97 | } 98 | 99 | assertTrue(fileNames.contains("file1.txt")); 100 | assertTrue(fileNames.contains("file2.txt")); 101 | assertTrue(fileNames.contains("subfile1.txt")); 102 | assertTrue(fileNames.contains("deep-file.txt")); 103 | } 104 | 105 | @Test 106 | void shouldFindDirectories() throws Exception { 107 | // When 108 | String result = searchFilesService.searchFiles(tempDir.toString(), "*dir*"); 109 | JsonNode jsonResult = objectMapper.readTree(result); 110 | 111 | // Then 112 | assertTrue(jsonResult.get("success").asBoolean()); 113 | 114 | // Extract just the directory names from the full paths 115 | List dirNames = new ArrayList<>(); 116 | for (JsonNode match : jsonResult.get("matches")) { 117 | String path = match.asText(); 118 | dirNames.add(Path.of(path).getFileName().toString()); 119 | } 120 | 121 | assertTrue(dirNames.contains("subdir1")); 122 | assertTrue(dirNames.contains("subdir2")); 123 | } 124 | 125 | @Test 126 | void shouldHandleNestedDirectories() throws Exception { 127 | // When - search in a subdirectory 128 | String result = searchFilesService.searchFiles(tempDir.resolve("subdir1").toString(), "*.txt"); 129 | JsonNode jsonResult = objectMapper.readTree(result); 130 | 131 | // Then 132 | assertTrue(jsonResult.get("success").asBoolean()); 133 | 134 | // Should find the text file in subdir1 and its nested directory 135 | assertEquals(2, jsonResult.get("count").asInt()); 136 | 137 | List fileNames = new ArrayList<>(); 138 | for (JsonNode match : jsonResult.get("matches")) { 139 | String path = match.asText(); 140 | fileNames.add(Path.of(path).getFileName().toString()); 141 | } 142 | 143 | assertTrue(fileNames.contains("subfile1.txt")); 144 | assertTrue(fileNames.contains("deep-file.txt")); 145 | } 146 | 147 | @Test 148 | void shouldHandleNonExistentDirectory() throws Exception { 149 | // When 150 | String result = searchFilesService.searchFiles(tempDir.resolve("non-existent").toString(), "*.txt"); 151 | JsonNode jsonResult = objectMapper.readTree(result); 152 | 153 | // Then 154 | assertFalse(jsonResult.get("success").asBoolean()); 155 | assertTrue(jsonResult.get("error").asText().contains("Path does not exist")); 156 | } 157 | 158 | @Test 159 | void shouldHandleInvalidGlobPattern() throws Exception { 160 | // When 161 | String result = searchFilesService.searchFiles(tempDir.toString(), "[invalid-pattern"); 162 | JsonNode jsonResult = objectMapper.readTree(result); 163 | 164 | // Then 165 | assertFalse(jsonResult.get("success").asBoolean()); 166 | assertTrue(jsonResult.get("error").asText().contains("Invalid pattern syntax")); 167 | } 168 | 169 | @Test 170 | void shouldReturnEmptyResultsWhenNoMatches() throws Exception { 171 | // When 172 | String result = searchFilesService.searchFiles(tempDir.toString(), "nomatch*.xyz"); 173 | JsonNode jsonResult = objectMapper.readTree(result); 174 | 175 | // Then 176 | assertTrue(jsonResult.get("success").asBoolean()); 177 | assertEquals(0, jsonResult.get("count").asInt()); 178 | assertTrue(jsonResult.get("matches").isArray()); 179 | assertEquals(0, jsonResult.get("matches").size()); 180 | } 181 | 182 | @Test 183 | void findResultsWithComplexWildcard() throws Exception { 184 | // "arguments": { 185 | // "path": "/Users/stephan/IdeaProjects/JavaFileSystemMCP", 186 | // "pattern": "*.java*Test" 187 | // } 188 | // TODO 189 | } 190 | } -------------------------------------------------------------------------------- /src/test/java/com/devoxx/mcp/filesystem/tools/WriteFileServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.devoxx.mcp.filesystem.tools; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.io.TempDir; 8 | 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | 12 | import static org.junit.jupiter.api.Assertions.*; 13 | 14 | class WriteFileServiceTest { 15 | 16 | private WriteFileService writeFileService; 17 | private ObjectMapper objectMapper; 18 | 19 | @TempDir 20 | Path tempDir; 21 | 22 | @BeforeEach 23 | void setUp() { 24 | writeFileService = new WriteFileService(); 25 | objectMapper = new ObjectMapper(); 26 | } 27 | 28 | @Test 29 | void shouldSuccessfullyCreateNewFile() throws Exception { 30 | // Given 31 | String testContent = "This is new file content"; 32 | Path testFile = tempDir.resolve("new-file.txt"); 33 | String testFilePath = testFile.toString(); 34 | 35 | // When 36 | String result = writeFileService.writeFile(testFilePath, testContent); 37 | JsonNode jsonResult = objectMapper.readTree(result); 38 | 39 | // Then 40 | assertTrue(jsonResult.get("success").asBoolean()); 41 | assertEquals(testFilePath, jsonResult.get("path").asText()); 42 | assertEquals("created", jsonResult.get("action").asText()); 43 | assertEquals(testContent.getBytes().length, jsonResult.get("bytesWritten").asInt()); 44 | 45 | // Verify file was actually created with correct content 46 | assertTrue(Files.exists(testFile)); 47 | assertEquals(testContent, Files.readString(testFile)); 48 | } 49 | 50 | @Test 51 | void shouldSuccessfullyOverwriteExistingFile() throws Exception { 52 | // Given 53 | String initialContent = "Initial content"; 54 | String newContent = "New content that should overwrite the initial content"; 55 | Path testFile = tempDir.resolve("existing-file.txt"); 56 | Files.writeString(testFile, initialContent); 57 | String testFilePath = testFile.toString(); 58 | 59 | // When 60 | String result = writeFileService.writeFile(testFilePath, newContent); 61 | JsonNode jsonResult = objectMapper.readTree(result); 62 | 63 | // Then 64 | assertTrue(jsonResult.get("success").asBoolean()); 65 | assertEquals(testFilePath, jsonResult.get("path").asText()); 66 | assertEquals("overwritten", jsonResult.get("action").asText()); 67 | assertEquals(newContent.getBytes().length, jsonResult.get("bytesWritten").asInt()); 68 | 69 | // Verify file was actually overwritten with new content 70 | assertEquals(newContent, Files.readString(testFile)); 71 | } 72 | 73 | @Test 74 | void shouldCreateParentDirectories() throws Exception { 75 | // Given 76 | String testContent = "Content in nested directory"; 77 | Path nestedDir = tempDir.resolve("parent/child/grandchild"); 78 | Path testFile = nestedDir.resolve("nested-file.txt"); 79 | String testFilePath = testFile.toString(); 80 | 81 | // When 82 | String result = writeFileService.writeFile(testFilePath, testContent); 83 | JsonNode jsonResult = objectMapper.readTree(result); 84 | 85 | // Then 86 | assertTrue(jsonResult.get("success").asBoolean()); 87 | assertEquals(testFilePath, jsonResult.get("path").asText()); 88 | assertTrue(jsonResult.has("createdDirectories")); 89 | 90 | // Verify directories and file were created 91 | assertTrue(Files.exists(nestedDir)); 92 | assertTrue(Files.exists(testFile)); 93 | assertEquals(testContent, Files.readString(testFile)); 94 | } 95 | 96 | @Test 97 | void shouldHandleEmptyContent() throws Exception { 98 | // Given 99 | String emptyContent = ""; 100 | Path testFile = tempDir.resolve("empty-content.txt"); 101 | String testFilePath = testFile.toString(); 102 | 103 | // When 104 | String result = writeFileService.writeFile(testFilePath, emptyContent); 105 | JsonNode jsonResult = objectMapper.readTree(result); 106 | 107 | // Then 108 | assertTrue(jsonResult.get("success").asBoolean()); 109 | assertEquals(0, jsonResult.get("bytesWritten").asInt()); 110 | 111 | // Verify file was created but is empty 112 | assertTrue(Files.exists(testFile)); 113 | assertEquals("", Files.readString(testFile)); 114 | } 115 | 116 | @Test 117 | void shouldHandleSpecialCharacters() throws Exception { 118 | // Given 119 | String specialContent = "Special characters: çñüé€ñÑ\nSymbols: ©®™§±"; 120 | Path testFile = tempDir.resolve("special-chars.txt"); 121 | String testFilePath = testFile.toString(); 122 | 123 | // When 124 | String result = writeFileService.writeFile(testFilePath, specialContent); 125 | JsonNode jsonResult = objectMapper.readTree(result); 126 | 127 | // Then 128 | assertTrue(jsonResult.get("success").asBoolean()); 129 | 130 | // Verify file was created with special characters intact 131 | assertEquals(specialContent, Files.readString(testFile)); 132 | } 133 | 134 | @Test 135 | void shouldHandleLargeContent() throws Exception { 136 | // Given 137 | StringBuilder largeContent = new StringBuilder(); 138 | for (int i = 0; i < 10000; i++) { 139 | largeContent.append("Line ").append(i).append("\n"); 140 | } 141 | Path testFile = tempDir.resolve("large-file.txt"); 142 | String testFilePath = testFile.toString(); 143 | 144 | // When 145 | String result = writeFileService.writeFile(testFilePath, largeContent.toString()); 146 | JsonNode jsonResult = objectMapper.readTree(result); 147 | 148 | // Then 149 | assertTrue(jsonResult.get("success").asBoolean()); 150 | assertEquals(largeContent.length(), jsonResult.get("bytesWritten").asInt()); 151 | 152 | // Verify large file was created correctly 153 | assertEquals(largeContent.toString(), Files.readString(testFile)); 154 | } 155 | } --------------------------------------------------------------------------------