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 b4ffe18 fix(logging): use one logback-spring.xml and silence logback
status from main() (#193)
b4ffe18 is described below
commit b4ffe18f0c3cf01f1b8dd691fd53b96b03b08fe0
Author: Aditya Parikh <[email protected]>
AuthorDate: Fri Sep 11 11:10:37 2026 -0400
fix(logging): use one logback-spring.xml and silence logback status from
main() (#193)
Spring Boot expects a single logging configuration resolved by convention.
Since #189 the repo shipped two (logback.xml holding only a
NopStatusListener,
logback-spring.xml holding the <springProfile> appenders) plus a
logging.config property whose only purpose was to steer Boot past the first
file, which initializeWithConventions() would otherwise stop at, never
loading
the -spring variant. A test existed solely to keep the three coupled.
The early file did one job: install a status listener before Boot exists, so
that logback's own initialization in a native image (where checkVersions()
always raises a |-WARN) does not print its status list to stdout, corrupting
the MCP STDIO stream. Logback's logback.statusListenerClass system property
does exactly that: ContextInitializer.autoConfig() reads it in
installIfAsked() after checkVersions(), and LogbackServiceProvider skips
StatusPrinter.printInCaseOfErrorsOrWarnings() whenever a listener is
installed. Main.main() now sets it as its first statement, unless an
operator
already set it on the command line.
- delete logback.xml and the logback.xml native resource hint
- drop logging.config from application.properties; LOGGING_CONFIG in the
environment still overrides as Boot's normal external-file mechanism
- LoggingConfigurationTest now pins the convention: no standard-location
logback file on the classpath, no logging.config, and Main installs a
listener StatusListenerConfigHelper honours
- update AGENTS.md, the native-image dev doc, docs/security/stdio.md and the
keycloak.md troubleshooting section that still described the pre-#189
state
Claude-Session: https://claude.ai/code/session_01Wh7SJkZhL1uuK7pYc3SLk8
Signed-off-by: Aditya Parikh <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
AGENTS.md | 100 ++++++-------
dev-docs/graalvm-native-image.md | 12 +-
docs/security/keycloak.md | 16 +-
docs/security/stdio.md | 2 +-
src/main/java/org/apache/solr/mcp/server/Main.java | 33 +++++
.../solr/mcp/server/config/SolrNativeHints.java | 19 +--
src/main/resources/application.properties | 15 --
src/main/resources/logback-spring.xml | 24 +--
src/main/resources/logback.xml | 60 --------
.../solr/mcp/server/LoggingConfigurationTest.java | 162 +++++++++++++++++++++
.../server/config/LoggingConfigurationTest.java | 151 -------------------
.../mcp/server/config/SolrNativeHintsTest.java | 9 +-
12 files changed, 267 insertions(+), 336 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index e0156f7..811ce1b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -132,50 +132,41 @@ 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 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:
+corrupts the protocol. The setup follows Spring Boot's conventions: **one**
logback
+configuration, `logback-spring.xml`, resolved by Boot by convention, plus one
line of
+code in `Main` for the window before Boot exists.
+
+**Why the `-spring` name and nothing else.** Boot's rule: `<springProfile>`
"cannot be
+used in the standard `logback.xml` file because it is loaded too early." A
standard-
+location file is worse than useless here:
`AbstractLoggingSystem.initializeWithConventions()`
+finds it first, reinitializes from it and **returns**, so the `-spring`
variant is never
+loaded and every `<springProfile>` appender is silently dropped. HTTP mode
would run with
+no console logs and no OTLP log export, and startup failures would exit 1
showing only
+the banner. `LoggingConfigurationTest` fails the build if a `logback.xml` (or
+`logback-test.xml`) ever reappears, or if `logging.config` is set in
+`application.properties` to paper over one. `LOGGING_CONFIG` in the
environment still
+works as Boot's normal operator override for an *external* file.
+
+**Why `Main` sets `logback.statusListenerClass`.** Logback initializes itself
on the
+first `LoggerFactory` touch, before Boot's `LoggingApplicationListener` runs.
In a
+**native image** it cannot read its own manifest, so
`ContextInitializer.checkVersions()`
+always raises `|-WARN … Versions of logback-classic and ? are different or
unknown`, and
+`LogbackServiceProvider` then calls
`StatusPrinter.printInCaseOfErrorsOrWarnings()`,
+flushing the whole `|-INFO` status list to **stdout** — in the middle of the
MCP
+JSON-RPC stream. That provider skips the print whenever a status listener is
installed,
+and `ContextInitializer.autoConfig()` installs one from the
`logback.statusListenerClass`
+system property *after* the version check but *before* the print.
`Main.main()` therefore
+sets that property to `NopStatusListener` as its first statement, unless an
operator has
+already set it (so
`-Dlogback.statusListenerClass=ch.qos.logback.core.status.OnConsoleStatusListener`
+still works for debugging logback itself). On the JVM the version lookup
succeeds 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.
+-Pnative` is the sole test that covers it end to end.
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.
+- A `NopStatusListener` suppressing logback's internal status messages during
Boot's
+ own (re)configuration, 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
@@ -184,31 +175,26 @@ Contents of `logback-spring.xml`:
- **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`.
+ pattern), the idiom Spring AI documents for STDIO servers, as a second line
of defence.
-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:
+Under AOT, `LogbackLoggingSystem` replays `META-INF/spring/logback-model` —
the model
+`processAot` serialized from `logback-spring.xml` — before looking at the
classpath.
+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.
+the `logback-spring.xml` resource hint in `SolrNativeHints` is belt-and-braces
for the
+non-AOT path.
-**Init order**: logback.xml → Spring Boot starts → `logging.config` →
logback-spring.xml
-→ application-{profile}.properties
+**Init order**: `Main` sets `logback.statusListenerClass` → first logger touch
(logback
+self-init, silent) → Spring Boot starts → 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`.
+**Debugging tip**: if an HTTP-mode startup fails with no output, check that no
+`logback.xml` has crept onto the classpath; `LoggingConfigurationTest` should
already
+have caught it.
### Docker image strategy
@@ -272,7 +258,7 @@ buildpacks (`bootBuildImage -Pnative`). Key configuration:
generic `Object` dispatch): `CollectionCreationResult`, `SolrHealthStatus`,
`SolrMetrics`, `IndexStats`, `QueryStats`, `CacheStats`, `CacheInfo`,
`HandlerStats`, `HandlerInfo`, `SearchResponse`
- - **Resource**: `logback.xml` (see Logging Architecture above)
+ - **Resource**: `logback-spring.xml` (see Logging Architecture above)
- **Wire format:** `SolrConfig` uses `XMLRequestWriter` instead of the default
`JavaBinRequestWriter`. The JavaBin binary codec uses deep reflection that
would
require extensive additional native image hints.
diff --git a/dev-docs/graalvm-native-image.md b/dev-docs/graalvm-native-image.md
index 5051f84..f289a40 100644
--- a/dev-docs/graalvm-native-image.md
+++ b/dev-docs/graalvm-native-image.md
@@ -162,11 +162,13 @@ container and value types.
are package-private records the MCP framework dispatches via generic
`Object`, so AOT can't see them. They're registered by name with
`registerTypeIfPresent`.
-- **`logback.xml` resource.** Registered as a resource pattern so logback's
- early (pre-Spring) initialization finds it and installs the
`NopStatusListener`.
- Without it, logback falls through to `BasicConfigurator` and writes status
- lines to stdout, corrupting STDIO framing. (See the Logging Architecture
- section of `AGENTS.md`.)
+- **`logback-spring.xml` resource.** Registered as a resource pattern as
+ belt-and-braces for the non-AOT path; under AOT Boot replays the serialized
+ logback model instead. Logback's own pre-Spring status output, which would
+ otherwise land on stdout in the native image and corrupt STDIO framing, is
+ silenced by `Main` via the `logback.statusListenerClass` system property, so
+ no early `logback.xml` is needed. (See the Logging Architecture section of
+ `AGENTS.md`.)
### Adding a hint when a native run fails
diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md
index 226d009..3befbf2 100644
--- a/docs/security/keycloak.md
+++ b/docs/security/keycloak.md
@@ -869,11 +869,9 @@ public String getSchema(String collection) { ... }
### `bootRun` exits with code 1 and prints nothing
-This is the most common failure, and the hardest to read, because **HTTP mode
-currently produces no application log output**: `logback.xml` is picked up by
-logback's own self-initialization, so Spring Boot never applies
-`logback-spring.xml`, where the `http` profile's console appender is defined.
-Gradle reports only:
+This is the most common failure. HTTP mode logs to the console through the
+`http` profile's appender in `logback-spring.xml`, so the real exception is
+normally in the Gradle output just above the summary:
```
> Task :bootRun FAILED
@@ -881,11 +879,9 @@ Execution failed for task ':bootRun'.
> Process 'command '.../bin/java'' finished with non-zero exit value 1
```
-Re-run with logging forced on to see the real exception:
-
-```bash
-LOGGING_CONFIG=classpath:logback-spring.xml ./gradlew bootRun
-```
+If there is genuinely no application output, something has disabled logging
+(for example a stray `logback.xml` on the classpath, which
+`LoggingConfigurationTest` guards against); check that first.
The usual cause is that the realm does not exist yet:
diff --git a/docs/security/stdio.md b/docs/security/stdio.md
index a18008d..e557e79 100644
--- a/docs/security/stdio.md
+++ b/docs/security/stdio.md
@@ -18,7 +18,7 @@ that launched the process. No code changes are required for
STDIO security.
| Communication is stdin/stdout only | MCP framing per spec | No socket to
reach; input arrives over an inherited file descriptor |
| Trust boundary = OS process owner | Launcher runs the binary | Same model as
any local CLI; OS user permissions are the auth. Note the boundary is **any
process that can reach the server's stdin**, not only the direct parent — a
descriptor can be inherited or passed on, so isolate by OS user rather than
assuming a single writer |
| Spring Security autoconfig disabled | `application-stdio.properties`
excludes `SecurityAutoConfiguration` and
`ManagementWebSecurityAutoConfiguration` | Belt-and-suspenders; the filter
chain has nothing to do without a servlet container |
-| `stdout` is reserved for JSON-RPC | `logback.xml` + empty
`logging.pattern.console` (see [Logging Architecture in
CLAUDE.md](../../CLAUDE.md#logging-architecture)) | Prevents log lines from
being mis-parsed as MCP frames |
+| `stdout` is reserved for JSON-RPC | No appenders under the `stdio` profile
in `logback-spring.xml`, empty `logging.pattern.console`, and `Main` silencing
logback's own status output (see [Logging Architecture in
CLAUDE.md](../../CLAUDE.md#logging-architecture)) | Prevents log lines from
being mis-parsed as MCP frames |
## Operational guidance for STDIO deployments
diff --git a/src/main/java/org/apache/solr/mcp/server/Main.java
b/src/main/java/org/apache/solr/mcp/server/Main.java
index e6ee2dc..ac642fa 100644
--- a/src/main/java/org/apache/solr/mcp/server/Main.java
+++ b/src/main/java/org/apache/solr/mcp/server/Main.java
@@ -16,6 +16,7 @@
*/
package org.apache.solr.mcp.server;
+import ch.qos.logback.core.status.NopStatusListener;
import org.apache.solr.mcp.server.collection.CollectionService;
import org.apache.solr.mcp.server.indexing.IndexingService;
import org.apache.solr.mcp.server.schema.SchemaService;
@@ -110,7 +111,39 @@ public class Main {
public Main() {
}
+ /**
+ * Logback's own system property naming the status listener to install
during
+ * its self-initialization.
+ */
+ static final String LOGBACK_STATUS_LISTENER_PROPERTY =
"logback.statusListenerClass";
+
public static void main(String[] args) {
+ silenceLogbackStatusOutput();
SpringApplication.run(Main.class, args);
}
+
+ /**
+ * Keeps logback's internal status messages off stdout, which the STDIO
+ * transport reserves for JSON-RPC.
+ *
+ * <p>
+ * Logback initializes itself on the first {@code LoggerFactory} touch,
long
+ * before Spring Boot's logging system runs. In a native image it
cannot read
+ * its version from the manifest, raises a {@code |-WARN}, and
+ * {@code LogbackServiceProvider} then prints its whole status list to
stdout -
+ * unless a status listener is already installed. It installs one from
this
+ * system property before deciding whether to print, so setting it
here, before
+ * anything can touch a logger, is the earliest and only hook. Spring
Boot's
+ * later configuration ({@code logback-spring.xml}) is unaffected.
+ *
+ * <p>
+ * An explicit {@code -Dlogback.statusListenerClass=...} on the command
line
+ * wins, so logback's own configuration can still be debugged with
+ * {@code OnConsoleStatusListener}.
+ */
+ static void silenceLogbackStatusOutput() {
+ if (System.getProperty(LOGBACK_STATUS_LISTENER_PROPERTY) ==
null) {
+ System.setProperty(LOGBACK_STATUS_LISTENER_PROPERTY,
NopStatusListener.class.getName());
+ }
+ }
}
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 55805a5..aecf59c 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,19 +125,12 @@ public class SolrNativeHints {
"org.springaicommunity.mcp.context.DefaultMetaProvider",
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
- // 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");
+ // logback-spring.xml is the only logback
configuration; Spring Boot
+ // resolves it by convention. Under AOT the parsed
model is replayed
+ // from META-INF/spring/logback-model, so this hint is
belt-and-braces
+ // for the non-AOT path. Logback's own pre-Spring
status output is
+ // silenced by Main, not by a second configuration
file; see
+ // LoggingConfigurationTest.
hints.resources().registerPattern("logback-spring.xml");
}
}
diff --git a/src/main/resources/application.properties
b/src/main/resources/application.properties
index 855f958..089d7d3 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -28,18 +28,3 @@ 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 7b95675..afcd85b 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -16,23 +16,15 @@
limitations under the License.
-->
<!--
- The application's real logging configuration: everything Spring
Boot-managed
- lives here, including the per-profile appenders.
+ The one and only logback configuration. Spring Boot resolves the "-spring"
+ variant by convention, and that suffix is required: <springProfile> "cannot
+ be used in the standard logback.xml file because it is loaded too early"
+ (Spring Boot reference documentation). Do not add a logback.xml next to it:
+ Boot would initialize from that file and never load this one.
- 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.
+ Logback's own status output during its pre-Spring self-initialization is
+ silenced by Main (logback.statusListenerClass), which is why no early
+ configuration file is needed. See LoggingConfigurationTest.
-->
<configuration>
<!--
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
deleted file mode 100644
index 22918f3..0000000
--- a/src/main/resources/logback.xml
+++ /dev/null
@@ -1,60 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
- 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.
--->
-<!--
- Logging configuration for logback's OWN initialization, which happens on
the
- first LoggerFactory touch, long before Spring Boot exists.
-
- 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.
-
- 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"/>
-</configuration>
diff --git
a/src/test/java/org/apache/solr/mcp/server/LoggingConfigurationTest.java
b/src/test/java/org/apache/solr/mcp/server/LoggingConfigurationTest.java
new file mode 100644
index 0000000..c9608d7
--- /dev/null
+++ b/src/test/java/org/apache/solr/mcp/server/LoggingConfigurationTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.core.status.OnConsoleStatusListener;
+import ch.qos.logback.core.status.StatusUtil;
+import ch.qos.logback.core.util.StatusListenerConfigHelper;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.Properties;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Pins the logging setup to Spring Boot's conventions.
+ *
+ * <p>
+ * Exactly one logback configuration ships, {@code logback-spring.xml}, and
Boot
+ * finds it by convention. A standard-location file ({@code logback.xml}) would
+ * be applied by logback itself before Boot starts and would then make
+ * {@code AbstractLoggingSystem.initializeWithConventions()} stop there, never
+ * loading the {@code -spring} variant and silently dropping every
+ * {@code <springProfile>} appender. Boot's reference documentation:
+ * {@code <springProfile>} <em>"cannot be used in the standard logback.xml file
+ * because it is loaded too early"</em>.
+ *
+ * <p>
+ * The one thing that must happen before Boot exists is silencing logback's own
+ * status output. In a native image logback cannot read its version from the
+ * manifest, raises a {@code |-WARN}, and {@code LogbackServiceProvider} then
+ * prints the whole status list to stdout - which STDIO reserves for JSON-RPC.
+ * {@code LogbackServiceProvider} skips that print whenever a status listener
is
+ * installed, and {@code ContextInitializer.autoConfig()} installs one from the
+ * {@code logback.statusListenerClass} system property. {@link Main} sets that
+ * property first thing, before anything can touch {@code LoggerFactory}.
+ *
+ * @see <a href=
+ *
"https://docs.spring.io/spring-boot/reference/features/logging.html#features.logging.custom-log-configuration">Spring
+ * Boot - Custom Log Configuration</a>
+ * @see <a href=
+ *
"https://logback.qos.ch/manual/configuration.html#dumpingStatusData">Logback
+ * - status data</a>
+ */
+class LoggingConfigurationTest {
+
+ /** Locations logback's {@code ContextInitializer} scans on its own. */
+ 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";
+
+ private String previousStatusListenerClass;
+
+ @BeforeEach
+ void rememberStatusListenerProperty() {
+ previousStatusListenerClass =
System.getProperty(Main.LOGBACK_STATUS_LISTENER_PROPERTY);
+ System.clearProperty(Main.LOGBACK_STATUS_LISTENER_PROPERTY);
+ }
+
+ @AfterEach
+ void restoreStatusListenerProperty() {
+ if (previousStatusListenerClass == null) {
+
System.clearProperty(Main.LOGBACK_STATUS_LISTENER_PROPERTY);
+ } else {
+
System.setProperty(Main.LOGBACK_STATUS_LISTENER_PROPERTY,
previousStatusListenerClass);
+ }
+ }
+
+ /** 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();
+ }
+
+ /**
+ * A standard-location file would be found first by
+ * {@code initializeWithConventions()} and would disable the {@code
-spring}
+ * variant.
+ */
+ @Test
+ void noStandardLocationLogbackFileShipsOnTheClasspath() {
+ for (String location : STANDARD_LOGBACK_LOCATIONS) {
+
assertThat(getClass().getClassLoader().getResource(location))
+ .as("%s must not ship: Spring Boot
would initialize from it and never load %s, "
+ + "silently dropping
every <springProfile> appender", location, SPRING_VARIANT)
+ .isNull();
+ }
+ }
+
+ /**
+ * With no standard-location file there is nothing to steer Boot past,
so
+ * {@code logging.config} stays unset and Boot resolves {@code -spring}
by
+ * convention. The property remains available to operators via the
environment.
+ */
+ @Test
+ void bootResolvesTheSpringVariantByConvention() {
+
assertThat(applicationProperties().getProperty("logging.config"))
+ .as("logging.config is an operator override for
an external file, not a way to pick between "
+ + "classpath files; leave it
unset and let Boot find %s by convention", SPRING_VARIANT)
+ .isNull();
+ }
+
+ /**
+ * The status listener has to be in place before logback prints its
status list,
+ * i.e. before the first {@code LoggerFactory} touch. The only code
that runs
+ * that early is {@code main()}.
+ */
+ @Test
+ void
mainInstallsAStatusListenerLogbackHonoursDuringItsOwnInitialization() {
+ Main.silenceLogbackStatusOutput();
+
+ LoggerContext context = new LoggerContext();
+ StatusListenerConfigHelper.installIfAsked(context);
+
+ assertThat(StatusUtil.contextHasStatusListener(context))
+ .as("LogbackServiceProvider prints the status
list to stdout unless a listener is installed; "
+ + "%s must name one",
Main.LOGBACK_STATUS_LISTENER_PROPERTY)
+ .isTrue();
+ }
+
+ /** An operator debugging logback itself keeps their {@code -D}
override. */
+ @Test
+ void mainDoesNotOverrideAnOperatorSuppliedStatusListener() {
+ String operatorChoice = OnConsoleStatusListener.class.getName();
+ System.setProperty(Main.LOGBACK_STATUS_LISTENER_PROPERTY,
operatorChoice);
+
+ Main.silenceLogbackStatusOutput();
+
+
assertThat(System.getProperty(Main.LOGBACK_STATUS_LISTENER_PROPERTY)).isEqualTo(operatorChoice);
+ }
+
+ 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;
+ }
+}
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
deleted file mode 100644
index 1d02528..0000000
---
a/src/test/java/org/apache/solr/mcp/server/config/LoggingConfigurationTest.java
+++ /dev/null
@@ -1,151 +0,0 @@
-/*
- * 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 1765c7c..1ce653b 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
@@ -70,16 +70,9 @@ class SolrNativeHintsTest {
.withMemberCategory(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS).test(hints));
}
- @Test
- void registersLogbackXmlResourceHint() {
- // Required so logback's pre-Spring initialization finds
logback.xml and
- // 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
+ // The configuration Spring Boot loads by convention; it
carries the
// per-profile appenders and must survive AOT.
assertTrue(RuntimeHintsPredicates.resource().forResource("logback-spring.xml").test(hints));
}