RIDER-139586 [rider-mcp]: resolve the target project by its base directories

`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
This commit is contained in:
Vladislav Annenkov
2026-09-04 10:56:58 +00:00
committed by intellij-monorepo-bot
parent 72f9c34405
commit 4f25eece90
2 changed files with 203 additions and 6 deletions
@@ -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<Path> =
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 <T> 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<String>): Project? {
return roots.firstNotNullOfOrNull { findMostRelevantProject (it) }
}
@@ -99,18 +124,61 @@ private suspend fun findMostRelevantProject(path: Path): Project? {
val targetNormalizedPath = path.normalize()
val openProjects = serviceAsync<ProjectManager>().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<ProjectDirectoryMatch> =
compareByDescending<ProjectDirectoryMatch> { it.directory.nameCount }.thenByDescending { it.isProjectDirectory }
/**
* The directories of this project that contain [targetNormalizedPath].
*/
private fun Project.matchingDirectories(targetNormalizedPath: Path): List<ProjectDirectoryMatch> {
if (isDisposed) return emptyList()
return readSafely("the directories", emptyList()) {
val projectPath = if (this is ProjectStoreOwner) componentStore.projectBasePath.normalize() else null
val directories = LinkedHashSet<Path>()
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)
}
}
}
/**
@@ -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<Path>("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()
}