From 4f25eece90ec80fa31f13c02bbc03487c05c4df1 Mon Sep 17 00:00:00 2001 From: Vladislav Annenkov Date: Mon, 24 Aug 2026 20:41:23 +0200 Subject: [PATCH] RIDER-139586 [rider-mcp]: resolve the target project by its base directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rootFolder` pointing at a repository root was rejected with "doesn't correspond to any open project": a project was matched by its own directory only, so a solution opened from a subdirectory of the repository could not be addressed by the root the agent runs in. Match a project by every directory `BaseProjectDirectories` reports for it as well — an attached repository root is one of them. Ranking of several matches was dead code: the comparison key was the looked-up path itself, equal for all candidates, so an arbitrary project won. Order matches by the depth of the matched directory instead, preferring the project's own directory over a base directory pointing at the same place. Report no project when the same directory belongs to two projects and it is the directory of neither. Two solutions opened from one repository both report the repository root, and the root alone tells one from the other. The caller then asks for an explicit project path. RIDER-139586 fix (cherry picked from commit f01045db6812303603aafa1f9bb9eceac95c296c) GitOrigin-RevId: 6614b671f44de60d057e77b2b3dcdbc4f67fccbf --- .../com/intellij/mcpserver/util/fs.util.kt | 80 ++++++++++- .../impl/McpProjectLocationInputsTest.kt | 129 ++++++++++++++++++ 2 files changed, 203 insertions(+), 6 deletions(-) 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() }