diff --git a/claude-plugins/spring-tools/.claude-plugin/plugin.json b/claude-plugins/spring-tools/.claude-plugin/plugin.json index 25e3551a67..a2801dd0d2 100644 --- a/claude-plugins/spring-tools/.claude-plugin/plugin.json +++ b/claude-plugins/spring-tools/.claude-plugin/plugin.json @@ -14,21 +14,5 @@ "${CLAUDE_PLUGIN_ROOT}/launcher.js" ] } - }, - "lspServers": { - "spring-tools-lsp": { - "command": "node", - "args": [ - "${CLAUDE_PLUGIN_ROOT}/proxy.js" - ], - "extensionToLanguage": { - ".java": "java", - ".properties": "spring-boot-properties", - ".yml": "spring-boot-properties-yaml", - ".yaml": "spring-boot-properties-yaml", - ".xml": "xml", - ".factories": "spring-factories" - } - } } } \ No newline at end of file diff --git a/claude-plugins/spring-tools/hooks/hooks.json b/claude-plugins/spring-tools/hooks/hooks.json new file mode 100644 index 0000000000..8948604082 --- /dev/null +++ b/claude-plugins/spring-tools/hooks/hooks.json @@ -0,0 +1,53 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|StrReplace|Edit|Replace", + "hooks": [ + { + "type": "mcp_tool", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + } + } + ] + }, + { + "matcher": "Delete", + "hooks": [ + { + "type": "mcp_tool", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileDeleted", + "input": { + "filePath": "${tool_input.file_path}" + } + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "mcp_tool", + "if": "Bash(git *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {} + } + ] + }, + { + "matcher": "Write|StrReplace|Delete|Edit|Replace", + "hooks": [ + { + "type": "command", + "command": "echo \"=== PostToolUse Hook Fired ===\" >> hook-debug.log && cat >> hook-debug.log && echo \"\" >> hook-debug.log" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/claude-plugins/spring-tools/skills/quickfix/SKILL.md b/claude-plugins/spring-tools/skills/quickfix/SKILL.md index acbe5784c6..e868c50b98 100644 --- a/claude-plugins/spring-tools/skills/quickfix/SKILL.md +++ b/claude-plugins/spring-tools/skills/quickfix/SKILL.md @@ -8,11 +8,11 @@ You have encountered the Spring Boot diagnostic error code: `$ARGUMENTS[0]`. The problem is located in the file: `$ARGUMENTS[1]`. The text range of the problem is: `$ARGUMENTS[2]` (if provided). +MUST DO: If the error code includes a prefix like `errorCode=` or `code=`, strip it out before you continue to use it anywhere. + To find the official explanation and potential fixes for this issue, you must read the explanation file located at: `${CLAUDE_PLUGIN_ROOT}/explanations/$ARGUMENTS[0].md` -(Note: If the error code includes a prefix like `errorCode=`, strip it out before forming the file path). - If the file does not exist, use your general Spring Boot knowledge to fix the issue. Based on the provided "Explanations" and "Fixes" in that file: diff --git a/claude-plugins/spring-tools/skills/validate/SKILL.md b/claude-plugins/spring-tools/skills/validate/SKILL.md index 20732fa556..f221e1288c 100644 --- a/claude-plugins/spring-tools/skills/validate/SKILL.md +++ b/claude-plugins/spring-tools/skills/validate/SKILL.md @@ -1,14 +1,14 @@ --- -description: MUST be used whenever a user or agent asks to find problems, check for issues, or validate changes in a Spring Boot project. Uses the spring-tools LSP plugin to check for validation problems in the project's source files. +description: MUST be used whenever a user or agent asks to find problems, check for issues, or validate changes in a Spring Boot project. Uses the spring-tools MCP server to check for validation problems in the project's source files. allowed-tools: View --- -CRITICAL REQUIREMENT: Whenever a user asks for problems in the project, or an agent is attempting to check if there are problems with the project, or attempts to validate changes to the project, IF the project is a Spring Boot project, then the `spring-tools` LSP plugin MUST be used to check for validation problems in each source file of the project. +CRITICAL REQUIREMENT: Whenever a user asks for problems or validations or diagnsotics or problem markers in the project, or an agent is attempting to check if there are problems with the project, or attempts to validate changes to the project, IF the project is a Spring Boot project, then the `spring-tools` plugin MCP server MUST be used to check for validation problems in each source file of the project. To perform this validation: -1. Use the `View` tool to open the modified files (or the files you want to check). In Claude Code, opening a file triggers the Language Server Protocol (LSP), and any errors or warnings will automatically appear in your context as diagnostics. -2. Carefully review the returned diagnostics for any diagnostic that originated from the `spring-tools` LSP plugin. +1. Use the `getDiagnostics` tool from the spring tools MCP server to get a list of diagnostics for a specific project. You need to pass in the name of the project. If you are not sure about the exact name, you can invoke `getProjectList` from the spring tools MCP server to get the exact names of the projects in the current workspace. +2. Carefully review the returned diagnostics. 3. Identify the specific error code, file path, and text range for each Spring-related diagnostic (e.g. error code "WEB_SECURITY_CONFIGURER_ADAPTER"). 4. For every Spring-specific error code you encounter, you MUST invoke the `/spring-tools:quickfix` skill, passing the error code, the file path, and the text range as arguments, to retrieve the official explanation and fix instructions. 5. Apply the appropriate fix based on the instructions, or ask the user if a choice needs to be made. -6. Also ensure any standard Java compilation or build errors are addressed. \ No newline at end of file +6. Also ensure any standard Java compilation or build errors are addressed. diff --git a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/AbstractJavaProjectCache.java b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/AbstractJavaProjectCache.java index 536ba797b5..c9cb9c67d2 100644 --- a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/AbstractJavaProjectCache.java +++ b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/AbstractJavaProjectCache.java @@ -34,7 +34,7 @@ * @param key class * @param

project class */ -public abstract class AbstractJavaProjectCache implements JavaProjectCache { +public abstract class AbstractJavaProjectCache implements JavaProjectCache, ProjectChangeNotifier { private static final Logger log = LoggerFactory.getLogger(AbstractJavaProjectCache.class); @@ -121,6 +121,13 @@ final protected void notifyProjectDeleted(P project) { listeners.forEach(l -> l.deleted(project)); } + @Override + public void notifyProjectsChanged() { + for (P project : cache.asMap().values()) { + notifyProjectChanged(project); + } + } + final protected FileObserver getFileObserver() { return server.getWorkspaceService().getFileObserver(); } diff --git a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/CompositeProjectOvserver.java b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/CompositeProjectOvserver.java index 1d69716159..23ad7c6525 100644 --- a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/CompositeProjectOvserver.java +++ b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/CompositeProjectOvserver.java @@ -18,7 +18,7 @@ * @author Alex Boyko * */ -public class CompositeProjectOvserver implements ProjectObserver { +public class CompositeProjectOvserver implements ProjectObserver, ProjectChangeNotifier { private List observers; @@ -41,4 +41,13 @@ public boolean isSupported() { return observers.stream().map(o -> o.isSupported()).reduce(false, (a, b) -> a || b); } + @Override + public void notifyProjectsChanged() { + for (ProjectObserver o : observers) { + if (o instanceof ProjectChangeNotifier) { + ((ProjectChangeNotifier) o).notifyProjectsChanged(); + } + } + } + } diff --git a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/ProjectChangeNotifier.java b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/ProjectChangeNotifier.java new file mode 100644 index 0000000000..fcb6510705 --- /dev/null +++ b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/languageserver/java/ProjectChangeNotifier.java @@ -0,0 +1,20 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.languageserver.java; + +/** + * Interface for notifying that projects have changed. + */ +public interface ProjectChangeNotifier { + + void notifyProjectsChanged(); + +} diff --git a/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/BasicFileObserver.java b/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/BasicFileObserver.java index 33cefbf82b..08f53697e9 100644 --- a/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/BasicFileObserver.java +++ b/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/BasicFileObserver.java @@ -32,7 +32,7 @@ * @author Alex Boyko * */ -public class BasicFileObserver implements FileObserver { +public class BasicFileObserver implements FileObserver, FileChangeNotifier { protected ConcurrentHashMap, Consumer>> createRegistry = new ConcurrentHashMap<>(); protected ConcurrentHashMap, Consumer>> deleteRegistry = new ConcurrentHashMap<>(); diff --git a/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/FileChangeNotifier.java b/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/FileChangeNotifier.java new file mode 100644 index 0000000000..e7d83b9fc4 --- /dev/null +++ b/headless-services/commons/commons-util/src/main/java/org/springframework/ide/vscode/commons/util/FileChangeNotifier.java @@ -0,0 +1,26 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.commons.util; + +/** + * Interface for notifying about file system changes. + * + * @author Alex Boyko + */ +public interface FileChangeNotifier { + + void notifyFileCreated(String uri); + + void notifyFileChanged(String uri); + + void notifyFileDeleted(String uri); + +} diff --git a/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/LegacyJavaProjectsService.java b/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/LegacyJavaProjectsService.java index b7b490eda6..486e834449 100644 --- a/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/LegacyJavaProjectsService.java +++ b/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/LegacyJavaProjectsService.java @@ -29,6 +29,7 @@ import org.springframework.ide.vscode.commons.javadoc.JavaDocProviders; import org.springframework.ide.vscode.commons.languageserver.java.CompositeJavaProjectFinder; import org.springframework.ide.vscode.commons.languageserver.java.CompositeProjectOvserver; +import org.springframework.ide.vscode.commons.languageserver.java.ProjectChangeNotifier; import org.springframework.ide.vscode.commons.languageserver.java.ProjectObserver; import org.springframework.ide.vscode.commons.languageserver.util.SimpleLanguageServer; import org.springframework.ide.vscode.commons.maven.MavenCore; @@ -45,7 +46,7 @@ * Its presence on the classpath causes {@link BootLanguageServerBootApp} to skip creating * the JDT-LS-backed {@code JavaProjectsService} bean. */ -public class LegacyJavaProjectsService implements JavaProjectsService, ApplicationListener { +public class LegacyJavaProjectsService implements JavaProjectsService, ApplicationListener, ProjectChangeNotifier { private static final Logger log = LoggerFactory.getLogger(LegacyJavaProjectsService.class); @@ -60,13 +61,13 @@ public class LegacyJavaProjectsService implements JavaProjectsService, Applicati public LegacyJavaProjectsService(SimpleLanguageServer server) { this.mavenProjectCache = new MavenProjectCache(server, MavenCore.getDefault(), false, null, - (uri, cpe) -> JavaDocProviders.createFor(cpe)); - mavenProjectCache.setAlwaysFireEventOnFileChanged(true); + (uri, cpe) -> JavaDocProviders.createFor(cpe)); + this.mavenProjectCache.setAlwaysFireEventOnFileChanged(true); this.gradleProjectCache = new GradleProjectCache(server, GradleCore.getDefault(), false, null, - (uri, cpe) -> JavaDocProviders.createFor(cpe)); - gradleProjectCache.setAlwaysFireEventOnFileChanged(true); - + (uri, cpe) -> JavaDocProviders.createFor(cpe)); + this.gradleProjectCache.setAlwaysFireEventOnFileChanged(true); + this.projectFinder = new CompositeJavaProjectFinder(); projectFinder.addJavaProjectFinder(new MavenProjectFinder(mavenProjectCache)); projectFinder.addJavaProjectFinder(new GradleProjectFinder(gradleProjectCache)); @@ -100,6 +101,11 @@ public void addListener(ProjectObserver.Listener listener) { public void removeListener(ProjectObserver.Listener listener) { projectObserver.removeListener(listener); } + + @Override + public void notifyProjectsChanged() { + this.projectObserver.notifyProjectsChanged(); + } @Override public boolean isSupported() { diff --git a/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/StandaloneProjectServiceConfig.java b/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/StandaloneProjectServiceConfig.java index 916d1f42c9..1ad6abc64f 100644 --- a/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/StandaloneProjectServiceConfig.java +++ b/headless-services/spring-boot-language-server-standalone/src/main/java/org/springframework/ide/vscode/boot/app/StandaloneProjectServiceConfig.java @@ -27,7 +27,7 @@ */ @Configuration(proxyBeanMethods = false) public class StandaloneProjectServiceConfig { - + @Bean JavaProjectsService javaProjectsService(SimpleLanguageServer server) { return new LegacyJavaProjectsService(server); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java index 8880d1814a..fe3f89a0e6 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java @@ -255,11 +255,12 @@ public void removeSymbols(IJavaProject project, String docURI) { log.info("update settings of spring indexer - done"); }); - server.doOnInitialized(this::serverInitialized); +// server.doOnInitialized(this::registerFileListeners); + registerFileListeners(); server.onShutdown(this::shutdown); } - public void serverInitialized() { + public void registerFileListeners() { List globPattern = Stream.concat(Arrays.stream(springIndexerJava.getFileWatchPatterns()), Arrays.stream(factoriesIndexer.getFileWatchPatterns())) .collect(Collectors.toList()); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/DocumentDescriptor.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/DocumentDescriptor.java index f81921cd37..b494d24e4e 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/DocumentDescriptor.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/utils/DocumentDescriptor.java @@ -57,7 +57,7 @@ public static DocumentDescriptor createFromUri(String docUri) { try { File file = new File(new URI(docUri)); long lastModified = file.lastModified(); - return new DocumentDescriptor(file.getAbsolutePath(), docUri, lastModified); + return new DocumentDescriptor(file.getAbsolutePath(), UriUtil.toUri(file).toASCIIString(), lastModified); } catch (URISyntaxException e) { throw new RuntimeException(e); } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/DiagnosticsMcpTools.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/DiagnosticsMcpTools.java index c8e80eca87..8314a9f2cc 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/DiagnosticsMcpTools.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/DiagnosticsMcpTools.java @@ -10,6 +10,7 @@ *******************************************************************************/ package org.springframework.ide.vscode.boot.mcp; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -22,6 +23,8 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ide.vscode.boot.app.SpringSymbolIndex; import org.springframework.ide.vscode.boot.java.reconcilers.CachedDiagnostic; +import org.springframework.ide.vscode.boot.validation.generations.ProjectVersionDiagnosticProvider; +import org.springframework.ide.vscode.boot.validation.generations.ProjectVersionDiagnosticProvider.DiagnosticResult; import org.springframework.ide.vscode.commons.java.IJavaProject; import org.springframework.ide.vscode.commons.languageserver.java.JavaProjectFinder; import org.springframework.stereotype.Component; @@ -39,10 +42,13 @@ public class DiagnosticsMcpTools { private final JavaProjectFinder projectFinder; private final SpringSymbolIndex symbolIndex; + private final ProjectVersionDiagnosticProvider versionDiagnosticProvider; - public DiagnosticsMcpTools(JavaProjectFinder projectFinder, SpringSymbolIndex symbolIndex) { + public DiagnosticsMcpTools(JavaProjectFinder projectFinder, SpringSymbolIndex symbolIndex, + ProjectVersionDiagnosticProvider versionDiagnosticProvider) { this.projectFinder = projectFinder; this.symbolIndex = symbolIndex; + this.versionDiagnosticProvider = versionDiagnosticProvider; } /** @@ -71,7 +77,7 @@ public static record ProjectDiagnostic( ) {} @Tool(description = """ - Returns all current Spring Tools diagnostics (errors, warnings, hints) for a specific project. + Returns all current Spring Tools diagnostics or problems or validations (errors, warnings, infos, hints) for a specific project. Diagnostics are produced by the Spring Tools language server during indexing and validation. Each entry identifies the source file, the exact source location, the severity, and the message. Only diagnostics known to Spring Tools are included; general Java compiler errors are not. @@ -87,11 +93,19 @@ public List getProjectDiagnostics( IJavaProject project = getProject(projectName); - List cached = symbolIndex.getJavaIndexer() - .getCacheHelper() - .getAllCachedDiagnostics(project); + List result = new ArrayList<>(); + addIndexerDiagnostics(project, result); + addVersionValidationDiagnostics(project, result); + + logger.info("found {} diagnostics for project: {}", result.size(), projectName); + return result; + } - List result = cached.stream() + private void addIndexerDiagnostics(IJavaProject project, List result) { + symbolIndex.getJavaIndexer() + .getCacheHelper() + .getAllCachedDiagnostics(project) + .stream() .map(cd -> new ProjectDiagnostic( cd.getDocURI(), cd.getDiagnostic().getRange().getStart().getLine(), @@ -103,10 +117,34 @@ public List getProjectDiagnostics( extractCode(cd), cd.getDiagnostic().getSource() )) - .toList(); + .forEach(result::add); + } - logger.info("found {} diagnostics for project: {}", result.size(), projectName); - return result; + private void addVersionValidationDiagnostics(IJavaProject project, List result) { + try { + DiagnosticResult versionResult = versionDiagnosticProvider.getDiagnostics(project); + if (versionResult == null || versionResult.getDiagnostics().isEmpty()) { + return; + } + String buildFileUri = versionResult.getDocumentUri().toASCIIString(); + versionResult.getDiagnostics().stream() + .map(d -> new ProjectDiagnostic( + buildFileUri, + d.getRange().getStart().getLine(), + d.getRange().getStart().getCharacter(), + d.getRange().getEnd().getLine(), + d.getRange().getEnd().getCharacter(), + severityToString(d.getSeverity()), + d.getMessage().isLeft() ? d.getMessage().getLeft() + : d.getMessage().getRight() != null ? d.getMessage().getRight().getValue() : null, + d.getCode() != null && d.getCode().isLeft() ? d.getCode().getLeft() + : d.getCode() != null && d.getCode().isRight() ? String.valueOf(d.getCode().getRight()) : null, + d.getSource() + )) + .forEach(result::add); + } catch (Exception e) { + logger.warn("Failed to retrieve version validation diagnostics for project: {}", project.getElementName(), e); + } } private String severityToString(DiagnosticSeverity severity) { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/FileChangesMcpTools.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/FileChangesMcpTools.java new file mode 100644 index 0000000000..59f243952d --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/FileChangesMcpTools.java @@ -0,0 +1,109 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.mcp; + +import java.io.File; +import java.net.URI; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.ide.vscode.commons.languageserver.java.ProjectChangeNotifier; +import org.springframework.ide.vscode.commons.languageserver.util.SimpleLanguageServer; +import org.springframework.ide.vscode.commons.util.FileChangeNotifier; +import org.springframework.stereotype.Component; + +/** + * MCP tools for notifying the language server about file changes on disk. + * This bridges the gap when running in MCP-only mode without full LSP file watching. + */ +@Component +@ConditionalOnBean({FileChangeNotifier.class}) +public class FileChangesMcpTools { + + private static final Logger logger = LoggerFactory.getLogger(FileChangesMcpTools.class); + + private final FileChangeNotifier fileChangeNotifier; + private final ProjectChangeNotifier projectChangeNotifier; + private final SimpleLanguageServer server; + + public FileChangesMcpTools(FileChangeNotifier fileChangeNotifier, ProjectChangeNotifier projectChangeNotifier, SimpleLanguageServer server) { + + logger.info("FileChangesMcpTools constructor called"); + + this.fileChangeNotifier = fileChangeNotifier; + this.projectChangeNotifier = projectChangeNotifier; + this.server = server; + } + + @Tool(description = "Notifies the MCP Server that a file has been created or modified on disk so it can update its internal index and diagnostics.") + public void fileChanged( + @ToolParam(description = "Absolute path to the file") String filePath) { + + if (filePath == null || filePath.startsWith("$")) { + logger.warn("fileChanged called but could not extract file path. filePath={}", filePath); + return; + } + + if (server.getClient() != null) { + logger.info("LSP client connected, skipping fileChanged MCP notification for: {}", filePath); + return; + } + + logger.info("MCP fileChanged called for file: {}", filePath); + + try { + URI uri = new File(filePath).toURI(); + fileChangeNotifier.notifyFileChanged(uri.toASCIIString()); + } catch (Exception e) { + logger.error("Failed to notify file changed for: " + filePath, e); + } + } + + @Tool(description = "Notifies the MCP Server that a file has been deleted on disk so it can update its internal index and diagnostics.") + public void fileDeleted( + @ToolParam(description = "Absolute path to the file") String filePath) { + + if (filePath == null || filePath.startsWith("$")) { + logger.warn("fileDeleted called but could not extract file path. filePath={}", filePath); + return; + } + + if (server.getClient() != null) { + logger.info("LSP client connected, skipping fileDeleted MCP notification for: {}", filePath); + return; + } + + logger.info("MCP fileDeleted called for file: {}", filePath); + + try { + URI uri = new File(filePath).toURI(); + fileChangeNotifier.notifyFileDeleted(uri.toASCIIString()); + } catch (Exception e) { + logger.error("Failed to notify file deleted for: " + filePath, e); + } + } + + @Tool(description = "Notifies the MCP Server to refresh the entire workspace (e.g., after a git checkout or pull).") + public void refreshWorkspace() { + logger.info("MCP refreshWorkspace called"); + + if (server.getClient() != null) { + logger.info("LSP client connected, skipping refreshWorkspace MCP notification."); + return; + } + + projectChangeNotifier.notifyProjectsChanged(); + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/McpConfig.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/McpConfig.java index d3cd30ee80..1f694c7c09 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/McpConfig.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/McpConfig.java @@ -10,13 +10,15 @@ *******************************************************************************/ package org.springframework.ide.vscode.boot.mcp; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Optional; import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - import org.springframework.ide.vscode.boot.mcp.prompts.Prompts; import io.modelcontextprotocol.server.McpServerFeatures; @@ -36,9 +38,10 @@ ToolCallbackProvider registerTools( StereotypeInformation stereotypeInformation, RequestMappingMcpTools requestMappingMcpTools, ComponentAnalysisMcpTools componentAnalysisMcpTools, - DiagnosticsMcpTools diagnosticsMcpTools) { + DiagnosticsMcpTools diagnosticsMcpTools, + Optional fileChangesMcpTools) { - return ToolCallbackProvider.from(ToolCallbacks.from( + List tools = new ArrayList<>(Arrays.asList( springVersionsAndGenerations, springIoApiMcpTools, springIndexAccess, @@ -47,6 +50,10 @@ ToolCallbackProvider registerTools( requestMappingMcpTools, componentAnalysisMcpTools, diagnosticsMcpTools)); + + fileChangesMcpTools.ifPresent(tools::add); + + return ToolCallbackProvider.from(ToolCallbacks.from(tools.toArray())); } /** diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/utils/test/MockProjectObserver.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/utils/test/MockProjectObserver.java index 32fe67ee27..7a1ca6065b 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/utils/test/MockProjectObserver.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/utils/test/MockProjectObserver.java @@ -4,10 +4,11 @@ import java.util.List; import java.util.function.Consumer; +import org.springframework.ide.vscode.commons.languageserver.java.ProjectChangeNotifier; import org.springframework.ide.vscode.commons.languageserver.java.ProjectObserver; import org.springframework.ide.vscode.commons.languageserver.java.ProjectObserver.Listener; -public class MockProjectObserver implements ProjectObserver { +public class MockProjectObserver implements ProjectObserver, ProjectChangeNotifier { List listeners = new ArrayList<>(); @@ -21,6 +22,11 @@ synchronized public void removeListener(Listener l) { listeners.remove(l); } + @Override + public void notifyProjectsChanged() { + // Mock implementation + } + public synchronized void doWithListeners(Consumer action) { for (Listener l : listeners) { action.accept(l); diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java index 93a35231d9..3c8b9c1f47 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java @@ -43,6 +43,7 @@ import org.springframework.ide.vscode.commons.protocol.java.Classpath; import org.springframework.ide.vscode.commons.protocol.java.Classpath.CPE; import org.springframework.ide.vscode.commons.protocol.java.Jre; +import org.springframework.ide.vscode.commons.util.FileChangeNotifier; import org.springframework.ide.vscode.commons.util.FileObserver; import org.springframework.ide.vscode.commons.util.IOUtil; @@ -199,7 +200,7 @@ public String uri(String projectRelativePath) { } } - public class MockProjectObserver implements ProjectObserver { + public class MockProjectObserver implements ProjectObserver, org.springframework.ide.vscode.commons.languageserver.java.ProjectChangeNotifier { public final LinkedHashSet listeners = new LinkedHashSet<>(); @@ -213,6 +214,19 @@ public void removeListener(Listener l) { listeners.remove(l); } + @Override + public void notifyProjectsChanged() { + synchronized (projectsByName) { + for (MockProject project : projectsByName.values()) { + synchronized (listeners) { + for (Listener l : listeners) { + l.changed(project); + } + } + } + } + } + } private static class FileListener { @@ -248,7 +262,7 @@ private PathMatcher buildPathMatcher(List globPatterns) { } } - public class MockFileObserver implements FileObserver { + public class MockFileObserver implements FileObserver, FileChangeNotifier { final AtomicLong idGen = new AtomicLong(); @@ -272,6 +286,21 @@ public void fileCreated(File target) { notify(create_listeners, target); } + @Override + public void notifyFileCreated(String uri) { + try { fileCreated(new File(new URI(uri))); } catch (Exception e) {} + } + + @Override + public void notifyFileChanged(String uri) { + try { fileChanged(new File(new URI(uri))); } catch (Exception e) {} + } + + @Override + public void notifyFileDeleted(String uri) { + try { notify(delete_listeners, new File(new URI(uri))); } catch (Exception e) {} + } + private void notify(Map listeners, File target) { Path path = target.toPath(); synchronized (listeners) {