Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
- 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<T>` 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)
- feat: List test-source applications last with `testSource` in the MCP application listing (#602)
- fix: Serve an active candidate on the first page of `explyt_find_spring_bean` whenever the budget holds one: the `inactiveCandidates` sample now gives way to the first active candidate, down to none with `inactiveCandidatesTruncated`, and still takes at most half of `maxChars`, instead of leaving a small budget with `RESPONSE_TOO_LARGE`, whose hint now names the `maxChars` that serves the first active candidate; a continuation of a static answer of `explyt_find_spring_bean` or `explyt_get_spring_beans` across a toggle of bean condition evaluation answers `RESULT_CHANGED` (#634)
- feat: List project beans (and those of unknown origin) excluded by a `@Conditional` other than `@Profile` in `explyt_get_spring_beans` instead of hiding them: they come after every other row with `condition` — `state: INACTIVE` and the `annotation`, `carrier` and `detail` of the excluding condition — and count in `totalCount`, the `origin` filter and the continuation; the rows of library beans excluded by a condition are counted in `inactiveLibraryCount` and listed only with `origin=LIBRARY`, after the active library rows; a bean whose condition cannot be decided keeps its place with `condition` in `state: UNDECIDED` and its `reasons`, and the envelope-level `limitations` names `CONDITIONS_NOT_EVALUATED` when condition evaluation is disabled (#496)
- fix: Name the handler of a Kotlin router DSL route `path("/x", handler)` in `explyt_get_spring_endpoint_contract`: its partial contract listed no `serviceCalls`, because only routes named after an HTTP verb were read, so `handler::handle` is now its `serviceCall` like that of `GET("/x", handler::handle)`, and `explyt_trace_spring_call_chain` started at such a route begins at its handler and marks it with the route `ALL /x` (#607)
- feat: Show why a project Actuator endpoint's bean is off in `explyt_find_spring_endpoint`, `explyt_get_spring_http_endpoints` and `explyt_get_spring_endpoint_contract`: the endpoint stays listed with `beanCondition` — `state: INACTIVE` with the `annotation`, `carrier` and `detail` of the condition that excludes it, or `state: UNDECIDED` when its conditions cannot be decided statically, naming the first undecided condition and listing every undecided reason in `reasons` — while `exposed` and `access` keep describing the configured exposure; the key is absent when no condition excludes the bean or leaves it undecided, and when bean condition evaluation is disabled in the settings (#496)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package com.explyt.spring.ai.mcp

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode

Expand Down Expand Up @@ -94,17 +95,18 @@ class BoundedPageWriter {
sampleTruncatedField: String,
totalCount: Int,
revision: String,
page: PageRequest
page: PageRequest,
firstItem: ObjectNode? = null,
maxSampleChars: Int = Int.MAX_VALUE,
): ObjectNode {
val fitted = envelope.deepCopy()
val served = fitted.putArray(sampleField)
fitted.put(sampleTruncatedField, true)
for (item in sample) {
served.add(item)
val probe = pageEnvelope(fitted, revision, totalCount, page.offset).apply { putArray(itemsField) }
val placeholderLength = encodedLength(probe)
setContinuation(probe, totalCount, page.offset)
if (maxOf(placeholderLength, encodedLength(probe)) > page.maxChars) {
if (encodedLength(served) > maxSampleChars ||
firstPageLength(fitted, itemsField, firstItem, totalCount, revision, page.offset) > page.maxChars
) {
served.remove(served.size() - 1)
break
}
Expand All @@ -113,6 +115,24 @@ class BoundedPageWriter {
return fitted
}

private fun firstPageLength(
envelope: ObjectNode,
itemsField: String,
firstItem: ObjectNode?,
totalCount: Int,
revision: String,
offset: Int
): Int {
val probe = pageEnvelope(envelope, revision, totalCount, offset)
val items = probe.putArray(itemsField)
val placeholderLength = encodedLength(probe)
val emptyLength = maxOf(placeholderLength, terminalLength(probe, totalCount, offset))
if (firstItem == null) return emptyLength
items.add(firstItem)
setContinuation(probe, totalCount, offset + 1)
return maxOf(emptyLength, encodedLength(probe))
}

fun writeError(
code: String,
message: String,
Expand Down Expand Up @@ -237,7 +257,7 @@ class BoundedPageWriter {
private fun terminalLength(root: ObjectNode, totalCount: Int, offset: Int): Int =
root.deepCopy().apply { setContinuation(this, totalCount, offset) }.let(::encodedLength)

private fun encodedLength(node: ObjectNode): Int = mapper.writeValueAsString(node).length
private fun encodedLength(node: JsonNode): Int = mapper.writeValueAsString(node).length

private val mapper = ObjectMapper()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,38 +42,48 @@ typealias BeanPageRequest = PageRequest
*/
class BoundedBeanResponseWriter {

fun write(content: BeanResponseContent, revision: String, page: BeanPageRequest): String =
writer.write(envelope(content, page), FIELD_CANDIDATES, content.totalCount, content.candidateAt, revision, page)
fun write(content: BeanResponseContent, revision: String, page: BeanPageRequest): String {
val first by lazy { content.candidateAt(page.offset) }
val candidateAt = { index: Int -> if (index == page.offset) first else content.candidateAt(index) }
val envelope = envelope(content)
val sampled = when {
content.inactiveCount == 0 || page.offset != 0 -> envelope
content.totalCount == 0 -> envelope.withInactiveSample(content, revision, page, firstCandidate = null)
else -> envelope.withInactiveSample(content, revision, page, first)
}
return writer.write(sampled, FIELD_CANDIDATES, content.totalCount, candidateAt, revision, page)
}

fun writeError(problem: BeanQueryProblem, maxChars: Int): String =
writer.writeError(problem.code, problem.message, problem.choices, maxChars)

private fun envelope(content: BeanResponseContent, page: BeanPageRequest): ObjectNode {
private fun envelope(content: BeanResponseContent): ObjectNode {
val envelope = mapper.createObjectNode()
envelope.put("mode", content.mode)
envelope.set<ObjectNode>("model", content.model)
envelope.put("outcome", content.outcome)
envelope.put("matchCompleteness", content.matchCompleteness)
envelope.put("unresolvedCount", content.unresolvedCount)
content.injection?.let { envelope.set<ObjectNode>("injection", it) }
if (content.inactiveCount > 0) {
envelope.put(FIELD_INACTIVE_COUNT, content.inactiveCount)
if (page.offset == 0) putInactiveSample(envelope, content, page.maxChars.coerceIn(0, MAX_CHARS) / 2)
}
if (content.inactiveCount > 0) envelope.put(FIELD_INACTIVE_COUNT, content.inactiveCount)
return envelope
}

private fun putInactiveSample(envelope: ObjectNode, content: BeanResponseContent, budget: Int) {
val sample = mapper.createArrayNode()
for (index in 0 until minOf(content.inactiveCount, MAX_INACTIVE_SAMPLE)) {
sample.add(content.inactiveAt(index))
if (mapper.writeValueAsString(sample).length > budget) {
sample.remove(sample.size() - 1)
break
}
}
if (!sample.isEmpty) envelope.set<ObjectNode>(FIELD_INACTIVE_CANDIDATES, sample)
if (sample.size() < content.inactiveCount) envelope.put(FIELD_INACTIVE_TRUNCATED, true)
private fun ObjectNode.withInactiveSample(
content: BeanResponseContent,
revision: String,
page: BeanPageRequest,
firstCandidate: ObjectNode?
): ObjectNode {
val sample = List(minOf(content.inactiveCount, MAX_INACTIVE_SAMPLE), content.inactiveAt)
val fitted = writer.fitSample(
this, FIELD_CANDIDATES, FIELD_INACTIVE_CANDIDATES, sample, FIELD_INACTIVE_TRUNCATED,
content.totalCount, revision, page, firstCandidate, maxSampleChars = page.maxChars / 2
)
val served = fitted[FIELD_INACTIVE_CANDIDATES].size()
if (served == 0) fitted.remove(FIELD_INACTIVE_CANDIDATES)
if (served < content.inactiveCount) fitted.put(FIELD_INACTIVE_TRUNCATED, true)
return fitted
}

private val writer = BoundedPageWriter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,42 @@ class BeanListingConditionTest : ExplytJavaLightTestCase() {
}
}

fun testTogglingConditionEvaluationChangesTheRevision() = runBlockingReleasingWriteIntent<Unit> {
addProperties("admin.marker=present")
addActiveControllers()
addSyncAdminController()
assertVerdict<ConditionVerdict.Inactive>(SYNC_ADMIN_CONTROLLER)
val settings = SpringToolRunConfigurationsSettingsState.getInstance()
val enabled = settings.isBeanFilterEnabled
assertTrue("Precondition: condition evaluation starts enabled", enabled)
val first = listing(origin = PROJECT, limit = 1)
assertEquals(listOf(ALPHA), rows(first).map { it["beanName"].asText() })
assertTrue("Precondition: the first page is truncated, got $first", first["truncated"].asBoolean())
assertFalse("Precondition: page 1 is read with conditions evaluated, got $first", first.has("limitations"))

settings.isBeanFilterEnabled = false
try {
val fresh = listing(origin = PROJECT, limit = 1)
assertEquals(
"Precondition: the toggle changes the answer, got $fresh",
listOf("CONDITIONS_NOT_EVALUATED"), fresh["limitations"]?.map { it.asText() }
)

val stale = listing(
origin = PROJECT,
limit = 1,
offset = first["nextOffset"].asInt(),
expectedRevision = first["revision"].asText()
)

assertEquals("a continuation across the toggle must restart, got $stale", "ERROR", stale["status"].asText())
assertEquals("RESULT_CHANGED", stale["error"]["code"].asText())
} finally {
settings.isBeanFilterEnabled = enabled
ModificationTrackerManager.getInstance(project).invalidateAll()
}
}

fun testInactiveLibraryBeansAreCountedInsteadOfListedByDefault() = runBlockingReleasingWriteIntent<Unit> {
addProperties("admin.marker=present")
addActiveControllers()
Expand Down
Loading
Loading