From c486e927348e7c8abfc06edb973b88b2e983cd25 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 03:04:33 +0300 Subject: [PATCH 1/5] test: Pin source names in the MCP call trace (#591) --- ...ootApplicationMcpToolsetTraceCallChainTest.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt index 78257515c..cfa6427af 100644 --- a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt +++ b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt @@ -344,13 +344,19 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture fun testCompanionCallsLinkToDistinctOuterClassNodes() = runBlockingReleasingWriteIntent { val chain = traceCompanion("twoParsers") + val companion = JavaPsiFacade.getInstance(project) + .findClass("explyt.trace.VmId.Companion", GlobalSearchScope.projectScope(project))!! + val parse = companion.findMethodsByName("parse", false).filterIsInstance().single { it.kotlinOrigin != null } + val sourceName = SourceNames.qualifiedNameOf(parse)!! + assertTrue("The fixture must expose a light-class owner distinct from its source owner", sourceName.substringBeforeLast(".") != companion.qualifiedName) + assertFalse("The source owner must not use a JVM light-class separator", sourceName.any { it == 36.toChar() }) val calls = chain[0]["callsInto"].filter { it["target"].asText().endsWith(".parse") } assertEquals("Both companion calls must be present in callsInto", 2, calls.size) assertTrue("Both calls must link to expanded nodes: $calls", calls.all { it["node"].isIntegralNumber }) val nodes = calls.map { call -> chain.single { it["id"].asInt() == call["node"].asInt() } } assertEquals(2, nodes.map { it["id"].asInt() }.distinct().size) assertEquals( - setOf("explyt.trace.VmId.Companion", "explyt.trace.OtherVmId.Companion"), + setOf("explyt.trace.VmId", "explyt.trace.OtherVmId"), nodes.map { it["className"].asText() }.toSet() ) assertEquals(listOf("parse", "parse"), nodes.map { it["methodName"].asText() }) @@ -364,7 +370,13 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture assertEquals("PROJECT", call["kind"].asText()) assertTrue("The nested companion must be expanded", call["node"].isIntegralNumber) val node = chain.single { it["id"].asInt() == call["node"].asInt() } - assertEquals("explyt.trace.Outer.InnerId.Companion", node["className"].asText()) + val companion = JavaPsiFacade.getInstance(project) + .findClass("explyt.trace.Outer.InnerId.Companion", GlobalSearchScope.projectScope(project))!! + val parse = companion.findMethodsByName("parse", false).filterIsInstance().single { it.kotlinOrigin != null } + val expectedClassName = SourceNames.qualifiedNameOf(parse)!!.substringBeforeLast(".") + assertTrue("The source class name must differ from the light-class name", expectedClassName != companion.qualifiedName) + assertFalse("The source class name must not contain a JVM separator", expectedClassName.any { it == 36.toChar() }) + assertEquals(expectedClassName, node["className"].asText()) } fun testCompanionNameFallsBackToClassNavigationWithoutKotlinMethodOrigin() = runBlockingReleasingWriteIntent { From 1fb332323d54a0d3b1936b381ca8734dbef83ee8 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 03:38:38 +0300 Subject: [PATCH 2/5] test: Expect the source class name for both JvmStatic and companion trace nodes (#591) --- .../mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt index cfa6427af..aa02988b3 100644 --- a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt +++ b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt @@ -414,14 +414,14 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture assertEquals("explyt.trace.Owner.Companion.Nested", node["className"].asText()) } - fun testJavaJvmStaticAndCompanionCallsKeepDistinctNodes() = runBlockingReleasingWriteIntent { + fun testJavaJvmStaticAndCompanionCallsKeepDistinctNodesWithTheSourceClassName() = runBlockingReleasingWriteIntent { val chain = traceCompanion("bothStaticParsers", javaCaller = true) val calls = chain[0]["callsInto"].toList() assertEquals(listOf("StaticVmId.parse", "StaticVmId.parse"), calls.map { it["target"].asText() }) assertTrue(calls.all { it["node"].isIntegralNumber }) assertEquals(2, calls.map { it["node"].asInt() }.distinct().size) val nodes = calls.map { call -> chain.single { it["id"].asInt() == call["node"].asInt() } } - assertEquals(setOf("explyt.trace.StaticVmId", "explyt.trace.StaticVmId.Companion"), nodes.map { it["className"].asText() }.toSet()) + assertEquals(listOf("explyt.trace.StaticVmId", "explyt.trace.StaticVmId"), nodes.map { it["className"].asText() }) } private fun companionCall(chain: JsonNode, methodName: String): JsonNode { From 66a3e277700371b0125708524964f2b3733d3f54 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 03:43:09 +0300 Subject: [PATCH 3/5] fix: Name trace nodes of Kotlin companion members after the source class (#591) --- CHANGELOG.md | 1 + .../kotlin/com/explyt/spring/ai/mcp/SourceNames.kt | 11 +++++++++++ .../com/explyt/spring/ai/mcp/SpringMcpProvider.kt | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcadfdb0f..dc8d08281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,7 @@ ### Spring MCP +- fix: Name a trace node of a Kotlin companion member in `explyt_trace_spring_call_chain` after its outer class, as the call targets already are (#591) - docs: Explain in the descriptions of `explyt_find_spring_endpoint`, `explyt_get_spring_endpoint_contract` and `explyt_get_spring_http_endpoints` that `httpMethods` is `["ALL"]` for a route that answers every HTTP method and that such a route passes every value of the `httpMethod` filter of the find and contract tools (#653) - fix: List the test requests of every verb in 'testUrlReferences' of `explyt_trace_spring_call_chain` for a functional route that answers every HTTP method, as for a bare `@RequestMapping` handler (#659) - feat: Describe a handler returning `PagedModel` in `explyt_get_spring_endpoint_contract` as the paged model it writes (`content` and `page`, `wireShapeSource: DECLARED_TYPE`) instead of its element type, use Boot's direct default for a blank `spring.data.web.pageable.serialization-mode`, and read a composed `@EnableSpringDataWebSupport` annotation from the class that uses it; a composed annotation that declares its own `pageSerializationMode` without `@AliasFor`, or leaves its mode alias unset, reports an unknown source (#639) diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt index 8c6e45d1d..31ef78772 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt @@ -7,6 +7,7 @@ package com.explyt.spring.ai.mcp import com.intellij.psi.PsiClass import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.asJava.elements.KtLightMethod import org.jetbrains.kotlin.psi.KtClassOrObject @@ -33,6 +34,16 @@ internal object SourceNames { return "$owner.${sourceNameOf(method)}" } + fun qualifiedOwnerNameOf(method: PsiMethod): String? { + val containingClass = method.containingClass ?: return null + if (method.hasModifierProperty(PsiModifier.STATIC)) return containingClass.qualifiedName + val sourceOwner = shortNameOf(method).substringBeforeLast('.') + if (sourceOwner != containingClass.name) { + return qualifiedNameOf(method)?.substringBeforeLast('.') ?: containingClass.qualifiedName + } + return containingClass.qualifiedName + } + private fun sourceOwnerOf(method: PsiMethod): KtClassOrObject? { val declaration = kotlinOwnerOf(method) ?: return null if (declaration !is KtObjectDeclaration || !declaration.isCompanion()) return declaration diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt index 603d911ef..32c471c16 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SpringMcpProvider.kt @@ -1657,7 +1657,7 @@ class SpringBootApplicationMcpToolset : McpToolset { id = id, layer = containingClass?.let { detectSpringLayer(it) }, reachedBy = traced.reachedBy?.name, - className = containingClass?.qualifiedName ?: containingClass?.name, + className = SourceNames.qualifiedOwnerNameOf(method) ?: containingClass?.name, methodName = SourceNames.sourceNameOf(method), filePath = position.filePath, library = position.library, From 79f863395cc9961d2e576bcc79b64aebf08e8771 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 04:55:58 +0300 Subject: [PATCH 4/5] test: Pin the source class name for a companion of a class named Companion (#591) --- ...ApplicationMcpToolsetTraceCallChainTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt index aa02988b3..c2d375905 100644 --- a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt +++ b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/SpringBootApplicationMcpToolsetTraceCallChainTest.kt @@ -363,6 +363,20 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture assertTrue(nodes.all { it["filePath"].asText().endsWith("Ids.kt") }) } + fun testCompanionInsideClassNamedCompanionUsesTheClassSourceName() = runBlockingReleasingWriteIntent { + val chain = traceCompanion("classNamedCompanionParse") + val call = companionCall(chain, "parse") + assertTrue("The companion call must be expanded", call["node"].isIntegralNumber) + val lightClass = JavaPsiFacade.getInstance(project) + .findClass("explyt.trace.Companion.Companion", GlobalSearchScope.projectScope(project))!! + val parse = lightClass.findMethodsByName("parse", false).filterIsInstance().single { it.kotlinOrigin != null } + assertEquals("Companion", lightClass.name) + assertTrue("The callee must be the companion of a class named Companion", lightClass.qualifiedName!!.endsWith(".Companion.Companion")) + assertEquals("explyt.trace.Companion", SourceNames.qualifiedNameOf(parse)!!.substringBeforeLast(".")) + val node = chain.single { it["id"].asInt() == call["node"].asInt() } + assertEquals("explyt.trace.Companion", node["className"].asText()) + } + fun testNestedCompanionCallNamesItsImmediateOuterClass() = runBlockingReleasingWriteIntent { val chain = traceCompanion("nestedCompanionParse") val call = companionCall(chain, "parse") @@ -449,6 +463,11 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture fun parse(raw: String): String = raw } } + class Companion { + companion object { + fun parse(raw: String): String = raw + } + } class StaticVmId { companion object { @JvmStatic @@ -487,6 +506,7 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture fun objectParse(raw: String): String = IdFormat.parse(raw) fun nestedParse(raw: String): String = Outer.Inner().parse(raw) fun nestedCompanionParse(raw: String): String = Outer.InnerId.parse(raw) + fun classNamedCompanionParse(raw: String): String = Companion.parse(raw) fun twoParsers(raw: String): String = VmId.parse(raw) + OtherVmId.parse(raw) } """.trimIndent() From cef4a25de529fdcd9ddeb3c053d807e616481f45 Mon Sep 17 00:00:00 2001 From: Ilya Muromtsev Date: Tue, 13 Oct 2026 05:00:09 +0300 Subject: [PATCH 5/5] fix: Recognise a companion member by its declaration, not by name (#591) --- .../kotlin/com/explyt/spring/ai/mcp/SourceNames.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt index 31ef78772..b63273bba 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/SourceNames.kt @@ -17,8 +17,7 @@ import org.jetbrains.kotlin.psi.KtObjectDeclaration internal object SourceNames { fun shortNameOf(method: PsiMethod): String { - val declaration = kotlinOwnerOf(method) - val owner = if (declaration is KtObjectDeclaration && declaration.isCompanion()) { + val owner = if (isCompanionMember(method)) { sourceOwnerOf(method)?.name ?: method.containingClass?.name ?: "?" } else { method.containingClass?.name ?: "?" @@ -37,13 +36,15 @@ internal object SourceNames { fun qualifiedOwnerNameOf(method: PsiMethod): String? { val containingClass = method.containingClass ?: return null if (method.hasModifierProperty(PsiModifier.STATIC)) return containingClass.qualifiedName - val sourceOwner = shortNameOf(method).substringBeforeLast('.') - if (sourceOwner != containingClass.name) { - return qualifiedNameOf(method)?.substringBeforeLast('.') ?: containingClass.qualifiedName + if (isCompanionMember(method)) { + return sourceOwnerOf(method)?.fqName?.asString() ?: containingClass.qualifiedName } return containingClass.qualifiedName } + private fun isCompanionMember(method: PsiMethod): Boolean = + (kotlinOwnerOf(method) as? KtObjectDeclaration)?.isCompanion() == true + private fun sourceOwnerOf(method: PsiMethod): KtClassOrObject? { val declaration = kotlinOwnerOf(method) ?: return null if (declaration !is KtObjectDeclaration || !declaration.isCompanion()) return declaration