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-org-apache-sling-mcp-server.git
commit feb8a9a60634287d8fb6f2983c517b1871a035a8 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]> --- 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/pom.xml b/pom.xml index d2c09fb..edca093 100644 --- a/pom.xml +++ b/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/src/main/java/org/apache/sling/mcp/server/impl/contribs/ComponentResourceContribution.java b/src/main/java/org/apache/sling/mcp/server/impl/contribs/ComponentResourceContribution.java new file mode 100644 index 0000000..7a307fd --- /dev/null +++ b/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/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java b/src/main/java/org/apache/sling/mcp/server/impl/contribs/LogToolContribution.java new file mode 100644 index 0000000..9e68661 --- /dev/null +++ b/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/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiBundleDiagnosticContribution.java b/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiBundleDiagnosticContribution.java new file mode 100644 index 0000000..a4affef --- /dev/null +++ b/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/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiDiagnosticPromptContribution.java b/src/main/java/org/apache/sling/mcp/server/impl/contribs/OsgiDiagnosticPromptContribution.java new file mode 100644 index 0000000..25ea95b --- /dev/null +++ b/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(); + } +}
