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 cd924edfa004..909869f89801 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 @@ -4,6 +4,7 @@ import com.intellij.mcpserver.mcpFail import com.intellij.openapi.components.serviceAsync import com.intellij.openapi.diagnostic.fileLogger import com.intellij.openapi.diagnostic.trace +import com.intellij.openapi.project.BaseProjectDirectories.Companion.getBaseDirectories import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.io.FileUtilRt @@ -61,6 +62,30 @@ fun resolveInProject(pathInProject: String, projectDirectory: Path, throwWhenOut return filePath } +/** + * The roots [BaseProjectDirectories] reports for this project, or an empty list when they cannot be obtained. + */ +private fun Project.baseProjectDirectories(): List = + readSafely("the base directories", emptyList()) { + getBaseDirectories().mapNotNull { it.toNioPathOrNull()?.normalize() } + } + +/** + * Reads a part of the project state, and returns [fallback] when the project cannot supply it. A project that is + * disposed in parallel throws from every service it owns, and one failed project must not break the whole lookup. + */ +private fun Project.readSafely(what: String, fallback: T, action: () -> T): T = + try { + action() + } + catch (ce: CancellationException) { + throw ce + } + catch (error: Throwable) { + logger.warn("Failed to read $what of the project '$name'", error) + fallback + } + suspend fun findMostRelevantProjectForRoots(roots: Collection): Project? { return roots.firstNotNullOfOrNull { findMostRelevantProject (it) } } @@ -99,18 +124,61 @@ private suspend fun findMostRelevantProject(path: Path): Project? { val targetNormalizedPath = path.normalize() val openProjects = serviceAsync().openProjects + // a project is matched not only by its own directory, but by every directory `BaseProjectDirectories` reports for it: + // a solution opened from a repository subdirectory keeps the repository root attached, and that root is a valid + // location of the project even though the project directory is below it + // // prefer most inner directories // let's say we have // - frontend (a project) // - frontend/common (also a separate project but in the inner dir) // - frontend/common/src <-- this path passed as `path` // 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 - }.sortedByDescending { it.second.nameCount } - logger.trace { "Found projects for path $path: ${pairs.joinToString { it.first.basePath ?: "null"}}" } - return pairs.firstOrNull()?.first + val matches = openProjects.flatMap { it.matchingDirectories(targetNormalizedPath) }.sortedWith(MOST_INNER_MATCH_FIRST) + logger.trace { "Found projects for path $path: ${matches.joinToString { "${it.project.basePath} (matched by ${it.directory})" }}" } + val bestMatch = matches.firstOrNull() ?: return null + + // two projects opened from the same repository report the same repository root, and the root alone does not tell one + // from the other: report no project, so that the caller asks for an explicit project path + if (!bestMatch.isProjectDirectory && + matches.any { it.project !== bestMatch.project && it.directory == bestMatch.directory }) { + logger.trace { "The path $path matches the directory ${bestMatch.directory} of more than one project" } + return null + } + return bestMatch.project +} + +/** + * A directory of [project] that contains the looked-up path. [isProjectDirectory] tells the project's own directory from + * a base directory of the project. + */ +private class ProjectDirectoryMatch(val project: Project, val directory: Path, val isProjectDirectory: Boolean) + +/** + * Orders matches from the most to the least specific: a deeper directory wins, and the project's own directory wins over + * a base directory pointing at the very same place. + */ +private val MOST_INNER_MATCH_FIRST: Comparator = + compareByDescending { it.directory.nameCount }.thenByDescending { it.isProjectDirectory } + +/** + * The directories of this project that contain [targetNormalizedPath]. + */ +private fun Project.matchingDirectories(targetNormalizedPath: Path): List { + if (isDisposed) return emptyList() + + return readSafely("the directories", emptyList()) { + val projectPath = if (this is ProjectStoreOwner) componentStore.projectBasePath.normalize() else null + + val directories = LinkedHashSet() + projectPath?.let(directories::add) + directories.addAll(baseProjectDirectories()) + + directories.mapNotNull { directory -> + if (!targetNormalizedPath.startsWith(directory)) return@mapNotNull null + ProjectDirectoryMatch(project = this, directory = directory, isProjectDirectory = directory == projectPath) + } + } } /** diff --git a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/impl/McpProjectLocationInputsTest.kt b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/impl/McpProjectLocationInputsTest.kt index 9103a46f8dda..95a1ee9bc34c 100644 --- a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/impl/McpProjectLocationInputsTest.kt +++ b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/impl/McpProjectLocationInputsTest.kt @@ -14,23 +14,35 @@ import com.intellij.openapi.components.State import com.intellij.openapi.components.StateStorageOperation import com.intellij.openapi.components.Storage import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.BaseProjectDirectories.Companion.getBaseDirectories import com.intellij.openapi.project.Project import com.intellij.openapi.project.ex.ProjectManagerEx +import com.intellij.openapi.vfs.toNioPathOrNull import com.intellij.project.ProjectStoreOwner import com.intellij.testFramework.ExtensionTestUtil import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.junit5.TestDisposable +import com.intellij.testFramework.junit5.fixture.moduleFixture import com.intellij.testFramework.junit5.fixture.projectFixture +import com.intellij.testFramework.junit5.fixture.sourceRootFixture +import com.intellij.testFramework.junit5.fixture.tempPathFixture +import com.intellij.testFramework.junit5.fixture.testFixture import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.nio.file.Files import java.nio.file.Path +import kotlin.io.path.createDirectories import kotlin.io.path.invariantSeparatorsPathString +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes @TestApplication class McpProjectLocationInputsTest { @@ -39,6 +51,35 @@ class McpProjectLocationInputsTest { val firstProject by firstProjectFixture val secondProjectFixture = projectFixture(openAfterCreation = true) val secondProject by secondProjectFixture + + /** + * A directory above the project directory, akin to a repository root attached to a solution opened from + * a subdirectory of that repository: a content root of the project, but not the project directory itself. + */ + val outerRootPathFixture = tempPathFixture() + val outerRootProjectFixture = projectFixture(openAfterCreation = true) + val outerRootProject by outerRootProjectFixture + val outerRoot by outerRootProjectFixture.moduleFixture().sourceRootFixture(pathFixture = outerRootPathFixture) + + /** A project whose own directory is located under [outerRoot]. */ + val nestedProjectPathFixture = testFixture("nested project directory") { + val path = outerRootPathFixture.init().resolve("nested-project") + withContext(Dispatchers.IO) { path.createDirectories() } + initialized(path) {} + } + val nestedProject by projectFixture(pathFixture = nestedProjectPathFixture, openAfterCreation = true) + + /** + * A directory that is a content root of two projects at once, akin to a repository that holds two solutions. It + * tells neither project from the other. + */ + val sharedRootPathFixture = tempPathFixture() + val firstSharedRootProjectFixture = projectFixture(openAfterCreation = true) + val firstSharedRootProject by firstSharedRootProjectFixture + val firstSharedRoot by firstSharedRootProjectFixture.moduleFixture().sourceRootFixture(pathFixture = sharedRootPathFixture) + val secondSharedRootProjectFixture = projectFixture(openAfterCreation = true) + val secondSharedRootProject by secondSharedRootProjectFixture + val secondSharedRoot by secondSharedRootProjectFixture.moduleFixture().sourceRootFixture(pathFixture = sharedRootPathFixture) } @Test @@ -183,6 +224,94 @@ class McpProjectLocationInputsTest { }) } + @Test + fun `projectPath argument pointing at a content root outside the project directory resolves that project`() { + runBlocking(Dispatchers.Default) { + val outerRootPath = outerRoot.virtualFile.toNioPath() + awaitBaseDirectory(outerRootProject, outerRootPath) + + val project = McpProjectLocationInputs( + projectPathFromArgument = outerRootPath.invariantSeparatorsPathString, + projectPathFromCallHeader = null, + projectPathFromSessionHeader = null, + roots = emptySet(), + ).resolveProject() + + assertThat(project).isSameAs(outerRootProject) + } + } + + @Test + fun `roots pointing at a content root outside the project directory resolve that project`() { + runBlocking(Dispatchers.Default) { + val outerRootPath = outerRoot.virtualFile.toNioPath() + awaitBaseDirectory(outerRootProject, outerRootPath) + + val project = McpProjectLocationInputs( + projectPathFromArgument = null, + projectPathFromCallHeader = null, + projectPathFromSessionHeader = null, + roots = setOf(outerRootPath.toUri().toString()), + ).resolveProject() + + assertThat(project).isSameAs(outerRootProject) + } + } + + @Test + fun `innermost project wins over a project matched by its outer content root`() { + runBlocking(Dispatchers.Default) { + val outerRootPath = outerRoot.virtualFile.toNioPath() + awaitBaseDirectory(outerRootProject, outerRootPath) + val nestedProjectPath = Path.of(nestedProject.basePath!!) + assertThat(nestedProjectPath).startsWith(outerRootPath) + + val project = McpProjectLocationInputs( + projectPathFromArgument = nestedProjectPath.invariantSeparatorsPathString, + projectPathFromCallHeader = null, + projectPathFromSessionHeader = null, + roots = emptySet(), + ).resolveProject() + + assertThat(project).isSameAs(nestedProject) + } + } + + @Test + fun `a content root shared by two projects resolves no project`() { + runBlocking(Dispatchers.Default) { + val sharedRootPath = firstSharedRoot.virtualFile.toNioPath() + assertThat(secondSharedRoot.virtualFile.toNioPath()).isEqualTo(sharedRootPath) + awaitBaseDirectory(firstSharedRootProject, sharedRootPath) + awaitBaseDirectory(secondSharedRootProject, sharedRootPath) + + assertThatThrownBy { + runBlocking(Dispatchers.Default) { + McpProjectLocationInputs( + projectPathFromArgument = sharedRootPath.invariantSeparatorsPathString, + projectPathFromCallHeader = null, + projectPathFromSessionHeader = null, + roots = emptySet(), + ).resolveProject() + } + } + .isInstanceOf(McpExpectedError::class.java) + .hasMessageContaining("doesn't correspond to any open project.") + } + } + + /** + * [com.intellij.openapi.project.BaseProjectDirectories] picks workspace model changes up asynchronously. + */ + private suspend fun awaitBaseDirectory(project: Project, directory: Path) { + val normalizedDirectory = directory.normalize() + withTimeout(1.minutes) { + while (project.getBaseDirectories().none { it.toNioPathOrNull()?.normalize() == normalizedDirectory }) { + delay(50.milliseconds) + } + } + } + private fun projectRootUri(project: Project): String = Path.of(project.basePath!!).toUri().toString() }