diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/util/fs.util.kt b/plugins/mcp-server/src/com/intellij/mcpserver/util/fs.util.kt index d00784834273c..7f5f1344114a7 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/util/fs.util.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/util/fs.util.kt @@ -38,13 +38,61 @@ val Project.projectDirectory: Path else mcpFail("The project directory cannot be determined.") } +/** + * Returns all root paths that should be considered "inside" the project. + * + * This includes both the [projectDirectory] and any content roots registered with the project. + * Content roots are important for project types like Bazel/IJwB where [projectDirectory] points + * to a subdirectory (e.g., `.ijwb/`) while source files reside in the workspace root. + */ +private fun Project.allProjectRoots(): List { + val roots = mutableListOf(projectDirectory.normalize()) + try { + ProjectRootManager.getInstance(this).contentRoots + .mapNotNull { it.toNioPathOrNull()?.normalize() } + .forEach { roots.add(it) } + } + catch (_: Throwable) { + // ProjectRootManager might not be available in all contexts + } + return roots.distinct() +} + /** * Resolves a relative path against the project's directory. * - * When [throwWhenOutside] is true the method throws an McpExpectedException if the path is outside the project directory. + * When [throwWhenOutside] is true the method throws an McpExpectedException if the path is outside the project directory + * or any of its content roots. + * + * For project types like Bazel/IJwB where the project directory (e.g., `.ijwb/`) differs from the workspace root, + * this method also tries to resolve the path against each content root if the primary resolution does not find an + * existing file. */ fun Project.resolveInProject(pathInProject: String, throwWhenOutside: Boolean = true): Path { - return resolveInProject(pathInProject = pathInProject, projectDirectory = projectDirectory, throwWhenOutside = throwWhenOutside) + val roots = allProjectRoots() + val filePath = projectDirectory.resolve(pathInProject).normalize() + + // If the path resolves to an existing file under projectDirectory, use it directly + if (filePath.toFile().exists() && filePath.startsWith(projectDirectory)) { + return filePath + } + + // Try resolving against content roots (needed for Bazel/IJwB projects) + for (root in roots) { + val candidate = root.resolve(pathInProject).normalize() + if (candidate.toFile().exists()) { + if (throwWhenOutside && roots.none { candidate.startsWith(it) }) { + mcpFail("Specified path '$candidate' points to the location outside of the project directory") + } + return candidate + } + } + + // Fall back to the original resolution + if (throwWhenOutside && roots.none { filePath.startsWith(it) }) { + mcpFail("Specified path '$filePath' points to the location outside of the project directory") + } + return filePath } /** @@ -106,7 +154,20 @@ private suspend fun findMostRelevantProject(path: Path): Project? { // here we will have 2 project matches: `frontend/common` and `frontend` and better to prefer `frontend/common` val pairs = openProjects.mapNotNull { project -> val openProjectPath = if (project is ProjectStoreOwner) project.componentStore.projectBasePath.normalize() else return@mapNotNull null - if (targetNormalizedPath.startsWith(openProjectPath)) project to path else null + // Check against projectBasePath first + if (targetNormalizedPath.startsWith(openProjectPath)) return@mapNotNull project to openProjectPath + // Also check against content roots (needed for Bazel/IJwB where projectBasePath is a subdirectory) + try { + val contentRoots = ProjectRootManager.getInstance(project).contentRoots + for (root in contentRoots) { + val rootPath = root.toNioPathOrNull()?.normalize() ?: continue + if (targetNormalizedPath.startsWith(rootPath)) return@mapNotNull project to rootPath + } + } + catch (_: Throwable) { + // ProjectRootManager might not be available + } + null }.sortedByDescending { it.second.nameCount } logger.trace { "Found projects for path $path: ${pairs.joinToString { it.first.basePath ?: "null"}}" } return pairs.firstOrNull()?.first