This is an automated email from the ASF dual-hosted git repository.
rombert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-whiteboard.git
The following commit(s) were added to refs/heads/master by this push:
new 2da7f58b fix(mcp-server-contributions): the log tool now has access to
logback messages
2da7f58b is described below
commit 2da7f58b8a149ba3ee18068d2d4bbb0f87aeacfa
Author: Robert Munteanu <[email protected]>
AuthorDate: Fri Apr 24 15:41:51 2026 +0200
fix(mcp-server-contributions): the log tool now has access to logback
messages
Previously it would only get events from the OSGi LogService, which are a
small subset of all
logging events of interest.
---
mcp-server-contributions/pom.xml | 33 +++-
.../server/impl/contribs/LogToolContribution.java | 218 +++++----------------
.../server/impl/contribs/internal/LogSnapshot.java | 42 ++++
.../contribs/internal/StructuredLogBuffer.java | 100 ++++++++++
.../internal/StructuredLogBufferAppender.java | 173 ++++++++++++++++
.../internal/StructuredLogBufferAppenderTest.java | 62 ++++++
.../contribs/internal/StructuredLogBufferTest.java | 64 ++++++
7 files changed, 515 insertions(+), 177 deletions(-)
diff --git a/mcp-server-contributions/pom.xml b/mcp-server-contributions/pom.xml
index f63c232c..6299a91b 100644
--- a/mcp-server-contributions/pom.xml
+++ b/mcp-server-contributions/pom.xml
@@ -108,9 +108,21 @@
<scope>provided</scope>
</dependency>
<dependency>
- <groupId>org.osgi</groupId>
- <artifactId>org.osgi.service.log</artifactId>
- <version>1.3.0</version>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>1.7.32</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ <version>1.2.13</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ <version>1.2.13</version>
<scope>provided</scope>
</dependency>
<dependency>
@@ -119,6 +131,21 @@
<version>0.1.0</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-api</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-engine</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>org.osgi.util.converter</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
index 14d549de..0ec2db36 100644
---
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
+++
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
@@ -18,29 +18,23 @@
*/
package org.apache.sling.mcp.server.impl.contribs;
-import java.io.PrintWriter;
-import java.io.StringWriter;
import java.text.SimpleDateFormat;
-import java.util.ArrayList;
import java.util.Date;
-import java.util.Enumeration;
import java.util.List;
+import java.util.Map;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
+import ch.qos.logback.classic.Level;
import io.modelcontextprotocol.json.McpJsonMapperSupplier;
import
io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.Tool;
+import org.apache.sling.mcp.server.impl.contribs.internal.LogSnapshot;
+import
org.apache.sling.mcp.server.impl.contribs.internal.StructuredLogBufferAppender;
import org.apache.sling.mcp.server.spi.McpServerContribution;
-import org.osgi.framework.Bundle;
-import org.osgi.framework.Constants;
-import org.osgi.framework.ServiceReference;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.log.LogEntry;
-import org.osgi.service.log.LogReaderService;
-import org.osgi.service.log.LogService;
/**
* MCP Tool that provides access to logs with filtering capabilities.
@@ -50,7 +44,7 @@ import org.osgi.service.log.LogService;
public class LogToolContribution implements McpServerContribution {
@Reference
- private LogReaderService logReaderService;
+ private StructuredLogBufferAppender structuredLogBufferAppender;
@Reference
private McpJsonMapperSupplier jsonMapper;
@@ -105,10 +99,10 @@ public class LogToolContribution implements
McpServerContribution {
maxEntries = Math.min(maxEntries, 1000); // Cap at 1000
}
- int minLogLevel = LogService.LOG_ERROR;
+ Level minLogLevel = Level.ERROR;
if (logLevelStr != null && !logLevelStr.isEmpty()) {
minLogLevel = parseLogLevel(logLevelStr);
- if (minLogLevel == -1) {
+ if (minLogLevel == null) {
return CallToolResult.builder()
.addTextContent("Invalid log level: " +
logLevelStr
+ ". Valid options are: ERROR,
WARN, INFO, DEBUG, TRACE")
@@ -130,8 +124,8 @@ public class LogToolContribution implements
McpServerContribution {
}
}
- // Collect and filter logs
- List<LogEntry> filteredLogs = collectLogs(pattern,
minLogLevel, maxEntries);
+ List<LogSnapshot> filteredLogs =
+
structuredLogBufferAppender.getBuffer().getRecent(pattern, minLogLevel,
maxEntries);
// Format output
String result = formatLogs(filteredLogs, regexPattern,
minLogLevel, maxEntries);
@@ -140,89 +134,18 @@ public class LogToolContribution implements
McpServerContribution {
}));
}
- private List<LogEntry> collectLogs(Pattern pattern, int minLogLevel, int
maxEntries) {
- List<LogEntry> logs = new ArrayList<>();
-
- @SuppressWarnings("unchecked")
- Enumeration<LogEntry> logEntries = logReaderService.getLog();
- while (logEntries.hasMoreElements() && logs.size() < maxEntries) {
- LogEntry entry = logEntries.nextElement();
-
- // Filter by log level (lower values = higher severity)
- if (entry.getLevel() > minLogLevel) {
- continue;
- }
-
- // Filter by regex pattern if provided - search entire log entry
- if (pattern != null) {
- String fullLogEntry = buildFullLogEntryText(entry);
- if (!pattern.matcher(fullLogEntry).find()) {
- continue;
- }
- }
-
- logs.add(entry);
- }
-
- return logs;
- }
-
- private String buildFullLogEntryText(LogEntry entry) {
- StringBuilder text = new StringBuilder();
-
- // Add log level
- text.append(getLogLevelName(entry.getLevel())).append(" ");
-
- // Add bundle name
- Bundle bundle = entry.getBundle();
- if (bundle != null) {
- text.append(getBundleName(bundle)).append(" ");
- }
-
- // Add message
- String message = entry.getMessage();
- if (message != null) {
- text.append(message).append(" ");
- }
-
- // Add service reference info
- ServiceReference<?> serviceRef = entry.getServiceReference();
- if (serviceRef != null) {
- String serviceDesc = getServiceDescription(serviceRef);
- if (serviceDesc != null && !serviceDesc.isEmpty()) {
- text.append(serviceDesc).append(" ");
- }
- }
-
- // Add exception info
- Throwable exception = entry.getException();
- if (exception != null) {
- text.append(exception.getClass().getName()).append(" ");
- if (exception.getMessage() != null) {
- text.append(exception.getMessage()).append(" ");
- }
-
- // Add stack trace
- StringWriter sw = new StringWriter();
- PrintWriter pw = new PrintWriter(sw);
- exception.printStackTrace(pw);
- text.append(sw.toString());
- }
-
- return text.toString();
- }
-
- private int parseLogLevel(String levelStr) {
+ private Level parseLogLevel(String levelStr) {
return switch (levelStr.toUpperCase()) {
- case "ERROR" -> LogService.LOG_ERROR;
- case "WARN", "WARNING" -> LogService.LOG_WARNING;
- case "INFO" -> LogService.LOG_INFO;
- case "DEBUG" -> LogService.LOG_DEBUG;
- default -> -1;
+ case "ERROR" -> Level.ERROR;
+ case "WARN", "WARNING" -> Level.WARN;
+ case "INFO" -> Level.INFO;
+ case "DEBUG" -> Level.DEBUG;
+ case "TRACE" -> Level.TRACE;
+ default -> null;
};
}
- private String formatLogs(List<LogEntry> logs, String regexPattern, int
minLogLevel, int maxEntries) {
+ private String formatLogs(List<LogSnapshot> logs, String regexPattern,
Level minLogLevel, int maxEntries) {
StringBuilder result = new StringBuilder();
result.append("=== Log Entries ===\n\n");
@@ -242,7 +165,7 @@ public class LogToolContribution implements
McpServerContribution {
result.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
for (int i = 0; i < logs.size(); i++) {
- LogEntry entry = logs.get(i);
+ LogSnapshot entry = logs.get(i);
formatLogEntry(entry, i + 1, result);
if (i < logs.size() - 1) {
@@ -253,49 +176,29 @@ public class LogToolContribution implements
McpServerContribution {
return result.toString();
}
- private void formatLogEntry(LogEntry entry, int index, StringBuilder
result) {
+ private void formatLogEntry(LogSnapshot entry, int index, StringBuilder
result) {
result.append("[").append(index).append("] ");
- result.append(DATE_FORMAT.format(new Date(entry.getTime())));
- result.append("
[").append(getLogLevelName(entry.getLevel())).append("] ");
-
- // Add bundle information
- Bundle bundle = entry.getBundle();
- if (bundle != null) {
- String bundleName = getBundleName(bundle);
- result.append("[").append(bundleName).append("] ");
- }
+ result.append(DATE_FORMAT.format(new Date(entry.timeMillis())));
+ result.append(" [").append(getLogLevelName(entry.level())).append("]
");
+ result.append("[")
+ .append(entry.loggerName() != null ? entry.loggerName() :
"(unknown logger)")
+ .append("] ");
- // Add message
- String message = entry.getMessage();
+ String message = entry.formattedMessage();
result.append(message != null ? message : "(no message)");
result.append("\n");
- // Add service reference info if available
- ServiceReference<?> serviceRef = entry.getServiceReference();
- if (serviceRef != null) {
- String serviceDesc = getServiceDescription(serviceRef);
- if (serviceDesc != null && !serviceDesc.isEmpty()) {
- result.append(" Service:
").append(serviceDesc).append("\n");
- }
+ if (entry.threadName() != null && !entry.threadName().isEmpty()) {
+ result.append(" Thread:
").append(entry.threadName()).append("\n");
}
- // Add exception info if available
- Throwable exception = entry.getException();
- if (exception != null) {
- result.append(" Exception:
").append(exception.getClass().getName());
- if (exception.getMessage() != null) {
- result.append(": ").append(exception.getMessage());
- }
- result.append("\n");
-
- // Add stack trace (first few lines)
- StringWriter sw = new StringWriter();
- PrintWriter pw = new PrintWriter(sw);
- exception.printStackTrace(pw);
- String stackTrace = sw.toString();
+ if (!entry.mdc().isEmpty()) {
+ result.append(" MDC:
").append(formatMdc(entry.mdc())).append("\n");
+ }
- // Limit stack trace to first 10 lines
- String[] lines = stackTrace.split("\n");
+ String throwableText = entry.throwableText();
+ if (throwableText != null && !throwableText.isEmpty()) {
+ String[] lines = throwableText.split("\n");
int maxLines = Math.min(lines.length, 10);
for (int i = 0; i < maxLines; i++) {
result.append(" ").append(lines[i]).append("\n");
@@ -306,53 +209,20 @@ public class LogToolContribution implements
McpServerContribution {
}
}
- private String getBundleName(Bundle bundle) {
- String name = bundle.getHeaders().get(Constants.BUNDLE_NAME);
- if (name == null || name.isEmpty()) {
- name = bundle.getSymbolicName();
- }
- if (name == null || name.isEmpty()) {
- name = "Bundle#" + bundle.getBundleId();
- }
- return name;
- }
-
- private String getServiceDescription(ServiceReference<?> ref) {
- if (ref == null) {
- return null;
- }
-
- Object serviceId = ref.getProperty(Constants.SERVICE_ID);
- Object objectClass = ref.getProperty(Constants.OBJECTCLASS);
-
- StringBuilder desc = new StringBuilder();
- if (objectClass instanceof String[]) {
- String[] classes = (String[]) objectClass;
- if (classes.length > 0) {
- desc.append(classes[0]);
- if (classes.length > 1) {
- desc.append(" (").append(classes.length - 1).append(" more
interfaces)");
- }
- }
- }
-
- if (serviceId != null) {
- if (desc.length() > 0) {
- desc.append(" ");
+ private String formatMdc(Map<String, String> mdc) {
+ StringBuilder result = new StringBuilder();
+ boolean first = true;
+ for (Map.Entry<String, String> entry : mdc.entrySet()) {
+ if (!first) {
+ result.append(", ");
}
- desc.append("[id=").append(serviceId).append("]");
+ result.append(entry.getKey()).append('=').append(entry.getValue());
+ first = false;
}
-
- return desc.toString();
+ return result.toString();
}
- private String getLogLevelName(int level) {
- return switch (level) {
- case LogService.LOG_ERROR -> "ERROR";
- case LogService.LOG_WARNING -> "WARN";
- case LogService.LOG_INFO -> "INFO";
- case LogService.LOG_DEBUG -> "DEBUG";
- default -> "LEVEL_" + level;
- };
+ private String getLogLevelName(Level level) {
+ return level != null ? level.levelStr : "UNKNOWN";
}
}
diff --git
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/LogSnapshot.java
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/LogSnapshot.java
new file mode 100644
index 00000000..80dce2f5
--- /dev/null
+++
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/LogSnapshot.java
@@ -0,0 +1,42 @@
+/*
+ * 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.sling.mcp.server.impl.contribs.internal;
+
+import java.util.Collections;
+import java.util.Map;
+
+import ch.qos.logback.classic.Level;
+
+/**
+ * Stores only the lightweight, stable parts of a log event so the in-memory
buffer
+ * does not retain full {@code ILoggingEvent} object graphs.
+ */
+public record LogSnapshot(
+ long timeMillis,
+ Level level,
+ String loggerName,
+ String threadName,
+ String formattedMessage,
+ String throwableText,
+ Map<String, String> mdc) {
+
+ public LogSnapshot {
+ mdc = mdc == null ? Collections.emptyMap() :
Collections.unmodifiableMap(mdc);
+ }
+}
diff --git
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBuffer.java
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBuffer.java
new file mode 100644
index 00000000..80c64fe3
--- /dev/null
+++
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBuffer.java
@@ -0,0 +1,100 @@
+/*
+ * 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.sling.mcp.server.impl.contribs.internal;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import ch.qos.logback.classic.Level;
+
+public class StructuredLogBuffer {
+
+ private final Object lock = new Object();
+ private final Deque<LogSnapshot> entries = new ArrayDeque<>();
+ private int maxEntriesKept;
+
+ public StructuredLogBuffer(int maxEntriesKept) {
+ this.maxEntriesKept = Math.max(1, maxEntriesKept);
+ }
+
+ public void append(LogSnapshot snapshot) {
+ synchronized (lock) {
+ entries.addLast(snapshot);
+ trimToSize();
+ }
+ }
+
+ public List<LogSnapshot> getRecent(Pattern pattern, Level minLevel, int
maxEntries) {
+ synchronized (lock) {
+ List<LogSnapshot> matches = new ArrayList<>();
+ int remaining = Math.max(1, maxEntries);
+
+ for (var iterator = entries.descendingIterator();
iterator.hasNext() && remaining > 0; ) {
+ LogSnapshot snapshot = iterator.next();
+ if (!matches(snapshot, pattern, minLevel)) {
+ continue;
+ }
+ matches.add(snapshot);
+ remaining--;
+ }
+
+ return matches;
+ }
+ }
+
+ private boolean matches(LogSnapshot snapshot, Pattern pattern, Level
minLevel) {
+ if (snapshot.level().isGreaterOrEqual(minLevel)) {
+ if (pattern == null) {
+ return true;
+ }
+ return matchesField(pattern, snapshot.level() != null ?
snapshot.level().levelStr : null)
+ || matchesField(pattern, snapshot.loggerName())
+ || matchesField(pattern, snapshot.threadName())
+ || matchesField(pattern, snapshot.formattedMessage())
+ || matchesField(pattern, snapshot.throwableText())
+ || matchesMdc(pattern, snapshot);
+ }
+ return false;
+ }
+
+ private boolean matchesMdc(Pattern pattern, LogSnapshot snapshot) {
+ if (snapshot.mdc().isEmpty()) {
+ return false;
+ }
+ for (var entry : snapshot.mdc().entrySet()) {
+ if (matchesField(pattern, entry.getKey()) || matchesField(pattern,
entry.getValue())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean matchesField(Pattern pattern, String value) {
+ return value != null && !value.isEmpty() &&
pattern.matcher(value).find();
+ }
+
+ private void trimToSize() {
+ while (entries.size() > maxEntriesKept) {
+ entries.removeFirst();
+ }
+ }
+}
diff --git
a/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppender.java
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppender.java
new file mode 100644
index 00000000..95a02db0
--- /dev/null
+++
b/mcp-server-contributions/src/main/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppender.java
@@ -0,0 +1,173 @@
+/*
+ * 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.sling.mcp.server.impl.contribs.internal;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.classic.spi.IThrowableProxy;
+import ch.qos.logback.classic.spi.StackTraceElementProxy;
+import ch.qos.logback.core.Appender;
+import ch.qos.logback.core.AppenderBase;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+
+@Component(
+ service = {Appender.class, StructuredLogBufferAppender.class},
+ property = {
+ "loggers=ROOT",
+ "service.description=Structured in-memory MCP log appender",
+ "service.vendor=The Apache Software Foundation"
+ })
+@Designate(ocd = StructuredLogBufferAppender.Configuration.class)
+public class StructuredLogBufferAppender extends AppenderBase<ILoggingEvent> {
+
+ // Forward compatibility with logback 1.5+, where IThrowableProxy may
expose getOverridingMessage().
+ private static final MethodHandle GET_OVERRIDING_MESSAGE =
findGetOverridingMessage();
+
+ @ObjectClassDefinition(name = "Apache Sling MCP Structured Log Buffer")
+ public @interface Configuration {
+
+ @AttributeDefinition(name = "Max entries")
+ int maxEntries() default 10000;
+ }
+
+ private final StructuredLogBuffer buffer;
+
+ @Activate
+ public StructuredLogBufferAppender(Configuration configuration) {
+ buffer = new StructuredLogBuffer(configuration.maxEntries());
+ setName("mcp-structured-log-buffer");
+ }
+
+ public StructuredLogBuffer getBuffer() {
+ return buffer;
+ }
+
+ @Override
+ protected void append(ILoggingEvent eventObject) {
+ if (eventObject == null) {
+ return;
+ }
+
+ buffer.append(new LogSnapshot(
+ eventObject.getTimeStamp(),
+ eventObject.getLevel(),
+ eventObject.getLoggerName(),
+ eventObject.getThreadName(),
+ eventObject.getFormattedMessage(),
+ getThrowableText(eventObject),
+ copyMdc(eventObject)));
+ }
+
+ private Map<String, String> copyMdc(ILoggingEvent eventObject) {
+ Map<String, String> mdc = eventObject.getMDCPropertyMap();
+ if (mdc == null || mdc.isEmpty()) {
+ return Map.of();
+ }
+ return new LinkedHashMap<>(mdc);
+ }
+
+ private String getThrowableText(ILoggingEvent eventObject) {
+ IThrowableProxy throwableProxy = eventObject.getThrowableProxy();
+ if (throwableProxy == null) {
+ return null;
+ }
+
+ StringBuilder text = new StringBuilder();
+ appendThrowable(text, throwableProxy, null);
+ return text.toString();
+ }
+
+ private void appendThrowable(StringBuilder text, IThrowableProxy
throwableProxy, String prefix) {
+ if (prefix != null) {
+ text.append(prefix);
+ }
+ text.append(getThrowableHeader(throwableProxy)).append('\n');
+
+ StackTraceElementProxy[] stackTrace =
throwableProxy.getStackTraceElementProxyArray();
+ if (stackTrace != null) {
+ int framesToRender = Math.max(0, stackTrace.length - Math.max(0,
throwableProxy.getCommonFrames()));
+ for (int i = 0; i < framesToRender; i++) {
+ text.append('\t').append(stackTrace[i]).append('\n');
+ }
+ if (throwableProxy.getCommonFrames() > 0) {
+ text.append("\t... ")
+ .append(throwableProxy.getCommonFrames())
+ .append(" common frames omitted")
+ .append('\n');
+ }
+ }
+
+ IThrowableProxy[] suppressed = throwableProxy.getSuppressed();
+ if (suppressed != null) {
+ for (IThrowableProxy suppressedThrowable : suppressed) {
+ appendThrowable(text, suppressedThrowable, "Suppressed: ");
+ }
+ }
+
+ IThrowableProxy cause = throwableProxy.getCause();
+ if (cause != null) {
+ appendThrowable(text, cause, "Caused by: ");
+ }
+ }
+
+ private String getThrowableHeader(IThrowableProxy throwableProxy) {
+ String overridingMessage = getOverridingMessage(throwableProxy);
+ if (overridingMessage != null && !overridingMessage.isEmpty()) {
+ return overridingMessage;
+ }
+
+ StringBuilder header = new
StringBuilder(throwableProxy.getClassName());
+ String message = throwableProxy.getMessage();
+ if (message != null && !message.isEmpty()) {
+ header.append(": ").append(message);
+ }
+ return header.toString();
+ }
+
+ private String getOverridingMessage(IThrowableProxy throwableProxy) {
+ if (GET_OVERRIDING_MESSAGE == null) {
+ return null;
+ }
+
+ try {
+ Object overridingMessage =
GET_OVERRIDING_MESSAGE.invoke(throwableProxy);
+ return overridingMessage instanceof String ? (String)
overridingMessage : null;
+ } catch (Throwable e) {
+ return null;
+ }
+ }
+
+ private static MethodHandle findGetOverridingMessage() {
+ try {
+ return MethodHandles.publicLookup()
+ .findVirtual(IThrowableProxy.class,
"getOverridingMessage", MethodType.methodType(String.class));
+ } catch (NoSuchMethodException | IllegalAccessException e) {
+ return null;
+ }
+ }
+}
diff --git
a/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppenderTest.java
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppenderTest.java
new file mode 100644
index 00000000..c9064e72
--- /dev/null
+++
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferAppenderTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.sling.mcp.server.impl.contribs.internal;
+
+import java.util.List;
+import java.util.Map;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.LoggingEvent;
+import org.junit.jupiter.api.Test;
+import org.osgi.util.converter.Converters;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class StructuredLogBufferAppenderTest {
+
+ @Test
+ void appenderSnapshotsFormattedMessageAndThrowable() {
+ StructuredLogBufferAppender appender = new
StructuredLogBufferAppender(configuration(5));
+
+ LoggerContext context = new LoggerContext();
+ appender.setContext(context);
+ Logger logger = context.getLogger("test.logger");
+ RuntimeException failure = new RuntimeException("error");
+ LoggingEvent event = new LoggingEvent(getClass().getName(), logger,
Level.ERROR, "message", failure, null);
+ event.setMDCPropertyMap(java.util.Map.of());
+ event.setThreadName("worker-1");
+
+ appender.append(event);
+
+ List<LogSnapshot> logs = appender.getBuffer().getRecent(null,
Level.TRACE, 10);
+ assertEquals(1, logs.size());
+ assertEquals("message", logs.get(0).formattedMessage());
+ assertEquals("worker-1", logs.get(0).threadName());
+ assertNotNull(logs.get(0).throwableText());
+ }
+
+ private StructuredLogBufferAppender.Configuration configuration(int
maxEntries) {
+ return Converters.standardConverter()
+ .convert(Map.of("maxEntries", maxEntries))
+ .to(StructuredLogBufferAppender.Configuration.class);
+ }
+}
diff --git
a/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferTest.java
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferTest.java
new file mode 100644
index 00000000..d92efced
--- /dev/null
+++
b/mcp-server-contributions/src/test/java/org/apache/sling/mcp/server/impl/contribs/internal/StructuredLogBufferTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.sling.mcp.server.impl.contribs.internal;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import ch.qos.logback.classic.Level;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class StructuredLogBufferTest {
+
+ @Test
+ void keepsOnlyNewestEntriesWithinCapacity() {
+ StructuredLogBuffer buffer = new StructuredLogBuffer(2);
+
+ buffer.append(snapshot(1L, Level.INFO, "first"));
+ buffer.append(snapshot(2L, Level.INFO, "second"));
+ buffer.append(snapshot(3L, Level.INFO, "third"));
+
+ List<LogSnapshot> logs = buffer.getRecent(null, Level.TRACE, 10);
+ assertEquals(
+ List.of("third", "second"),
+ logs.stream().map(LogSnapshot::formattedMessage).toList());
+ }
+
+ @Test
+ void filtersByLevelAndRegex() {
+ StructuredLogBuffer buffer = new StructuredLogBuffer(10);
+
+ buffer.append(snapshot(1L, Level.DEBUG, "debug trace"));
+ buffer.append(snapshot(2L, Level.INFO, "first user ok"));
+ buffer.append(snapshot(3L, Level.ERROR, "first user failure"));
+
+ List<LogSnapshot> logs = buffer.getRecent(Pattern.compile("first",
Pattern.CASE_INSENSITIVE), Level.INFO, 10);
+
+ assertEquals(
+ List.of("first user failure", "first user ok"),
+ logs.stream().map(LogSnapshot::formattedMessage).toList());
+ }
+
+ private LogSnapshot snapshot(long timeMillis, Level level, String message)
{
+ return new LogSnapshot(timeMillis, level, "logger", "thread", message,
null, Map.of());
+ }
+}