This is an automated email from the ASF dual-hosted git repository.
epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git
The following commit(s) were added to refs/heads/main by this push:
new 04b0c72 fix(logging): load logback-spring.xml without breaking native
stdout (#189)
04b0c72 is described below
commit 04b0c723b88d6099081d6b17c6170414c3b2f2d3
Author: Aditya Parikh <[email protected]>
AuthorDate: Fri Sep 11 10:16:07 2026 -0400
fix(logging): load logback-spring.xml without breaking native stdout (#189)
`logback-spring.xml` was never loaded. HTTP mode therefore ran with no
appenders at all — no console logging and no OTLP log export — and any
startup failure exited 1 printing nothing but the Spring banner.
Reproduce on `main`:
PROFILES=http java -jar build/libs/solr-mcp-<v>.jar # 0 stdout lines
Root cause: Spring Boot's
`AbstractLoggingSystem.initializeWithConventions()`
checks the *standard* Logback locations first (`logback-test.xml`,
`logback.xml`, ...). Finding one with `logging.file.name` unset, it calls
`reinitialize()` and returns early — `logback-spring.xml` is never
consulted,
taking every `<springProfile>` appender with it. Boot's reference
documentation is explicit that `<springProfile>` "cannot be used in the
standard logback.xml file because it is loaded too early".
Deleting `logback.xml` is not the fix, because there are two logging
initialization phases and each file serves one:
1. logback's own `ContextInitializer`, on the first `LoggerFactory` touch,
before Spring Boot exists. It only ever scans the standard locations;
it
has never heard of `logback-spring.xml`. With no file it falls back to
`BasicConfigurator` and — in a native image, where logback cannot read
its own manifest and so always raises `|-WARN ... Versions of
logback-classic and ? are different or unknown` — trips
`StatusPrinter.printInCaseOfErrorsOrWarnings()`, flushing its whole
`|-INFO` status list to stdout. In the native STDIO image those lines
land in the MCP JSON-RPC stream and `initialize()` times out.
2. Spring Boot's `LoggingApplicationListener`, which owns the per-profile
appenders and must load `logback-spring.xml`.
So keep both files and set `logging.config` to break the collision:
logging.config=${LOGGING_CONFIG:classpath:logback-spring.xml}
`AbstractLoggingSystem.initialize()` branches to
`initializeWithSpecificConfig` and returns when `logging.config` is
non-empty,
skipping the standard locations entirely. Under AOT the same property
decides
what `processAot` serializes into `META-INF/spring/logback-model`, which is
what the native image actually replays — verifiable with:
strings build/resources/aot/META-INF/spring/logback-model \
| grep -E 'SpringProfile|OpenTelemetry'
Also in this change:
- Use Boot's own `console-appender.xml` instead of a hand-rolled
`ConsoleAppender`, so `logging.pattern.console` /
`logging.charset.console` /
`logging.threshold.console` behave as in a stock Boot app. The hand-rolled
encoder's fallback was already dead code: `defaults.xml` defines
`CONSOLE_LOG_PATTERN`, so `${CONSOLE_LOG_PATTERN:-...}` always resolved to
Boot's pattern — the rendered format is unchanged.
- Register both logging files as native-image resources in
`SolrNativeHints`.
In a native image `getResource()` only sees registered resources, so an
unregistered `logback.xml` is exactly as absent as a deleted one.
- Add `LoggingConfigurationTest`, pinning the pairing: a standard-location
file is allowed only while `logging.config` routes past it, and that file
must declare no appenders (phase 1 runs before any profile is known, so an
appender there reaches STDIO's stdout).
- Correct the Logging Architecture section of `AGENTS.md`, which claimed
"logback-spring.xml — Loaded by Spring Boot, overrides logback.xml." That
was the intent, not the behaviour.
Verification:
./gradlew clean build 376 tests, 0 failures, 7
skipped
./gradlew dockerIntegrationTest -Pnative 43 tests, 0 failures
(native STDIO image: 0 logback status lines on stdout,
0 "Could NOT find resource [logback.xml]", 0 JSON parse errors)
PROFILES=http java -jar <bootJar> 47 stdout lines (0 before)
The native STDIO Docker image is the only thing that covers phase 1: on the
JVM the version lookup succeeds, there is no WARN, and nothing is ever
printed. That is why `McpClientStdioIntegrationTest` and `nativeTest` both
stay green either way, and why `DockerImageMcpClientStdioIntegrationTest`
under `dockerIntegrationTest -Pnative` is the test that matters here.
Claude-Session: https://claude.ai/code/session_013ZsUJxVPvynDWGb2Lu83XS
Signed-off-by: Aditya Parikh <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
AGENTS.md | 94 ++++++++++---
.../solr/mcp/server/config/SolrNativeHints.java | 18 ++-
src/main/resources/application.properties | 15 ++
src/main/resources/logback-spring.xml | 72 ++++++----
src/main/resources/logback.xml | 44 ++++--
.../server/config/LoggingConfigurationTest.java | 151 +++++++++++++++++++++
.../mcp/server/config/SolrNativeHintsTest.java | 7 +
7 files changed, 342 insertions(+), 59 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 89db734..e0156f7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -132,23 +132,83 @@ artifact ships the SBOM without per-image wiring.
### Logging Architecture
The STDIO transport uses stdout for JSON-RPC messages, so any stray stdout
output
-corrupts the protocol. Logging is configured in two layers:
-
-- **`logback.xml`** — Loaded by logback BEFORE Spring Boot initializes.
Contains only
- a `NopStatusListener` to suppress logback's internal status messages
(`|-INFO`,
- `|-WARN`) that would otherwise be written directly to stdout. Required for
native
- image where logback falls through to `BasicConfigurator` without it.
-- **`logback-spring.xml`** — Loaded by Spring Boot, overrides `logback.xml`.
Uses
- `<springProfile>` blocks to scope appenders per transport mode:
- - **HTTP**: CONSOLE appender (stdout) + OpenTelemetry appender (OTLP log
export with
- `captureExperimentalAttributes` and `captureKeyValuePairAttributes`
enabled).
- - **STDIO**: No appenders defined. Relies on `logging.pattern.console=` in
- `application-stdio.properties` to produce empty output from Spring Boot's
default
- console appender. The OTEL appender is intentionally excluded to keep
stdout clean.
-- **`application-stdio.properties`** — Sets `logging.pattern.console=` (empty
pattern)
- which suppresses all Spring-managed console logging after Spring Boot
initializes.
-
-**Init order**: logback.xml → Spring Boot starts → logback-spring.xml →
application-{profile}.properties
+corrupts the protocol. Logging is configured in **two phases**, and both files
are
+load-bearing:
+
+| Phase | Who configures | File | Why |
+|---|---|---|---|
+| 1 | logback's own `ContextInitializer`, on the first `LoggerFactory` touch |
`logback.xml` | `NopStatusListener`, no appenders |
+| 2 | Spring Boot's `LoggingApplicationListener` | `logback-spring.xml` |
`<springProfile>` appenders |
+
+**Phase 1 — why `logback.xml` must exist.** `ContextInitializer` only ever
scans the
+*standard* locations (`logback-test.xml`, `logback.xml`). It has never heard of
+`logback-spring.xml`; that name is a Spring Boot convention. With no
standard-location
+file it falls back to `BasicConfigurator`. In a **native image** logback
cannot read its
+own manifest, so it always raises `|-WARN … Versions of logback-classic and ?
are
+different or unknown`, which trips
`StatusPrinter.printInCaseOfErrorsOrWarnings()` and
+flushes the whole `|-INFO` status list to **stdout** — landing in the middle
of the MCP
+JSON-RPC stream. On the JVM the version lookup succeeds, there is no WARN and
nothing is
+printed, so this is reproducible *only* in the native image:
+`DockerImageMcpClientStdioIntegrationTest` under `./gradlew
dockerIntegrationTest
+-Pnative` is the sole test that covers it.
+
+**Phase 2 — why `logging.config` must be set.**
`AbstractLoggingSystem.initialize()`
+resolves `logging.config` first and returns; only when it is empty does it
fall through
+to `initializeWithConventions()`, which finds the standard-location
`logback.xml`,
+reinitializes from it and **returns** — never loading the `-spring` variant.
That is not
+"one overrides the other", it **disables** the `-spring` file, taking every
+`<springProfile>` appender with it. Boot's documented rule: `<springProfile>`
"cannot be
+used in the standard `logback.xml` file because it is loaded too early."
+
+That trap was live in this repo (both files, no `logging.config`): HTTP mode
ran with no
+appenders at all — no console logs, no OTLP log export, and startup failures
exited 1
+showing only the Spring banner. `application.properties` now sets
+
+```properties
+logging.config=${LOGGING_CONFIG:classpath:logback-spring.xml}
+```
+
+which takes the `initializeWithSpecificConfig` branch and skips the standard
locations
+altogether. `LoggingConfigurationTest` fails the build if either half of the
pairing is
+removed, or if an appender is ever added to the phase-1 file.
+
+Contents of `logback-spring.xml`:
+
+- A `NopStatusListener` suppressing logback's internal status messages
(`|-INFO`,
+ `|-WARN`), which are written straight to stdout and bypass the appenders.
+- `<springProfile>` blocks scoping appenders per transport mode:
+ - **HTTP**: Boot's own `console-appender.xml` (so `logging.pattern.console` /
+ `logging.charset.console` / `logging.threshold.console` behave as in a
stock Boot
+ app) + OpenTelemetry appender (OTLP log export with
`captureExperimentalAttributes`
+ and `captureKeyValuePairAttributes` enabled).
+ - **STDIO**: No appenders defined, so nothing can reach stdout. The OTEL
appender is
+ intentionally excluded too.
+- `application-stdio.properties` additionally sets `logging.pattern.console=`
(empty
+ pattern) as a second line of defence.
+
+`SolrNativeHints` registers **both** files as native-image resources — in a
native image
+`getResource()` only sees registered resources, so an unregistered
`logback.xml` is
+exactly as absent as a deleted one, and phase 1 falls straight back to
+`BasicConfigurator`.
+
+Phase 2 works differently under AOT: `LogbackLoggingSystem` checks
+`initializeFromAotGeneratedArtifactsIfPossible()` *before* reading
`logging.config`, and
+replays `META-INF/spring/logback-model` — the model `processAot` serialized
from whatever
+configuration Boot loaded at AOT time. So `logging.config` has to be set for
the AOT run
+too, which it is, being in `application.properties`. Verify with:
+
+```bash
+strings build/resources/aot/META-INF/spring/logback-model | grep -E
'SpringProfile|OpenTelemetry'
+```
+
+`SpringProfileModel` is serialized unresolved, so profiles are still evaluated
at runtime;
+the `logback-spring.xml` resource hint is belt-and-braces for the non-AOT path.
+
+**Init order**: logback.xml → Spring Boot starts → `logging.config` →
logback-spring.xml
+→ application-{profile}.properties
+
+**Debugging tip**: if an HTTP-mode startup fails with no output, logging
config is the
+first suspect — check that `logging.config` still resolves to
`logback-spring.xml`.
### Docker image strategy
diff --git
a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java
b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java
index 584f7ce..55805a5 100644
--- a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java
+++ b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java
@@ -125,12 +125,20 @@ public class SolrNativeHints {
"org.springaicommunity.mcp.context.DefaultMetaProvider",
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
- // Include logback.xml in the native image so logback's
early
- // initialization (before Spring Boot) finds it and
applies the
- // NopStatusListener. Without this, logback falls
through to
- // BasicConfigurator and writes status messages to
stdout,
- // corrupting the MCP STDIO JSON-RPC framing.
+ // Both logging configurations must be reachable in the
native image;
+ // they serve different initialization phases.
+ //
+ // logback.xml is read by logback's own
ContextInitializer before
+ // Spring Boot exists. In a native image getResource()
only sees
+ // registered resources, so without this hint logback
finds nothing,
+ // falls back to BasicConfigurator and dumps its |-INFO
status lines
+ // to stdout — which corrupts the MCP STDIO JSON-RPC
framing.
+ //
+ // logback-spring.xml is what Spring Boot loads
(application.properties
+ // sets logging.config to it) and carries the
<springProfile>
+ // appenders. See LoggingConfigurationTest.
hints.resources().registerPattern("logback.xml");
+ hints.resources().registerPattern("logback-spring.xml");
}
}
}
diff --git a/src/main/resources/application.properties
b/src/main/resources/application.properties
index c2038f5..48b8b4a 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -23,3 +23,18 @@ spring.ai.mcp.server.version=1.0.0
solr.url=${SOLR_URL:http://localhost:8983/solr/}
# Enable virtual threads for improved concurrency
spring.threads.virtual.enabled=true
+# Logging configuration.
+#
+# Point Spring Boot explicitly at logback-spring.xml. Without this, Boot runs
+# AbstractLoggingSystem.initializeWithConventions(), which finds the
+# standard-location logback.xml first, reinitializes from it and returns --
+# logback-spring.xml is never loaded and every <springProfile> appender is
+# silently dropped. Setting logging.config takes the
initializeWithSpecificConfig
+# branch instead, which skips the standard locations entirely.
+#
+# logback.xml still ships, but only to configure logback's OWN initialization,
+# which runs before Spring Boot and only ever looks at the standard locations.
+# See logback.xml and LoggingConfigurationTest.
+#
+# LOGGING_CONFIG in the environment still overrides this.
+logging.config=${LOGGING_CONFIG:classpath:logback-spring.xml}
diff --git a/src/main/resources/logback-spring.xml
b/src/main/resources/logback-spring.xml
index 0045aac..7b95675 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -15,40 +15,56 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
+<!--
+ The application's real logging configuration: everything Spring
Boot-managed
+ lives here, including the per-profile appenders.
+
+ It must keep the "-spring" name - <springProfile> "cannot be used in the
+ standard logback.xml file because it is loaded too early" (Spring Boot
+ reference documentation).
+
+ It is reached only because application.properties sets
+ logging.config=classpath:logback-spring.xml. Without that property Spring
+ Boot runs AbstractLoggingSystem.initializeWithConventions(), which resolves
+ the standard Logback locations first, finds logback.xml, reinitializes from
+ it and returns - this file would never be loaded and every <springProfile>
+ appender below would silently disappear.
+
+ logback.xml is a separate, deliberate file covering the phase before Spring
+ Boot exists; see its comment. LoggingConfigurationTest enforces that the
two
+ stay paired with logging.config.
+-->
<configuration>
<!--
- Suppress logback internal status messages (|-INFO, |-WARN lines).
- Without this, logback writes status output directly to stdout during
- initialization, BEFORE the profile-specific root logger is configured.
- In STDIO mode this corrupts the MCP JSON-RPC framing.
+ Suppress logback's internal status messages (|-INFO, |-WARN lines),
+ which are written straight to stdout and bypass the appenders below.
+ In STDIO mode that output would corrupt the MCP JSON-RPC framing.
-->
<statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
- <!-- Import Spring Boot's default logging configuration -->
+ <!-- Spring Boot's conversion rules and CONSOLE_LOG_* properties -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!--
- HTTP mode - Full observability with console logging and OTEL export
- Used when running as HTTP server with PROFILES=http
+ HTTP mode - console logging plus OTLP log export.
- Appenders are defined inside the profile block so they do not exist
- under the STDIO profile. This avoids logback "not referenced" warnings
- and keeps STDIO stdout completely clean.
- -->
- <!--
- "http & !stdio": if both profiles are activated at once
(PROFILES=stdio,http)
- the CONSOLE appender would write diagnostics to stdout, corrupting the
MCP
- JSON-RPC stream that STDIO transports over it. Keeping stdio dominant
here
- means the combination degrades safely instead of silently breaking.
+ Appenders are declared inside the profile block so they do not exist
+ under the STDIO profile at all, which keeps STDIO stdout clean without
+ relying on a filter or an empty pattern.
+
+ "http & !stdio": if both profiles are activated at once
+ (PROFILES=stdio,http) the CONSOLE appender would write diagnostics to
+ stdout and corrupt the JSON-RPC stream that STDIO transports over it.
+ Keeping stdio dominant means the combination degrades safely instead of
+ silently breaking.
-->
<springProfile name="http & !stdio">
- <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
- <encoder>
- <pattern>${CONSOLE_LOG_PATTERN:-%d{yyyy-MM-dd HH:mm:ss.SSS}
%5p --- [%15.15t] %-40.40logger{39} : %m%n}
- </pattern>
- <charset>UTF-8</charset>
- </encoder>
- </appender>
+ <!--
+ Spring Boot's own CONSOLE appender, so the logging.pattern.console,
+ logging.charset.console and logging.threshold.console properties
+ behave exactly as they do in a stock Boot application.
+ -->
+ <include
resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<!--
OpenTelemetry appender for log export.
@@ -68,10 +84,8 @@
</springProfile>
<!--
- STDIO mode (default) - stdout suppressed via logging.pattern.console=
- in application-stdio.properties. No logback-level overrides needed;
- Spring Boot's empty console pattern keeps stdout clean for MCP
- JSON-RPC framing.
+ STDIO mode (default) - deliberately no appenders, so nothing can reach
+ stdout and break the MCP JSON-RPC framing. application-stdio.properties
+ additionally sets logging.pattern.console= as a second line of defence.
-->
-
-</configuration>
\ No newline at end of file
+</configuration>
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
index 9463f02..22918f3 100644
--- a/src/main/resources/logback.xml
+++ b/src/main/resources/logback.xml
@@ -16,16 +16,44 @@
limitations under the License.
-->
<!--
- Early logback configuration loaded BEFORE Spring Boot initializes.
+ Logging configuration for logback's OWN initialization, which happens on
the
+ first LoggerFactory touch, long before Spring Boot exists.
- Logback's own initialization discovers this file (logback.xml) and
- applies it immediately. Without it, logback falls through to its
- BasicConfigurator, which writes internal status messages (|-INFO,
- |-WARN) directly to stdout — corrupting the MCP STDIO JSON-RPC
- framing in native images.
+ ch.qos.logback.classic.util.ContextInitializer only ever looks at the
+ standard locations (logback-test.xml, logback.xml). It does not know about
+ logback-spring.xml - that name is a Spring Boot convention resolved later
by
+ LoggingApplicationListener. So this file, not logback-spring.xml, is what
+ governs that first phase.
- Spring Boot subsequently loads logback-spring.xml, which overrides
- this configuration with profile-specific settings (HTTP vs STDIO).
+ Without it logback falls through to BasicConfigurator, which installs a
+ ConsoleAppender and - because the native image cannot read logback's
manifest
+ and therefore always raises
+
+ |-WARN ... Versions of logback-classic and ? are different or unknown
+
+ - trips StatusPrinter.printInCaseOfErrorsOrWarnings() and flushes the whole
+ status list to stdout:
+
+ |-INFO ... Could NOT find resource [logback.xml]
+ |-INFO ... Trying to configure with BasicConfigurator
+ |-INFO ... Setting up default configuration.
+
+ In the native STDIO image those lines land in the middle of the MCP
JSON-RPC
+ stream; the client fails to parse them and initialize() times out. That is
+ only reproducible in the native image (on the JVM the version lookup
+ succeeds, so there is no WARN and nothing is ever printed), which means
+ DockerImageMcpClientStdioIntegrationTest under
+ `./gradlew dockerIntegrationTest -Pnative` is the only test that covers it.
+
+ The NopStatusListener below suppresses that output. This file deliberately
+ declares no appenders, so nothing can reach stdout in the meantime.
+
+ This file does NOT shadow logback-spring.xml: application.properties sets
+ logging.config=classpath:logback-spring.xml, which makes Spring Boot take
+ AbstractLoggingSystem.initializeWithSpecificConfig() and skip the standard
+ locations altogether. Drop that property and Boot would reinitialize from
+ this file and return, silently discarding every <springProfile> appender.
+ LoggingConfigurationTest enforces the pairing.
-->
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
diff --git
a/src/test/java/org/apache/solr/mcp/server/config/LoggingConfigurationTest.java
b/src/test/java/org/apache/solr/mcp/server/config/LoggingConfigurationTest.java
new file mode 100644
index 0000000..1d02528
--- /dev/null
+++
b/src/test/java/org/apache/solr/mcp/server/config/LoggingConfigurationTest.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.mcp.server.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Guards the two-phase logging setup, which has a trap at each end.
+ *
+ * <p>
+ * <b>Phase 1 - logback's own initialization.</b> {@code ContextInitializer}
+ * runs on the first {@code LoggerFactory} touch, before Spring Boot exists,
and
+ * only ever looks at the standard locations ({@code logback-test.xml},
+ * {@code logback.xml}). It has never heard of {@code logback-spring.xml}. With
+ * no standard-location file it falls back to {@code BasicConfigurator} and, in
+ * a native image, prints its whole {@code |-INFO} status list to stdout -
which
+ * corrupts the MCP STDIO JSON-RPC stream. Hence {@code logback.xml}, carrying
a
+ * {@code NopStatusListener} and no appenders.
+ *
+ * <p>
+ * <b>Phase 2 - Spring Boot.</b> {@code AbstractLoggingSystem.initialize()}
+ * resolves {@code logging.config} first; only when it is empty does it fall
+ * through to {@code initializeWithConventions()}, which finds the
+ * standard-location {@code logback.xml}, reinitializes from it and
+ * <em>returns</em> - never loading the {@code -spring} variant, and silently
+ * dropping every {@code <springProfile>} appender with it. Boot's reference
+ * documentation states the rule directly: {@code <springProfile>} <em>"cannot
+ * be used in the standard logback.xml file because it is loaded too
+ * early"</em>.
+ *
+ * <p>
+ * So the two files are only safe together while
+ * {@code logging.config=classpath:logback-spring.xml} is set. Remove the
+ * property and HTTP mode loses its CONSOLE and OTEL appenders; remove
+ * {@code logback.xml} and the native STDIO image stops speaking MCP. Each test
+ * below pins one half of that.
+ *
+ * @see <a href=
+ *
"https://docs.spring.io/spring-boot/reference/features/logging.html#features.logging.custom-log-configuration">Spring
+ * Boot - Custom Log Configuration</a>
+ */
+class LoggingConfigurationTest {
+
+ /**
+ * Locations {@code ContextInitializer} scans, and that Spring Boot's
+ * convention-based resolution would short-circuit on.
+ */
+ private static final String[] STANDARD_LOGBACK_LOCATIONS =
{"logback-test.xml", "logback-test.groovy",
+ "logback.groovy", "logback.xml"};
+
+ private static final String SPRING_VARIANT = "logback-spring.xml";
+
+ /** The configuration carrying the per-profile appenders must ship. */
+ @Test
+ void springVariantIsPresentOnTheClasspath() {
+
assertThat(getClass().getClassLoader().getResource(SPRING_VARIANT))
+ .as("%s carries the per-profile appenders and
must ship on the classpath", SPRING_VARIANT).isNotNull();
+ }
+
+ /**
+ * Phase 2: a standard-location file is only allowed while
+ * {@code logging.config} points past it.
+ */
+ @Test
+ void standardLocationFileIsPairedWithAnExplicitLoggingConfig() {
+ String shadowing = firstStandardLocationOnClasspath();
+ if (shadowing == null) {
+ return;
+ }
+
+ String loggingConfig =
applicationProperties().getProperty("logging.config");
+
+ assertThat(loggingConfig).as(
+ "%s is on the classpath. Spring Boot only skips
it when logging.config is set; otherwise "
+ +
"AbstractLoggingSystem.initializeWithConventions() reinitializes from it and
returns, and "
+ + "%s is never loaded - every
<springProfile> appender is silently dropped",
+ shadowing,
SPRING_VARIANT).isNotNull().contains(SPRING_VARIANT);
+ }
+
+ /**
+ * Phase 1: whatever logback loads before Spring Boot must not be able
to write
+ * to stdout, which STDIO reserves for JSON-RPC.
+ */
+ @Test
+ void standardLocationFileDeclaresNoAppenders() {
+ String earlyConfig = firstStandardLocationOnClasspath();
+ if (earlyConfig == null) {
+ return;
+ }
+
+ assertThat(stripXmlComments(read(earlyConfig)))
+ .as("%s is applied by logback before Spring
Boot and before any profile is known, so an appender here "
+ + "reaches stdout in STDIO mode
and corrupts the MCP JSON-RPC framing. Appenders belong in "
+ + "%s, inside a <springProfile>
block", earlyConfig, SPRING_VARIANT)
+ .doesNotContain("<appender");
+ }
+
+ private String firstStandardLocationOnClasspath() {
+ for (String location : STANDARD_LOGBACK_LOCATIONS) {
+ if (getClass().getClassLoader().getResource(location)
!= null) {
+ return location;
+ }
+ }
+ return null;
+ }
+
+ private Properties applicationProperties() {
+ Properties properties = new Properties();
+ try (InputStream in =
getClass().getClassLoader().getResourceAsStream("application.properties")) {
+ assertThat(in).as("application.properties must be on
the classpath").isNotNull();
+ properties.load(in);
+ } catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ return properties;
+ }
+
+ private String read(String resource) {
+ try (InputStream in =
getClass().getClassLoader().getResourceAsStream(resource)) {
+ assertThat(in).as("%s must be readable from the
classpath", resource).isNotNull();
+ return new String(in.readAllBytes(),
StandardCharsets.UTF_8);
+ } catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ }
+
+ private static String stripXmlComments(String xml) {
+ return xml.replaceAll("(?s)<!--.*?-->", "");
+ }
+}
diff --git
a/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java
b/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java
index 3e12d09..1765c7c 100644
--- a/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java
@@ -76,4 +76,11 @@ class SolrNativeHintsTest {
// stays silent on stdout (MCP STDIO framing).
assertTrue(RuntimeHintsPredicates.resource().forResource("logback.xml").test(hints));
}
+
+ @Test
+ void registersLogbackSpringXmlResourceHint() {
+ // The configuration Spring Boot loads via logging.config; it
carries the
+ // per-profile appenders and must survive AOT.
+
assertTrue(RuntimeHintsPredicates.resource().forResource("logback-spring.xml").test(hints));
+ }
}