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 e1773013 mcp bundle diagnoser, component status, logs (#118)
e1773013 is described below
commit e1773013330512b3535acf7bf588afb353aba32d
Author: Niek Raaijmakers <[email protected]>
AuthorDate: Thu Dec 11 14:24:55 2025 +0100
mcp bundle diagnoser, component status, logs (#118)
Co-authored-by: Niek Raaijmakers <[email protected]>
---
mcp-server/pom.xml | 11 +
.../contribs/ComponentResourceContribution.java | 126 +++++
.../server/impl/contribs/LogToolContribution.java | 355 ++++++++++++++
.../contribs/OsgiBundleDiagnosticContribution.java | 522 +++++++++++++++++++++
.../contribs/OsgiDiagnosticPromptContribution.java | 155 ++++++
5 files changed, 1169 insertions(+)
diff --git a/mcp-server/pom.xml b/mcp-server/pom.xml
index d2c09fbe..edca0933 100644
--- a/mcp-server/pom.xml
+++ b/mcp-server/pom.xml
@@ -62,6 +62,11 @@
<artifactId>org.osgi.service.component.annotations</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>org.osgi.service.component</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.osgi</groupId>
<artifactId>org.osgi.service.metatype.annotations</artifactId>
@@ -100,6 +105,12 @@
<version>0.17.0</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>org.osgi.service.log</artifactId>
+ <version>1.3.0</version>
+ <scope>provided</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/ComponentResourceContribution.java
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/ComponentResourceContribution.java
new file mode 100644
index 00000000..7a307fda
--- /dev/null
+++
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/ComponentResourceContribution.java
@@ -0,0 +1,126 @@
+/*
+ * 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;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import io.modelcontextprotocol.server.McpStatelessServerFeatures;
+import
io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification;
+import
io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
+import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
+import org.apache.sling.mcp.server.impl.McpServerContribution;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.runtime.ServiceComponentRuntime;
+import org.osgi.service.component.runtime.dto.ComponentDescriptionDTO;
+
+@Component
+public class ComponentResourceContribution implements McpServerContribution {
+
+ private static String getStateString(int state) {
+ return switch (state) {
+ case 1 -> "UNSATISFIED_CONFIGURATION";
+ case 2 -> "UNSATISFIED_REFERENCE";
+ case 4 -> "SATISFIED";
+ case 8 -> "ACTIVE";
+ case 16 -> "REGISTERED";
+ case 32 -> "FACTORY";
+ case 64 -> "DISABLED";
+ case 128 -> "ENABLING";
+ case 256 -> "ENABLED";
+ case 512 -> "DISABLING";
+ default -> "UNKNOWN";
+ };
+ }
+
+ @Reference
+ private ServiceComponentRuntime scr;
+
+ @Activate
+ public ComponentResourceContribution() {}
+
+ @Override
+ public Optional<SyncResourceSpecification> getSyncResourceSpecification() {
+
+ return Optional.of(new
McpStatelessServerFeatures.SyncResourceSpecification(
+ new Resource.Builder()
+ .name("component")
+ .uri("component://")
+ .description("OSGi component status")
+ .mimeType("text/plain")
+ .build(),
+ (context, request) -> {
+ Collection<ComponentDescriptionDTO> components =
scr.getComponentDescriptionDTOs();
+ String componentInfo = components.stream()
+ .map(c -> {
+ String state =
scr.getComponentConfigurationDTOs(c).stream()
+ .map(config ->
getStateString(config.state))
+ .collect(Collectors.joining(", "));
+ return "Component " + c.name + " is in
state(s): " + state;
+ })
+ .collect(Collectors.joining("\n"));
+
+ TextResourceContents contents =
+ new TextResourceContents("component://",
"text/plain", componentInfo);
+
+ return new McpSchema.ReadResourceResult(List.of(contents));
+ }));
+ }
+
+ @Override
+ public Optional<SyncResourceTemplateSpecification>
getSyncResourceTemplateSpecification() {
+ return Optional.of(new
McpStatelessServerFeatures.SyncResourceTemplateSpecification(
+ new ResourceTemplate.Builder()
+ .uriTemplate("components://state/{state}")
+ .name("components")
+ .build(),
+ (context, request) -> {
+ String componentInfo = "";
+ String uri = request.uri().toLowerCase(Locale.ENGLISH);
+
+ if (uri.startsWith("components://state/")) {
+ String requestedState =
uri.substring("components://state/".length());
+ Collection<ComponentDescriptionDTO> components =
scr.getComponentDescriptionDTOs();
+
+ componentInfo = components.stream()
+ .flatMap(c ->
scr.getComponentConfigurationDTOs(c).stream()
+ .filter(config ->
getStateString(config.state)
+ .toLowerCase(Locale.ENGLISH)
+ .equals(requestedState))
+ .map(config -> "Component " + c.name +
" is in state: "
+ +
getStateString(config.state)))
+ .collect(Collectors.joining("\n"));
+ }
+
+ TextResourceContents contents =
+ new TextResourceContents(request.uri(),
"text/plain", componentInfo);
+
+ return new ReadResourceResult(List.of(contents));
+ }));
+ }
+}
diff --git
a/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
new file mode 100644
index 00000000..9e68661f
--- /dev/null
+++
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java
@@ -0,0 +1,355 @@
+/*
+ * 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;
+
+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.Optional;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import io.modelcontextprotocol.json.McpJsonMapper;
+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.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 AEM/OSGi logs with filtering capabilities.
+ * Allows filtering by regex pattern, log level, and maximum number of entries.
+ */
+@Component
+public class LogToolContribution implements McpServerContribution {
+
+ @Reference
+ private LogReaderService logReaderService;
+
+ @Reference
+ private McpJsonMapper jsonMapper;
+
+ private static final int DEFAULT_MAX_LOGS = 200;
+ private static final SimpleDateFormat DATE_FORMAT = new
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+
+ @Override
+ public Optional<SyncToolSpecification> getSyncToolSpecification() {
+
+ var schema = """
+ {
+ "type" : "object",
+ "id" : "urn:jsonschema:LogFilterInput",
+ "properties" : {
+ "regex" : {
+ "type" : "string",
+ "description" : "Optional regex pattern to filter log
messages. If not provided, all logs are returned."
+ },
+ "logLevel" : {
+ "type" : "string",
+ "description" : "Minimum log level to return. Options:
ERROR, WARN, INFO, DEBUG, TRACE. Defaults to ERROR.",
+ "enum" : ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"]
+ },
+ "maxEntries" : {
+ "type" : "integer",
+ "description" : "Maximum number of log entries to
return. Defaults to 200.",
+ "minimum" : 1,
+ "maximum" : 1000
+ }
+ }
+ }
+ """;
+
+ return Optional.of(new SyncToolSpecification(
+ Tool.builder()
+ .name("aem-logs")
+ .description("Retrieve AEM/OSGi logs with optional
filtering. "
+ + "Supports filtering by regex pattern, log
level (ERROR, WARN, INFO, DEBUG, TRACE), "
+ + "and maximum number of entries. Returns most
recent logs first.")
+ .inputSchema(jsonMapper, schema)
+ .build(),
+ (exchange, request) -> {
+ String regexPattern = (String)
request.arguments().get("regex");
+ String logLevelStr = (String)
request.arguments().get("logLevel");
+ Object maxEntriesObj =
request.arguments().get("maxEntries");
+
+ // Parse parameters
+ int maxEntries = DEFAULT_MAX_LOGS;
+ if (maxEntriesObj instanceof Number) {
+ maxEntries = ((Number) maxEntriesObj).intValue();
+ maxEntries = Math.min(maxEntries, 1000); // Cap at 1000
+ }
+
+ int minLogLevel = LogService.LOG_ERROR;
+ if (logLevelStr != null && !logLevelStr.isEmpty()) {
+ minLogLevel = parseLogLevel(logLevelStr);
+ if (minLogLevel == -1) {
+ return new CallToolResult(
+ "Invalid log level: " + logLevelStr
+ + ". Valid options are: ERROR,
WARN, INFO, DEBUG, TRACE",
+ Boolean.TRUE);
+ }
+ }
+
+ // Compile regex pattern if provided
+ Pattern pattern = null;
+ if (regexPattern != null && !regexPattern.isEmpty()) {
+ try {
+ pattern = Pattern.compile(regexPattern,
Pattern.CASE_INSENSITIVE);
+ } catch (PatternSyntaxException e) {
+ return new CallToolResult("Invalid regex pattern:
" + e.getMessage(), Boolean.TRUE);
+ }
+ }
+
+ // Collect and filter logs
+ List<LogEntry> filteredLogs = collectLogs(pattern,
minLogLevel, maxEntries);
+
+ // Format output
+ String result = formatLogs(filteredLogs, regexPattern,
minLogLevel, maxEntries);
+
+ return new CallToolResult(result, Boolean.FALSE);
+ }));
+ }
+
+ 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) {
+ 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;
+ };
+ }
+
+ private String formatLogs(List<LogEntry> logs, String regexPattern, int
minLogLevel, int maxEntries) {
+ StringBuilder result = new StringBuilder();
+
+ result.append("=== AEM Log Entries ===\n\n");
+ result.append("Filter Settings:\n");
+ result.append(" - Log Level:
").append(getLogLevelName(minLogLevel)).append(" and higher severity\n");
+ result.append(" - Regex Pattern: ")
+ .append(regexPattern != null ? regexPattern : "(none)")
+ .append("\n");
+ result.append(" - Max Entries: ").append(maxEntries).append("\n");
+ result.append(" - Entries Found:
").append(logs.size()).append("\n\n");
+
+ if (logs.isEmpty()) {
+ result.append("No log entries found matching the criteria.\n");
+ return result.toString();
+ }
+
+ result.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
+
+ for (int i = 0; i < logs.size(); i++) {
+ LogEntry entry = logs.get(i);
+ formatLogEntry(entry, i + 1, result);
+
+ if (i < logs.size() - 1) {
+ result.append("\n");
+ }
+ }
+
+ return result.toString();
+ }
+
+ private void formatLogEntry(LogEntry 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("] ");
+ }
+
+ // Add message
+ String message = entry.getMessage();
+ 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");
+ }
+ }
+
+ // 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();
+
+ // Limit stack trace to first 10 lines
+ String[] lines = stackTrace.split("\n");
+ int maxLines = Math.min(lines.length, 10);
+ for (int i = 0; i < maxLines; i++) {
+ result.append(" ").append(lines[i]).append("\n");
+ }
+ if (lines.length > maxLines) {
+ result.append(" ... (").append(lines.length -
maxLines).append(" more lines)\n");
+ }
+ }
+ }
+
+ 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(" ");
+ }
+ desc.append("[id=").append(serviceId).append("]");
+ }
+
+ return desc.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;
+ };
+ }
+}
diff --git
a/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiBundleDiagnosticContribution.java
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiBundleDiagnosticContribution.java
new file mode 100644
index 00000000..a4affef6
--- /dev/null
+++
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiBundleDiagnosticContribution.java
@@ -0,0 +1,522 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import io.modelcontextprotocol.json.McpJsonMapper;
+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.McpServerContribution;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.wiring.BundleRequirement;
+import org.osgi.framework.wiring.BundleWire;
+import org.osgi.framework.wiring.BundleWiring;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.runtime.ServiceComponentRuntime;
+import org.osgi.service.component.runtime.dto.ComponentConfigurationDTO;
+import org.osgi.service.component.runtime.dto.ComponentDescriptionDTO;
+import org.osgi.service.component.runtime.dto.UnsatisfiedReferenceDTO;
+
+/**
+ * MCP Tool that diagnoses why OSGi bundles and components aren't starting.
+ * This tool provides detailed diagnostic information about:
+ * - Bundle state and resolution issues
+ * - Unsatisfied package imports
+ * - Missing service dependencies for components
+ * - Configuration problems
+ */
+@Component
+public class OsgiBundleDiagnosticContribution implements McpServerContribution
{
+
+ @Reference
+ private McpJsonMapper jsonMapper;
+
+ @Reference
+ private ServiceComponentRuntime scr;
+
+ private final BundleContext ctx;
+
+ @Activate
+ public OsgiBundleDiagnosticContribution(BundleContext ctx) {
+ this.ctx = ctx;
+ }
+
+ @Override
+ public Optional<SyncToolSpecification> getSyncToolSpecification() {
+
+ var schema = """
+ {
+ "type" : "object",
+ "id" : "urn:jsonschema:DiagnoseBundleInput",
+ "properties" : {
+ "bundleSymbolicName" : {
+ "type" : "string",
+ "description" : "The symbolic name of the bundle to
diagnose. If not provided, will diagnose all problematic bundles."
+ }
+ }
+ }
+ """;
+
+ return Optional.of(new SyncToolSpecification(
+ Tool.builder()
+ .name("diagnose-osgi-bundle")
+ .description(
+ "Diagnose why an OSGi bundle or component
isn't starting. Provides detailed information about unsatisfied dependencies,
missing packages, and component configuration issues.")
+ .inputSchema(jsonMapper, schema)
+ .build(),
+ (exchange, request) -> {
+ String bundleSymbolicName = (String)
request.arguments().get("bundleSymbolicName");
+
+ if (bundleSymbolicName != null &&
!bundleSymbolicName.isEmpty()) {
+ return diagnoseSpecificBundle(bundleSymbolicName);
+ } else {
+ return diagnoseAllProblematicBundles();
+ }
+ }));
+ }
+
+ private CallToolResult diagnoseSpecificBundle(String symbolicName) {
+ Bundle bundle = findBundle(symbolicName);
+ if (bundle == null) {
+ return new CallToolResult("Bundle '" + symbolicName + "' not
found.", Boolean.TRUE);
+ }
+
+ StringBuilder result = new StringBuilder();
+ result.append("=== Bundle Diagnostic Report ===\n\n");
+ result.append("Bundle:
").append(bundle.getSymbolicName()).append("\n");
+ result.append("Version: ").append(bundle.getVersion()).append("\n");
+ result.append("State:
").append(getStateName(bundle.getState())).append("\n\n");
+
+ if (bundle.getState() == Bundle.ACTIVE) {
+ result.append("✓ Bundle is ACTIVE and running normally.\n\n");
+ // Check components
+ appendComponentDiagnostics(bundle, result);
+ } else {
+ result.append("✗ Bundle is NOT active. Analyzing issues...\n\n");
+ analyzeBundleIssues(bundle, result);
+ appendComponentDiagnostics(bundle, result);
+ }
+
+ return new CallToolResult(result.toString(), Boolean.FALSE);
+ }
+
+ private CallToolResult diagnoseAllProblematicBundles() {
+ StringBuilder result = new StringBuilder();
+ result.append("=== OSGi System Diagnostic Report ===\n\n");
+
+ List<Bundle> problematicBundles = Arrays.stream(ctx.getBundles())
+ .filter(b -> b.getState() != Bundle.ACTIVE && b.getState() !=
Bundle.UNINSTALLED)
+ .collect(Collectors.toList());
+
+ if (problematicBundles.isEmpty()) {
+ result.append("✓ All bundles are active!\n\n");
+ } else {
+ result.append("Found ").append(problematicBundles.size()).append("
problematic bundle(s):\n\n");
+
+ for (Bundle bundle : problematicBundles) {
+ result.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
+ result.append("Bundle:
").append(bundle.getSymbolicName()).append("\n");
+ result.append("State:
").append(getStateName(bundle.getState())).append("\n");
+ analyzeBundleIssues(bundle, result);
+ result.append("\n");
+ }
+ }
+
+ // Check for components with issues
+ appendAllComponentIssues(result);
+
+ return new CallToolResult(result.toString(), Boolean.FALSE);
+ }
+
+ private void analyzeBundleIssues(Bundle bundle, StringBuilder result) {
+ if (bundle.getState() == Bundle.INSTALLED) {
+ result.append("\n⚠ Bundle is INSTALLED but not RESOLVED\n");
+ result.append("This typically means there are unsatisfied
dependencies.\n\n");
+
+ boolean foundIssues = false;
+
+ // First try to get info from BundleWiring (for resolved
requirements)
+ BundleWiring wiring = bundle.adapt(BundleWiring.class);
+ if (wiring != null) {
+ List<BundleRequirement> requirements =
wiring.getRequirements(null);
+
+ for (BundleRequirement req : requirements) {
+ List<BundleWire> wires =
wiring.getRequiredWires(req.getNamespace());
+ if (wires == null || wires.isEmpty()) {
+ if (!foundIssues) {
+ result.append("Unsatisfied Requirements:\n");
+ foundIssues = true;
+ }
+ result.append(" ✗ ")
+ .append(req.getNamespace())
+ .append(": ")
+ .append(req.getDirectives())
+ .append("\n");
+
+ // For package imports, show which package is missing
+ if ("osgi.wiring.package".equals(req.getNamespace())) {
+ String filter = req.getDirectives().get("filter");
+ result.append(" Missing package: ")
+ .append(filter)
+ .append("\n");
+ }
+ }
+ }
+ }
+
+ // If no issues found via wiring, check manifest directly and
compare with available exports
+ if (!foundIssues) {
+ String importPackage =
bundle.getHeaders().get(Constants.IMPORT_PACKAGE);
+
+ if (importPackage != null && !importPackage.isEmpty()) {
+ result.append("Analyzing Package Dependencies:\n\n");
+ analyzePackageImports(importPackage, result);
+ foundIssues = true;
+ }
+
+ String requireBundle =
bundle.getHeaders().get(Constants.REQUIRE_BUNDLE);
+ if (requireBundle != null && !requireBundle.isEmpty()) {
+ result.append("\nRequired Bundles (from manifest):\n");
+ result.append(" ")
+ .append(requireBundle.replace(",", ",\n "))
+ .append("\n\n");
+ result.append("⚠ One or more of these bundles are not
available or not in the correct state.\n");
+ foundIssues = true;
+ }
+ }
+
+ if (!foundIssues) {
+ result.append("No dependency information found. Bundle may
have internal errors.\n");
+ }
+ } else if (bundle.getState() == Bundle.RESOLVED) {
+ result.append("\n✓ Bundle is RESOLVED (all dependencies
satisfied)\n");
+ result.append("Bundle can be started manually if it's not a
fragment.\n");
+ } else if (bundle.getState() == Bundle.STARTING) {
+ result.append("\n⚠ Bundle is STARTING (stuck during
activation)\n");
+ result.append("Check for errors in bundle activator or circular
dependencies.\n");
+ }
+
+ // Check for fragment information
+ if ((bundle.getHeaders().get(Constants.FRAGMENT_HOST)) != null) {
+ result.append("\nNote: This is a fragment bundle (attached to: ")
+ .append(bundle.getHeaders().get(Constants.FRAGMENT_HOST))
+ .append(")\n");
+ }
+ }
+
+ /**
+ * Analyzes imported packages by checking if they are available in the
OSGi environment.
+ * Scans all bundles to find which packages are exported and matches them
against imports.
+ */
+ private void analyzePackageImports(String importPackageHeader,
StringBuilder result) {
+ // Build a map of all exported packages in the system
+ java.util.Map<String, List<PackageExport>> exportedPackages = new
java.util.HashMap<>();
+
+ for (Bundle b : ctx.getBundles()) {
+ if (b.getState() == Bundle.UNINSTALLED) {
+ continue;
+ }
+
+ String exportPackage =
b.getHeaders().get(Constants.EXPORT_PACKAGE);
+ if (exportPackage != null && !exportPackage.isEmpty()) {
+ List<PackageInfo> exports = parsePackages(exportPackage);
+ for (PackageInfo pkg : exports) {
+ if (pkg.name != null && !pkg.name.isEmpty() &&
pkg.name.contains(".")) {
+ exportedPackages
+ .computeIfAbsent(pkg.name, k -> new
ArrayList<>())
+ .add(new PackageExport(b, pkg.name,
pkg.version));
+ }
+ }
+ }
+ }
+
+ // Parse and check each imported package
+ List<PackageInfo> imports = parsePackages(importPackageHeader);
+ int missingCount = 0;
+ int availableCount = 0;
+ List<String> missingPackages = new ArrayList<>();
+
+ for (PackageInfo importPkg : imports) {
+ // Skip invalid package names
+ if (importPkg.name == null || importPkg.name.isEmpty() ||
!importPkg.name.contains(".")) {
+ continue;
+ }
+
+ List<PackageExport> availableExports =
exportedPackages.get(importPkg.name);
+
+ if (availableExports == null || availableExports.isEmpty()) {
+ missingCount++;
+ missingPackages.add(" ✗ " + importPkg.name
+ + (importPkg.version.isEmpty() ? "" : " " +
importPkg.version)
+ + (importPkg.optional ? " (optional)" : "") + "\n");
+ } else {
+ availableCount++;
+ }
+ }
+
+ // Only show missing packages
+ if (missingCount > 0) {
+ result.append("Missing Packages (")
+ .append(missingCount)
+ .append(" of ")
+ .append(imports.size())
+ .append(" imports):\n\n");
+ for (String missing : missingPackages) {
+ result.append(missing);
+ }
+ result.append(
+ "\n⚠ Action Required: Install bundles that provide the
missing packages, or downgrade / change the dependencies.\n");
+ } else {
+ result.append("✓ All ").append(imports.size()).append(" imported
packages are available.\n");
+ result.append("Bundle should be resolvable. Check for other
issues.\n");
+ }
+ }
+
+ /**
+ * Parse OSGi package header (Import-Package or Export-Package).
+ * Handles complex cases with version ranges, attributes, and directives.
+ */
+ private List<PackageInfo> parsePackages(String header) {
+ List<PackageInfo> packages = new ArrayList<>();
+ if (header == null || header.isEmpty()) {
+ return packages;
+ }
+
+ // State machine for parsing
+ StringBuilder current = new StringBuilder();
+ int depth = 0; // Track depth of quotes and brackets
+ boolean inQuotes = false;
+
+ for (int i = 0; i < header.length(); i++) {
+ char c = header.charAt(i);
+
+ if (c == '"') {
+ inQuotes = !inQuotes;
+ current.append(c);
+ } else if (!inQuotes && (c == '[' || c == '(')) {
+ depth++;
+ current.append(c);
+ } else if (!inQuotes && (c == ']' || c == ')')) {
+ depth--;
+ current.append(c);
+ } else if (c == ',' && depth == 0 && !inQuotes) {
+ // This is a package separator
+ String pkg = current.toString().trim();
+ if (!pkg.isEmpty()) {
+ packages.add(parsePackageEntry(pkg));
+ }
+ current = new StringBuilder();
+ } else {
+ current.append(c);
+ }
+ }
+
+ // Don't forget the last package
+ String pkg = current.toString().trim();
+ if (!pkg.isEmpty()) {
+ packages.add(parsePackageEntry(pkg));
+ }
+
+ return packages;
+ }
+
+ /**
+ * Parse a single package entry like
"com.example.pkg;version="[1.0,2.0)";resolution:=optional"
+ */
+ private PackageInfo parsePackageEntry(String entry) {
+ // Split on first semicolon to separate package name from attributes
+ int semicolonPos = entry.indexOf(';');
+ String packageName;
+ String attributes;
+
+ if (semicolonPos > 0) {
+ packageName = entry.substring(0, semicolonPos).trim();
+ attributes = entry.substring(semicolonPos + 1);
+ } else {
+ packageName = entry.trim();
+ attributes = "";
+ }
+
+ // Extract version
+ String version = "";
+ if (attributes.contains("version=")) {
+ int vStart = attributes.indexOf("version=") + 8;
+ int vEnd = attributes.length();
+ // Find the end of the version value (next semicolon outside
quotes)
+ boolean inQuote = false;
+ for (int i = vStart; i < attributes.length(); i++) {
+ char c = attributes.charAt(i);
+ if (c == '"') {
+ inQuote = !inQuote;
+ } else if (c == ';' && !inQuote) {
+ vEnd = i;
+ break;
+ }
+ }
+ version = attributes.substring(vStart,
vEnd).trim().replaceAll("\"", "");
+ }
+
+ boolean optional = attributes.contains("resolution:=optional");
+
+ return new PackageInfo(packageName, version, optional);
+ }
+
+ private String extractVersion(String exportEntry) {
+ if (exportEntry.contains("version=")) {
+ int start = exportEntry.indexOf("version=") + 8;
+ int end = exportEntry.indexOf(";", start);
+ if (end == -1) {
+ end = exportEntry.length();
+ }
+ return exportEntry.substring(start, end).replaceAll("\"",
"").trim();
+ }
+ return "0.0.0";
+ }
+
+ private static class PackageInfo {
+ final String name;
+ final String version;
+ final boolean optional;
+
+ PackageInfo(String name, String version, boolean optional) {
+ this.name = name;
+ this.version = version;
+ this.optional = optional;
+ }
+ }
+
+ private static class PackageExport {
+ final Bundle bundle;
+ final String packageName;
+ final String version;
+
+ PackageExport(Bundle bundle, String packageName, String version) {
+ this.bundle = bundle;
+ this.packageName = packageName;
+ this.version = version;
+ }
+ }
+
+ private void appendComponentDiagnostics(Bundle bundle, StringBuilder
result) {
+ Collection<ComponentDescriptionDTO> components =
scr.getComponentDescriptionDTOs(bundle);
+
+ if (components.isEmpty()) {
+ return;
+ }
+
+ result.append("\n--- Declarative Services Components ---\n\n");
+
+ for (ComponentDescriptionDTO desc : components) {
+ Collection<ComponentConfigurationDTO> configs =
scr.getComponentConfigurationDTOs(desc);
+
+ result.append("Component: ").append(desc.name).append("\n");
+
+ if (configs.isEmpty()) {
+ result.append(" Status: Not configured/instantiated\n");
+ }
+
+ for (ComponentConfigurationDTO config : configs) {
+ result.append(" State: ")
+ .append(getComponentStateName(config.state))
+ .append("\n");
+
+ if (config.state ==
ComponentConfigurationDTO.UNSATISFIED_REFERENCE) {
+ result.append(" ✗ Unsatisfied Service References:\n");
+ for (UnsatisfiedReferenceDTO ref :
config.unsatisfiedReferences) {
+ result.append(" - ")
+ .append(ref.name)
+ .append(" (")
+ .append(ref.target)
+ .append(")\n");
+ }
+ } else if (config.state ==
ComponentConfigurationDTO.UNSATISFIED_CONFIGURATION) {
+ result.append(" ✗ Missing required configuration\n");
+ } else if (config.state == ComponentConfigurationDTO.SATISFIED
+ || config.state == ComponentConfigurationDTO.ACTIVE) {
+ result.append(" ✓ Component is working correctly\n");
+ }
+ }
+ result.append("\n");
+ }
+ }
+
+ private void appendAllComponentIssues(StringBuilder result) {
+ List<ComponentDescriptionDTO> allComponents = new
ArrayList<>(scr.getComponentDescriptionDTOs());
+ List<String> problematicComponents = new ArrayList<>();
+
+ for (ComponentDescriptionDTO desc : allComponents) {
+ Collection<ComponentConfigurationDTO> configs =
scr.getComponentConfigurationDTOs(desc);
+
+ for (ComponentConfigurationDTO config : configs) {
+ if (config.state ==
ComponentConfigurationDTO.UNSATISFIED_REFERENCE
+ || config.state ==
ComponentConfigurationDTO.UNSATISFIED_CONFIGURATION) {
+ problematicComponents.add(desc.name + " (" +
getComponentStateName(config.state) + ")");
+ }
+ }
+ }
+
+ if (!problematicComponents.isEmpty()) {
+ result.append("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
+ result.append("Problematic Components:\n\n");
+ for (String comp : problematicComponents) {
+ result.append(" ✗ ").append(comp).append("\n");
+ }
+ }
+ }
+
+ private Bundle findBundle(String symbolicName) {
+ return Arrays.stream(ctx.getBundles())
+ .filter(b -> b.getSymbolicName().equals(symbolicName))
+ .findFirst()
+ .orElse(null);
+ }
+
+ private String getStateName(int state) {
+ return switch (state) {
+ case Bundle.UNINSTALLED -> "UNINSTALLED";
+ case Bundle.INSTALLED -> "INSTALLED";
+ case Bundle.RESOLVED -> "RESOLVED";
+ case Bundle.STARTING -> "STARTING";
+ case Bundle.STOPPING -> "STOPPING";
+ case Bundle.ACTIVE -> "ACTIVE";
+ default -> "UNKNOWN (" + state + ")";
+ };
+ }
+
+ private String getComponentStateName(int state) {
+ return switch (state) {
+ case ComponentConfigurationDTO.UNSATISFIED_CONFIGURATION ->
"UNSATISFIED_CONFIGURATION";
+ case ComponentConfigurationDTO.UNSATISFIED_REFERENCE ->
"UNSATISFIED_REFERENCE";
+ case ComponentConfigurationDTO.SATISFIED -> "SATISFIED";
+ case ComponentConfigurationDTO.ACTIVE -> "ACTIVE";
+ default -> "UNKNOWN (" + state + ")";
+ };
+ }
+}
diff --git
a/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiDiagnosticPromptContribution.java
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiDiagnosticPromptContribution.java
new file mode 100644
index 00000000..25ea95ba
--- /dev/null
+++
b/mcp-server/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiDiagnosticPromptContribution.java
@@ -0,0 +1,155 @@
+/*
+ * 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;
+
+import java.util.List;
+import java.util.Optional;
+
+import
io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
+import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
+import io.modelcontextprotocol.spec.McpSchema.Role;
+import org.apache.sling.mcp.server.impl.McpServerContribution;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * MCP Prompt that helps developers diagnose and fix OSGi bundle issues.
+ * This prompt teaches Cursor how to:
+ * - Identify why bundles aren't starting
+ * - Understand common OSGi issues
+ * - Provide actionable solutions
+ */
+@Component
+public class OsgiDiagnosticPromptContribution implements McpServerContribution
{
+
+ @Override
+ public Optional<SyncPromptSpecification> getSyncPromptSpecification() {
+ return Optional.of(new SyncPromptSpecification(
+ new Prompt(
+ "diagnose-osgi-issue",
+ "Diagnose OSGi Bundle Issues",
+ "Helps diagnose why an OSGi bundle or component isn't
starting in AEM/Sling. Provides step-by-step troubleshooting guidance.",
+ List.of(new PromptArgument(
+ "bundle-name",
+ "Bundle Symbolic Name",
+ "The symbolic name of the bundle that isn't
starting (optional - if not provided, will check all bundles)",
+ false))),
+ (context, request) -> {
+ String bundleName = (String)
request.arguments().get("bundle-name");
+
+ String instructions =
buildDiagnosticInstructions(bundleName);
+
+ PromptMessage msg = new PromptMessage(Role.ASSISTANT, new
McpSchema.TextContent(instructions));
+
+ return new GetPromptResult("OSGi Diagnostic Guide",
List.of(msg));
+ }));
+ }
+
+ private String buildDiagnosticInstructions(String bundleName) {
+ StringBuilder sb = new StringBuilder();
+
+ sb.append("# OSGi Bundle Diagnostic Assistant\n\n");
+
+ if (bundleName != null && !bundleName.isEmpty()) {
+ sb.append("I'll help you diagnose why the bundle '")
+ .append(bundleName)
+ .append("' isn't starting.\n\n");
+ sb.append("## Step 1: Run Diagnostic Tool\n\n");
+ sb.append("First, use the `diagnose-osgi-bundle` tool with
bundleSymbolicName='")
+ .append(bundleName)
+ .append("'\n\n");
+ } else {
+ sb.append("I'll help you diagnose OSGi bundle issues in your
AEM/Sling environment.\n\n");
+ sb.append("## Step 1: Identify Problematic Bundles\n\n");
+ sb.append("Use the `diagnose-osgi-bundle` tool without parameters
to scan all bundles.\n\n");
+ }
+
+ sb.append("## Step 2: Interpret Common Issues\n\n");
+
+ sb.append("### Bundle State: INSTALLED (Not Resolved)\n");
+ sb.append("**Problem**: Bundle dependencies aren't satisfied.\n\n");
+ sb.append("**Common Causes**:\n");
+ sb.append(
+ "- Missing package imports: Another bundle that exports the
required package isn't installed or active\n");
+ sb.append("- Version conflicts: Required package version doesn't match
available versions\n");
+ sb.append("- Missing bundle: A required bundle hasn't been
deployed\n\n");
+ sb.append("**Solutions**:\n");
+ sb.append("1. Check the diagnostic output for 'Unsatisfied
Requirements'\n");
+ sb.append("2. Look for the missing packages in the Import-Package
errors\n");
+ sb.append("3. Use `bundle://` resource to find bundles that export the
needed packages\n");
+ sb.append("4. Install missing bundles or update bundle manifests to
match available versions\n\n");
+
+ sb.append("### Bundle State: RESOLVED (Not Active)\n");
+ sb.append("**Problem**: Bundle has all dependencies but hasn't been
started.\n\n");
+ sb.append("**Common Causes**:\n");
+ sb.append("- Bundle has lazy activation policy\n");
+ sb.append("- Bundle is a fragment (fragments never become ACTIVE)\n");
+ sb.append("- Manual start required\n\n");
+ sb.append("**Solutions**:\n");
+ sb.append("1. If it's not a fragment, the bundle might just need to be
started\n");
+ sb.append("2. Check if the bundle has Bundle-ActivationPolicy:
lazy\n");
+ sb.append("3. Fragments are normal - they attach to their host
bundle\n\n");
+
+ sb.append("### Component State: UNSATISFIED_REFERENCE\n");
+ sb.append("**Problem**: Component can't find required OSGi
services.\n\n");
+ sb.append("**Common Causes**:\n");
+ sb.append("- Required service isn't registered (bundle providing it
isn't active)\n");
+ sb.append("- Service filter doesn't match any available services\n");
+ sb.append("- Circular dependency between components\n\n");
+ sb.append("**Solutions**:\n");
+ sb.append("1. Check the 'Unsatisfied Service References' in the
diagnostic output\n");
+ sb.append("2. Use `component://` resource to verify the service
provider is active\n");
+ sb.append("3. Check if the target filter is too restrictive\n");
+ sb.append("4. Make the reference optional if possible
(cardinality=OPTIONAL)\n\n");
+
+ sb.append("### Component State: UNSATISFIED_CONFIGURATION\n");
+ sb.append("**Problem**: Component requires configuration that hasn't
been provided.\n\n");
+ sb.append("**Common Causes**:\n");
+ sb.append("- Missing OSGi configuration in /apps or /libs\n");
+ sb.append("- Configuration not deployed to the environment\n");
+ sb.append("- Wrong configuration PID\n\n");
+ sb.append("**Solutions**:\n");
+ sb.append("1. Check if component has @Designate annotation requiring
config\n");
+ sb.append("2. Verify configuration exists in repository or as .config
file\n");
+ sb.append("3. Check configuration PID matches component name\n");
+ sb.append("4. Make configuration optional by removing 'required'
policy\n\n");
+
+ sb.append("## Step 3: Apply Fixes\n\n");
+ sb.append("Based on the diagnostic results, I'll help you:\n");
+ sb.append("1. Identify which dependencies need to be added to your
pom.xml\n");
+ sb.append("2. Fix package import/export statements in bnd.bnd or
MANIFEST.MF\n");
+ sb.append("3. Create missing OSGi configurations\n");
+ sb.append("4. Update component annotations to fix reference issues\n");
+ sb.append("5. Suggest architectural changes if circular dependencies
are detected\n\n");
+
+ sb.append("## Step 4: Verify Fix\n\n");
+ sb.append("After applying fixes:\n");
+ sb.append("1. Rebuild and redeploy the bundle\n");
+ sb.append("2. Run the diagnostic tool again to verify all issues are
resolved\n");
+ sb.append("3. Check that the bundle state is ACTIVE\n");
+ sb.append("4. Verify components are in ACTIVE or SATISFIED state\n\n");
+
+ sb.append("Let me know what the diagnostic tool returns, and I'll
provide specific solutions for your issue.");
+
+ return sb.toString();
+ }
+}