From 36484f6f67a165b54fe0a2358934feb0d83e67f5 Mon Sep 17 00:00:00 2001 From: weilhaung <13622993145@163.com> Date: Sun, 29 Mar 2026 01:17:23 +0800 Subject: [PATCH] feat(server): add experimental MCP endpoint - add an experimental /jifa-api/mcp endpoint to the existing server - gate it behind jifa.mcp-enabled and keep it disabled by default - reuse the existing authentication, authorization, and file access checks - expose high-level MCP tools for files, GC logs, thread dumps, and heap dumps - add tests and English/Chinese documentation --- server/server.gradle | 3 + .../eclipse/jifa/server/Configuration.java | 5 + .../org/eclipse/jifa/server/Constant.java | 2 + .../jifa/server/configurer/McpConfigurer.java | 198 +++++++ .../server/mcp/JifaMcpAnalysisInvoker.java | 115 ++++ .../jifa/server/mcp/JifaMcpFileResolver.java | 118 +++++ .../server/mcp/JifaMcpFileToolService.java | 124 +++++ .../server/mcp/JifaMcpGcLogToolService.java | 203 +++++++ .../mcp/JifaMcpHeapDumpToolService.java | 227 ++++++++ .../jifa/server/mcp/JifaMcpResultHelper.java | 342 ++++++++++++ .../mcp/JifaMcpThreadDumpToolService.java | 280 ++++++++++ .../server/mcp/JifaMcpToolDefinition.java | 23 + .../jifa/server/mcp/JifaMcpToolRegistry.java | 191 +++++++ .../server/mcp/dto/McpAnalysisSummary.java | 26 + .../jifa/server/mcp/dto/McpFileInfo.java | 25 + .../jifa/server/mcp/dto/McpFinding.java | 21 + .../server/mcp/dto/McpListMyFilesResult.java | 25 + .../mcp/dto/McpThreadContentResult.java | 20 + .../mcp/JifaMcpToolServiceTestSupport.java | 315 +++++++++++ .../TestJifaMcpAuthenticatedIntegration.java | 236 +++++++++ .../mcp/TestJifaMcpFileToolService.java | 73 +++ .../mcp/TestJifaMcpGcLogToolService.java | 54 ++ .../mcp/TestJifaMcpHeapDumpToolService.java | 51 ++ .../server/mcp/TestJifaMcpIntegration.java | 495 ++++++++++++++++++ .../mcp/TestJifaMcpRealIntegration.java | 182 +++++++ .../mcp/TestJifaMcpThreadDumpToolService.java | 85 +++ .../mcp/TestMcpConfigurerCondition.java | 64 +++ site/docs/.vitepress/config.mts | 12 + site/docs/guide/configuration.md | 12 + site/docs/guide/mcp.md | 182 +++++++ site/docs/zh/guide/configuration.md | 12 + site/docs/zh/guide/mcp.md | 182 +++++++ 32 files changed, 3903 insertions(+) create mode 100644 server/src/main/java/org/eclipse/jifa/server/configurer/McpConfigurer.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpAnalysisInvoker.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileResolver.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileToolService.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpGcLogToolService.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpHeapDumpToolService.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpResultHelper.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpThreadDumpToolService.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolDefinition.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolRegistry.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpAnalysisSummary.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFileInfo.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFinding.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpListMyFilesResult.java create mode 100644 server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpThreadContentResult.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/JifaMcpToolServiceTestSupport.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpAuthenticatedIntegration.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpFileToolService.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpGcLogToolService.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpHeapDumpToolService.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpIntegration.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpRealIntegration.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpThreadDumpToolService.java create mode 100644 server/src/test/java/org/eclipse/jifa/server/mcp/TestMcpConfigurerCondition.java create mode 100644 site/docs/guide/mcp.md create mode 100644 site/docs/zh/guide/mcp.md diff --git a/server/server.gradle b/server/server.gradle index cb529c70..9c7b46dc 100644 --- a/server/server.gradle +++ b/server/server.gradle @@ -54,6 +54,9 @@ dependencies { implementation 'com.amazonaws:aws-java-sdk-s3:1.12.657' + implementation 'io.modelcontextprotocol.sdk:mcp-core:1.1.0' + implementation 'io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.0' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/server/src/main/java/org/eclipse/jifa/server/Configuration.java b/server/src/main/java/org/eclipse/jifa/server/Configuration.java index 2e4621bf..ef768fd8 100644 --- a/server/src/main/java/org/eclipse/jifa/server/Configuration.java +++ b/server/src/main/java/org/eclipse/jifa/server/Configuration.java @@ -173,6 +173,11 @@ public class Configuration { */ private boolean securityFiltersEnabled = true; + /** + * Whether to enable the experimental MCP endpoint. + */ + private boolean mcpEnabled = false; + @PostConstruct private void init() { if (role == Role.MASTER) { diff --git a/server/src/main/java/org/eclipse/jifa/server/Constant.java b/server/src/main/java/org/eclipse/jifa/server/Constant.java index 628ffc19..c6f013e0 100644 --- a/server/src/main/java/org/eclipse/jifa/server/Constant.java +++ b/server/src/main/java/org/eclipse/jifa/server/Constant.java @@ -31,6 +31,8 @@ public interface Constant extends org.eclipse.jifa.common.Constant { String HTTP_LOGIN_MAPPING = "/login"; + String HTTP_MCP_MAPPING = "/mcp"; + String HTTP_USER_MAPPING = "/user"; String STOMP_ENDPOINT = "jifa-stomp"; diff --git a/server/src/main/java/org/eclipse/jifa/server/configurer/McpConfigurer.java b/server/src/main/java/org/eclipse/jifa/server/configurer/McpConfigurer.java new file mode 100644 index 00000000..04453fa6 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/configurer/McpConfigurer.java @@ -0,0 +1,198 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.configurer; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.servlet.http.HttpServletRequest; +import org.eclipse.jifa.common.domain.exception.ErrorCode; +import org.eclipse.jifa.common.domain.exception.ErrorCodeAccessor; +import org.eclipse.jifa.server.Constant; +import org.eclipse.jifa.server.condition.ConditionalOnRole; +import org.eclipse.jifa.server.enums.Role; +import org.eclipse.jifa.server.mcp.JifaMcpToolDefinition; +import org.eclipse.jifa.server.mcp.JifaMcpToolRegistry; +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpFileInfo; +import org.eclipse.jifa.server.mcp.dto.McpListMyFilesResult; +import org.eclipse.jifa.server.mcp.dto.McpThreadContentResult; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; + +@Configuration +@ConditionalOnRole({Role.MASTER, Role.STANDALONE_WORKER}) +@ConditionalOnProperty(value = "jifa.mcp-enabled", havingValue = "true") +public class McpConfigurer { + + private static final String AUTHENTICATION_CONTEXT_KEY = "authentication"; + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + @Bean(destroyMethod = "closeGracefully") + public HttpServletStreamableServerTransportProvider jifaMcpTransport() { + return HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint(Constant.HTTP_MCP_MAPPING) + .contextExtractor(this::extractTransportContext) + .build(); + } + + @Bean + public ServletRegistrationBean jifaMcpServletRegistration( + HttpServletStreamableServerTransportProvider transport) { + ServletRegistrationBean registrationBean = + new ServletRegistrationBean<>(transport, Constant.HTTP_API_PREFIX + Constant.HTTP_MCP_MAPPING); + registrationBean.setName("jifaMcpServlet"); + registrationBean.setLoadOnStartup(1); + return registrationBean; + } + + @Bean(destroyMethod = "closeGracefully") + public McpSyncServer jifaMcpServer(HttpServletStreamableServerTransportProvider transport, + JifaMcpToolRegistry toolRegistry, + ObjectMapper objectMapper) { + return McpServer.sync(transport) + .serverInfo("Eclipse Jifa MCP", "dev") + .instructions(""" + Experimental MCP endpoint for Eclipse Jifa. + Use list_my_files first, then choose a high-level analysis tool that matches the file type. + The tools intentionally expose summarized diagnostics instead of mirroring the full internal HTTP API. + """.strip()) + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .tools(buildTools(toolRegistry, objectMapper)) + .build(); + } + + private List buildTools(JifaMcpToolRegistry toolRegistry, + ObjectMapper objectMapper) { + return toolRegistry.definitions().stream() + .map(definition -> tool(objectMapper, definition)) + .toList(); + } + + private McpTransportContext extractTransportContext(HttpServletRequest request) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return McpTransportContext.EMPTY; + } + return McpTransportContext.create(Map.of(AUTHENTICATION_CONTEXT_KEY, authentication)); + } + + private T withAuthentication(McpTransportContext transportContext, Supplier supplier) { + SecurityContext previous = SecurityContextHolder.getContext(); + Authentication authentication = transportContext == null + ? null + : (Authentication) transportContext.get(AUTHENTICATION_CONTEXT_KEY); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + if (authentication != null) { + context.setAuthentication(authentication); + } + SecurityContextHolder.setContext(context); + try { + return supplier.get(); + } finally { + SecurityContextHolder.setContext(previous); + } + } + + private McpServerFeatures.SyncToolSpecification tool(ObjectMapper objectMapper, + JifaMcpToolDefinition definition) { + McpSchema.Tool tool = McpSchema.Tool.builder() + .name(definition.name()) + .description(definition.description()) + .inputSchema(definition.inputSchema()) + .build(); + + return new McpServerFeatures.SyncToolSpecification(tool, (exchange, request) -> + withAuthentication(exchange.transportContext(), () -> { + try { + Object result = definition.handler().apply(request); + return McpSchema.CallToolResult.builder() + .structuredContent(toStructuredContent(objectMapper, result)) + .addTextContent(defaultTextContent(result)) + .isError(false) + .build(); + } catch (Exception e) { + Map errorContent = errorStructuredContent(e); + return McpSchema.CallToolResult.builder() + .structuredContent(errorContent) + .addTextContent("Tool execution failed: " + errorContent.get("error")) + .isError(true) + .build(); + } + })); + } + + private Map toStructuredContent(ObjectMapper objectMapper, Object result) { + return objectMapper.convertValue(result, MAP_TYPE); + } + + private String defaultTextContent(Object result) { + if (result instanceof McpAnalysisSummary analysisSummary) { + return analysisSummary.summary(); + } + if (result instanceof McpListMyFilesResult listResult) { + return "Returned " + listResult.returnedSize() + " file(s)."; + } + if (result instanceof McpFileInfo fileInfo) { + return "Resolved file " + fileInfo.originalName() + " as " + fileInfo.type() + "."; + } + if (result instanceof McpThreadContentResult threadContent) { + return "Returned " + threadContent.lineCount() + " line(s) for thread " + threadContent.threadId() + "."; + } + return "Tool execution completed."; + } + + private Map errorStructuredContent(Throwable throwable) { + LinkedHashMap content = new LinkedHashMap<>(); + ErrorCode errorCode = findErrorCode(throwable); + if (errorCode != null) { + content.put("errorCode", errorCode.identifier()); + } + content.put("error", errorMessage(throwable)); + return content; + } + + private ErrorCode findErrorCode(Throwable throwable) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current instanceof ErrorCodeAccessor accessor) { + return accessor.getErrorCode(); + } + } + return null; + } + + private String errorMessage(Throwable throwable) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current.getMessage() != null && !current.getMessage().isBlank()) { + return current.getMessage(); + } + } + return "Unknown error"; + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpAnalysisInvoker.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpAnalysisInvoker.java new file mode 100644 index 00000000..c9b06182 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpAnalysisInvoker.java @@ -0,0 +1,115 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.eclipse.jifa.common.util.GsonHolder; +import org.eclipse.jifa.server.domain.dto.AnalysisApiRequest; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.service.AnalysisApiService; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@Component +final class JifaMcpAnalysisInvoker { + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private static final TypeReference>> LIST_OF_MAPS_TYPE = new TypeReference<>() {}; + + private static final TypeReference> LIST_OF_STRINGS_TYPE = new TypeReference<>() {}; + + private final AnalysisApiService analysisApiService; + + private final ObjectMapper objectMapper; + + JifaMcpAnalysisInvoker(AnalysisApiService analysisApiService, ObjectMapper objectMapper) { + this.analysisApiService = analysisApiService; + this.objectMapper = objectMapper; + } + + Object invokeAnalysisRaw(FileType fileType, + String uniqueName, + String api, + Map parameters) { + JsonObject json = new JsonObject(); + json.addProperty("namespace", fileType.getApiNamespace()); + json.addProperty("api", api); + json.addProperty("target", uniqueName); + if (parameters != null && !parameters.isEmpty()) { + json.add("parameters", GsonHolder.GSON.toJsonTree(parameters).getAsJsonObject()); + } + + try { + Object raw = analysisApiService.invoke(new AnalysisApiRequest(json)).get(); + return normalizeAnalysisResult(raw, api); + } catch (Exception e) { + throw new IllegalStateException("Failed to invoke analysis API '" + api + "': " + e.getMessage(), e); + } + } + + Map invokeAnalysisAsMap(FileType fileType, + String uniqueName, + String api, + Map parameters) { + Object result = invokeAnalysisRaw(fileType, uniqueName, api, parameters); + return result == null ? Map.of() : objectMapper.convertValue(result, MAP_TYPE); + } + + List> invokeAnalysisAsListOfMaps(FileType fileType, + String uniqueName, + String api, + Map parameters) { + Object result = invokeAnalysisRaw(fileType, uniqueName, api, parameters); + return result == null ? List.of() : objectMapper.convertValue(result, LIST_OF_MAPS_TYPE); + } + + List invokeAnalysisAsListOfStrings(FileType fileType, + String uniqueName, + String api, + Map parameters) { + Object result = invokeAnalysisRaw(fileType, uniqueName, api, parameters); + return result == null ? List.of() : objectMapper.convertValue(result, LIST_OF_STRINGS_TYPE); + } + + private Object normalizeAnalysisResult(Object raw, String api) { + if (raw == null) { + return null; + } + if (raw instanceof byte[] bytes) { + return parseJsonPayload(bytes, api); + } + if (raw instanceof JsonElement element) { + return parseJsonPayload(element.toString().getBytes(), api); + } + return raw; + } + + private Object parseJsonPayload(byte[] payload, String api) { + if (payload.length == 0) { + return null; + } + + try { + return objectMapper.readValue(payload, Object.class); + } catch (IOException e) { + throw new IllegalStateException("Failed to decode analysis API '" + api + "' response", e); + } + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileResolver.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileResolver.java new file mode 100644 index 00000000..988bfc61 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileResolver.java @@ -0,0 +1,118 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jifa.common.domain.vo.PageView; +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.mcp.dto.McpFileInfo; +import org.eclipse.jifa.server.service.FileService; +import org.springframework.stereotype.Component; + +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@Component +final class JifaMcpFileResolver { + + private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + private final FileService fileService; + + private final JifaMcpResultHelper resultHelper; + + JifaMcpFileResolver(FileService fileService, JifaMcpResultHelper resultHelper) { + this.fileService = fileService; + this.resultHelper = resultHelper; + } + + PageView getUserFileViews(FileType type, int page, int pageSize) { + return fileService.getUserFileViews(type, page, pageSize); + } + + FileView getFileViewByUniqueName(String uniqueName) { + return fileService.getFileViewByUniqueName(uniqueName); + } + + FileView requireFile(String uniqueName, FileType expectedType) { + FileView fileView = getFileViewByUniqueName(uniqueName); + if (expectedType != null && fileView.type() != expectedType) { + throw new IllegalArgumentException( + "File '" + uniqueName + "' is of type " + fileView.type().getApiNamespace() + + ", expected " + expectedType.getApiNamespace() + ); + } + return fileView; + } + + McpFileInfo toFileInfo(FileView fileView) { + return new McpFileInfo( + fileView.id(), + fileView.uniqueName(), + fileView.originalName(), + fileView.type().getApiNamespace(), + resultHelper.displayName(fileView.type()), + fileView.size(), + fileView.createdTime() == null ? null : FILE_TIME_FORMATTER.format(fileView.createdTime()), + supportedAnalysisCapabilities(fileView.type()) + ); + } + + FileType parseFileType(String type, boolean allowNull) { + String normalized = StringUtils.trimToNull(type); + if (normalized == null) { + if (allowNull) { + return null; + } + throw new IllegalArgumentException("File type is required"); + } + + String lower = normalized.toLowerCase(Locale.ROOT); + for (FileType fileType : FileType.values()) { + if (lower.equals(fileType.getApiNamespace()) + || lower.equals(fileType.name().toLowerCase(Locale.ROOT)) + || lower.equals(fileType.getAnalysisUrlPath())) { + return fileType; + } + } + throw new IllegalArgumentException("Unsupported file type: " + type); + } + + private List supportedAnalysisCapabilities(FileType fileType) { + List capabilities = new ArrayList<>(); + capabilities.add("analyze_file_summary"); + switch (fileType) { + case GC_LOG -> { + capabilities.add("analyze_gc_log_summary"); + capabilities.add("analyze_gc_log_metrics"); + } + case THREAD_DUMP -> { + capabilities.add("analyze_thread_dump_summary"); + capabilities.add("analyze_thread_dump_details"); + capabilities.add("analyze_thread_dump_blocking_chains"); + capabilities.add("get_thread_dump_thread_content"); + } + case HEAP_DUMP -> { + capabilities.add("analyze_heap_dump_summary"); + capabilities.add("analyze_heap_dump_hotspots"); + capabilities.add("analyze_heap_dump_thread_details"); + } + case JFR_FILE -> { + } + } + return capabilities; + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileToolService.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileToolService.java new file mode 100644 index 00000000..f6c6d1bd --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpFileToolService.java @@ -0,0 +1,124 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jifa.common.domain.vo.PageView; +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpFileInfo; +import org.eclipse.jifa.server.mcp.dto.McpFinding; +import org.eclipse.jifa.server.mcp.dto.McpListMyFilesResult; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +final class JifaMcpFileToolService { + + private static final int DEFAULT_PAGE = 1; + + private static final int DEFAULT_PAGE_SIZE = 100; + + private static final int MAX_PAGE_SIZE = 100; + + private final JifaMcpFileResolver fileResolver; + + private final JifaMcpResultHelper resultHelper; + + private final JifaMcpGcLogToolService gcLogToolService; + + private final JifaMcpThreadDumpToolService threadDumpToolService; + + private final JifaMcpHeapDumpToolService heapDumpToolService; + + JifaMcpFileToolService(JifaMcpFileResolver fileResolver, + JifaMcpResultHelper resultHelper, + JifaMcpGcLogToolService gcLogToolService, + JifaMcpThreadDumpToolService threadDumpToolService, + JifaMcpHeapDumpToolService heapDumpToolService) { + this.fileResolver = fileResolver; + this.resultHelper = resultHelper; + this.gcLogToolService = gcLogToolService; + this.threadDumpToolService = threadDumpToolService; + this.heapDumpToolService = heapDumpToolService; + } + + McpListMyFilesResult listMyFiles(String type, Integer page, Integer pageSize) { + FileType parsedType = fileResolver.parseFileType(type, true); + int resolvedPage = validatePage(page); + int resolvedPageSize = validatePageSize(pageSize); + PageView pageView = fileResolver.getUserFileViews(parsedType, resolvedPage, resolvedPageSize); + List files = pageView.getData().stream().map(fileResolver::toFileInfo).toList(); + return new McpListMyFilesResult( + StringUtils.trimToNull(type), + parsedType == null ? null : parsedType.getApiNamespace(), + pageView.getPage(), + pageView.getPageSize(), + pageView.getTotalSize(), + files.size(), + pageView.getTotalSize() > files.size(), + files + ); + } + + McpFileInfo getFileInfo(String uniqueName) { + return fileResolver.toFileInfo(fileResolver.getFileViewByUniqueName(uniqueName)); + } + + McpAnalysisSummary analyzeFileSummary(String uniqueName) { + FileView fileView = fileResolver.getFileViewByUniqueName(uniqueName); + return switch (fileView.type()) { + case GC_LOG -> gcLogToolService.analyzeGcLogSummary(uniqueName); + case THREAD_DUMP -> threadDumpToolService.analyzeThreadDumpSummary(uniqueName); + case HEAP_DUMP -> heapDumpToolService.analyzeHeapDumpSummary(uniqueName); + case JFR_FILE -> unsupportedJfrSummary(fileView); + }; + } + + private McpAnalysisSummary unsupportedJfrSummary(FileView fileView) { + return resultHelper.analysisSummary( + fileView, + "JFR files are recognized by Jifa, but issue #382 intentionally keeps JFR-specific MCP tools out of scope.", + List.of(new McpFinding( + "low", + "JFR MCP tools are not exposed in this PR", + "The experimental MCP endpoint only exposes file, GC log, thread dump, and heap dump tools.", + resultHelper.orderedMap("fileType", fileView.type().getApiNamespace()) + )), + List.of("Open this file in the Jifa Web UI for JFR-specific analysis."), + resultHelper.orderedMap("fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView))) + ); + } + + private int validatePage(Integer page) { + if (page == null) { + return DEFAULT_PAGE; + } + if (page < 1) { + throw new IllegalArgumentException("Argument 'page' must be greater than or equal to 1."); + } + return page; + } + + private int validatePageSize(Integer pageSize) { + if (pageSize == null) { + return DEFAULT_PAGE_SIZE; + } + if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) { + throw new IllegalArgumentException("Argument 'pageSize' must be between 1 and " + MAX_PAGE_SIZE + "."); + } + return pageSize; + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpGcLogToolService.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpGcLogToolService.java new file mode 100644 index 00000000..26fe404a --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpGcLogToolService.java @@ -0,0 +1,203 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpFinding; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Component +final class JifaMcpGcLogToolService { + + private final JifaMcpAnalysisInvoker analysisInvoker; + + private final JifaMcpFileResolver fileResolver; + + private final JifaMcpResultHelper resultHelper; + + JifaMcpGcLogToolService(JifaMcpAnalysisInvoker analysisInvoker, + JifaMcpFileResolver fileResolver, + JifaMcpResultHelper resultHelper) { + this.analysisInvoker = analysisInvoker; + this.fileResolver = fileResolver; + this.resultHelper = resultHelper; + } + + McpAnalysisSummary analyzeGcLogSummary(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.GC_LOG); + Map metadata = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "metadata", null); + Map config = resultHelper.buildGcAnalysisConfig(metadata); + Map range = resultHelper.resolveGcAnalysisRange(metadata); + Map pauseStatistics = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "pauseStatistics", range); + Map diagnoseInfo = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "diagnoseInfo", + resultHelper.orderedMap("config", config)); + + Map mostSeriousProblem = resultHelper.getMap(diagnoseInfo, "mostSeriousProblem"); + double throughput = resultHelper.getDouble(pauseStatistics, "throughput", -1); + double pauseMax = resultHelper.getDouble(pauseStatistics, "pauseMax", -1); + double duration = resultHelper.getDouble(metadata, "endTime", -1) - resultHelper.getDouble(metadata, "startTime", -1); + String collector = resultHelper.getString(metadata, "collector"); + String summary = "GC log summary for " + fileView.originalName() + ": collector=" + + resultHelper.defaultIfBlank(collector, "unknown") + + ", duration=" + resultHelper.formatMilliseconds(duration) + + ", max pause=" + resultHelper.formatMilliseconds(pauseMax) + + ", throughput=" + resultHelper.formatPercent(throughput) + "."; + if (mostSeriousProblem != null) { + summary += " Most serious problem: " + resultHelper.formatI18n(mostSeriousProblem.get("problem")) + "."; + } + + List findings = new ArrayList<>(); + if (mostSeriousProblem != null) { + findings.add(new McpFinding( + "high", + resultHelper.formatI18n(mostSeriousProblem.get("problem")), + "Jifa identified the most serious GC problem in the default analysis range.", + resultHelper.orderedMap("sites", mostSeriousProblem.get("sites")) + )); + } + if (pauseMax >= resultHelper.getDouble(config, "longPauseThreshold", Double.MAX_VALUE)) { + findings.add(new McpFinding( + "medium", + "Long GC pauses detected", + "The maximum pause exceeds the configured long pause threshold.", + resultHelper.orderedMap( + "pauseMax", pauseMax, + "longPauseThreshold", resultHelper.getDouble(config, "longPauseThreshold", -1) + ) + )); + } + if (throughput >= 0 && throughput < resultHelper.getDouble(config, "badThroughputThreshold", 100) / 100.0) { + findings.add(new McpFinding( + "medium", + "GC throughput is below target", + "Application time outside GC is lower than the configured throughput target.", + resultHelper.orderedMap( + "throughput", throughput, + "badThroughputThreshold", resultHelper.getDouble(config, "badThroughputThreshold", -1) + ) + )); + } + + Map seriousProblems = resultHelper.getMap(diagnoseInfo, "seriousProblems"); + if (seriousProblems != null) { + seriousProblems.entrySet().stream() + .sorted(Comparator.comparingInt(entry -> -resultHelper.getList(entry.getValue()).size())) + .limit(3) + .filter(entry -> mostSeriousProblem == null + || !Objects.equals(resultHelper.formatI18n(mostSeriousProblem.get("problem")), entry.getKey())) + .forEach(entry -> findings.add(new McpFinding( + "medium", + entry.getKey(), + "Detected " + resultHelper.getList(entry.getValue()).size() + " occurrence(s) in the default range.", + resultHelper.orderedMap("sites", entry.getValue()) + ))); + } + + List recommendations = resultHelper.extractRecommendations(diagnoseInfo); + if (recommendations.isEmpty()) { + recommendations = List.of( + "Use `analyze_gc_log_metrics` to inspect detailed pause, heap, and allocation metrics.", + "Open the highlighted time ranges in the Jifa Web UI if you need event-level drill-down." + ); + } + + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "metadata", resultHelper.orderedMap( + "collector", collector, + "logStyle", resultHelper.getString(metadata, "logStyle"), + "startTime", resultHelper.getDouble(metadata, "startTime", -1), + "endTime", resultHelper.getDouble(metadata, "endTime", -1), + "duration", duration + ), + "pauseStatistics", pauseStatistics, + "mostSeriousProblem", mostSeriousProblem, + "seriousProblems", seriousProblems + ); + return resultHelper.analysisSummary(fileView, summary, findings, recommendations, evidence); + } + + McpAnalysisSummary analyzeGcLogMetrics(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.GC_LOG); + Map metadata = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "metadata", null); + Map config = resultHelper.buildGcAnalysisConfig(metadata); + Map range = resultHelper.resolveGcAnalysisRange(metadata); + Map pauseStatistics = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "pauseStatistics", range); + Map memoryStatistics = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "memoryStatistics", range); + Map objectStatistics = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "objectStatistics", range); + + double throughput = resultHelper.getDouble(pauseStatistics, "throughput", -1); + double pauseP99 = resultHelper.getDouble(pauseStatistics, "pauseP99", -1); + double pauseMax = resultHelper.getDouble(pauseStatistics, "pauseMax", -1); + Map heap = resultHelper.getMap(memoryStatistics, "heap"); + Map old = resultHelper.getMap(memoryStatistics, "old"); + Map metaspace = resultHelper.getMap(memoryStatistics, "metaspace"); + + String summary = "GC metrics for " + fileView.originalName() + + ": p99 pause=" + resultHelper.formatMilliseconds(pauseP99) + + ", max pause=" + resultHelper.formatMilliseconds(pauseMax) + + ", throughput=" + resultHelper.formatPercent(throughput) + + ", heap peak=" + resultHelper.formatBytes(resultHelper.getLong(heap, "usedMax", -1)) + "."; + + List findings = new ArrayList<>(); + if (pauseMax >= resultHelper.getDouble(config, "longPauseThreshold", Double.MAX_VALUE)) { + findings.add(new McpFinding( + "high", + "Pause time exceeds threshold", + "The observed maximum pause is above the configured long pause threshold.", + resultHelper.orderedMap("pauseMax", pauseMax, "threshold", resultHelper.getDouble(config, "longPauseThreshold", -1)) + )); + } + if (throughput >= 0 && throughput < resultHelper.getDouble(config, "badThroughputThreshold", 100) / 100.0) { + findings.add(new McpFinding( + "high", + "Low throughput", + "GC consumed more time than the configured throughput target allows.", + resultHelper.orderedMap("throughput", throughput, "threshold", resultHelper.getDouble(config, "badThroughputThreshold", -1)) + )); + } + + resultHelper.addUsageFinding(findings, "Heap usage remains high after GC", heap, + resultHelper.getDouble(config, "highHeapUsageThreshold", -1)); + resultHelper.addUsageFinding(findings, "Old generation usage remains high after GC", old, + resultHelper.getDouble(config, "highOldUsageThreshold", -1)); + resultHelper.addUsageFinding(findings, "Metaspace usage remains high after GC", metaspace, + resultHelper.getDouble(config, "highMetaspaceUsageThreshold", -1)); + + List recommendations = new ArrayList<>(); + if (findings.stream().anyMatch(f -> "high".equals(f.severity()))) { + recommendations.add("Inspect the time range around the highest pause and the most saturated memory areas in the Jifa Web UI."); + } + recommendations.add("Compare these metrics with application SLOs before drawing a final conclusion."); + + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "metadata", resultHelper.orderedMap( + "collector", resultHelper.getString(metadata, "collector"), + "logStyle", resultHelper.getString(metadata, "logStyle") + ), + "pauseStatistics", pauseStatistics, + "memoryStatistics", memoryStatistics, + "objectStatistics", objectStatistics + ); + return resultHelper.analysisSummary(fileView, summary, findings, recommendations, evidence); + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpHeapDumpToolService.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpHeapDumpToolService.java new file mode 100644 index 00000000..28cd6357 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpHeapDumpToolService.java @@ -0,0 +1,227 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpFinding; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +final class JifaMcpHeapDumpToolService { + + /** + * Treat retained slices at or above 10% of the dump view as worth surfacing in the MCP summary. + */ + private static final double LARGE_RETAINED_SLICE_THRESHOLD_PERCENT = 10.0; + + private final JifaMcpAnalysisInvoker analysisInvoker; + + private final JifaMcpFileResolver fileResolver; + + private final JifaMcpResultHelper resultHelper; + + JifaMcpHeapDumpToolService(JifaMcpAnalysisInvoker analysisInvoker, + JifaMcpFileResolver fileResolver, + JifaMcpResultHelper resultHelper) { + this.analysisInvoker = analysisInvoker; + this.fileResolver = fileResolver; + this.resultHelper = resultHelper; + } + + McpAnalysisSummary analyzeHeapDumpSummary(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.HEAP_DUMP); + Map details = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "details", null); + List> biggestObjects = analysisInvoker.invokeAnalysisAsListOfMaps(fileView.type(), uniqueName, + "biggestObjects", null); + Map classLoaderSummary = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, + "classLoaderExplorer.summary", null); + + String summary = "Heap dump summary for " + fileView.originalName() + + ": used heap=" + resultHelper.formatBytes(resultHelper.getLong(details, "usedHeapSize", -1)) + + ", objects=" + resultHelper.getLong(details, "numberOfObjects", -1) + + ", classes=" + resultHelper.getLong(details, "numberOfClasses", -1) + + ", classloaders=" + resultHelper.getLong(details, "numberOfClassLoaders", -1) + "."; + + List findings = new ArrayList<>(); + Map biggestObject = biggestObjects.isEmpty() ? null : biggestObjects.get(0); + if (biggestObject != null + && resultHelper.getDouble(biggestObject, "value", 0) >= LARGE_RETAINED_SLICE_THRESHOLD_PERCENT) { + findings.add(new McpFinding( + "medium", + "Large retained object slice detected", + "A single retained slice occupies a noticeable portion of the dump.", + resultHelper.orderedMap( + "label", resultHelper.getString(biggestObject, "label"), + "value", resultHelper.getDouble(biggestObject, "value", -1), + "description", resultHelper.getString(biggestObject, "description") + ) + )); + } + + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "details", details, + "classLoaderSummary", classLoaderSummary, + "largestObjects", biggestObjects.stream().limit(10).toList() + ); + return resultHelper.analysisSummary( + fileView, + summary, + findings, + List.of( + "Use `analyze_heap_dump_hotspots` to inspect the largest retained classes and objects.", + "Use `analyze_heap_dump_thread_details` if you want to inspect retained heap around thread objects." + ), + evidence + ); + } + + McpAnalysisSummary analyzeHeapDumpHotspots(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.HEAP_DUMP); + Map details = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "details", null); + List> biggestObjects = analysisInvoker.invokeAnalysisAsListOfMaps(fileView.type(), uniqueName, + "biggestObjects", null); + Map histogram = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "histogram", + resultHelper.orderedMap( + "groupBy", "BY_CLASS", + "sortBy", "retainedSize", + "ascendingOrder", false, + "searchText", "", + "searchType", "BY_NAME", + "page", 1, + "pageSize", 20 + )); + List> topClasses = resultHelper.getListOfMaps(histogram.get("data")); + + String topClassName = topClasses.isEmpty() ? "n/a" : resultHelper.getString(topClasses.get(0), "label"); + String summary = "Heap hotspots for " + fileView.originalName() + + ": top retained class=" + topClassName + + ", used heap=" + resultHelper.formatBytes(resultHelper.getLong(details, "usedHeapSize", -1)) + + ", sampled classes=" + topClasses.size() + "."; + + List findings = new ArrayList<>(); + Map topClass = topClasses.isEmpty() ? null : topClasses.get(0); + if (topClass != null) { + findings.add(new McpFinding( + "medium", + "Largest retained class slice", + "The first histogram entry is the class currently retaining the most heap in the sampled results.", + resultHelper.orderedMap( + "label", resultHelper.getString(topClass, "label"), + "retainedSize", resultHelper.getLong(topClass, "retainedSize", -1), + "numberOfObjects", resultHelper.getLong(topClass, "numberOfObjects", -1) + ) + )); + } + + Map largestObject = biggestObjects.isEmpty() ? null : biggestObjects.get(0); + if (largestObject != null + && resultHelper.getDouble(largestObject, "value", 0) >= LARGE_RETAINED_SLICE_THRESHOLD_PERCENT) { + findings.add(new McpFinding( + "medium", + "Largest single retained object slice", + "The retained slice reported by `biggestObjects` is large enough to be worth manual inspection.", + resultHelper.orderedMap( + "label", resultHelper.getString(largestObject, "label"), + "value", resultHelper.getDouble(largestObject, "value", -1) + ) + )); + } + + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "topClasses", topClasses, + "largestObjects", biggestObjects.stream().limit(10).toList() + ); + return resultHelper.analysisSummary( + fileView, + summary, + findings, + List.of("Inspect the top classes and object slices in the Jifa Web UI to confirm reachability and ownership."), + evidence + ); + } + + McpAnalysisSummary analyzeHeapDumpThreadDetails(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.HEAP_DUMP); + Map threadSummary = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "threadsSummary", + resultHelper.orderedMap("searchText", "", "searchType", "BY_NAME")); + Map threads = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "threads", + resultHelper.orderedMap( + "sortBy", "retainedHeap", + "ascendingOrder", false, + "searchText", "", + "searchType", "BY_NAME", + "page", 1, + "pageSize", 10 + )); + List> topThreads = resultHelper.getListOfMaps(threads.get("data")); + List> stackSamples = new ArrayList<>(); + for (Map thread : topThreads.stream().limit(3).toList()) { + if (!resultHelper.getBoolean(thread, "hasStack", false)) { + continue; + } + int objectId = resultHelper.getInt(thread, "objectId", -1); + List> stackTrace = analysisInvoker.invokeAnalysisAsListOfMaps(fileView.type(), uniqueName, + "stackTrace", + resultHelper.orderedMap("objectId", objectId)); + stackSamples.add(resultHelper.orderedMap( + "thread", resultHelper.orderedMap( + "objectId", objectId, + "name", resultHelper.getString(thread, "name") + ), + "frames", stackTrace.stream().limit(5).toList() + )); + } + + String summary = "Heap thread details for " + fileView.originalName() + + ": thread objects=" + resultHelper.getLong(threadSummary, "totalSize", -1) + + ", retained heap=" + resultHelper.formatBytes(resultHelper.getLong(threadSummary, "retainedHeap", -1)) + + ", sampled top threads=" + topThreads.size() + "."; + + List findings = new ArrayList<>(); + Map firstThread = topThreads.isEmpty() ? null : topThreads.get(0); + if (firstThread != null && resultHelper.getLong(firstThread, "retainedSize", 0) > 0) { + findings.add(new McpFinding( + "medium", + "Top thread object by retained heap", + "The first sampled thread retains the most heap among thread objects in the current view.", + resultHelper.orderedMap( + "name", resultHelper.getString(firstThread, "name"), + "retainedSize", resultHelper.getLong(firstThread, "retainedSize", -1), + "contextClassLoader", resultHelper.getString(firstThread, "contextClassLoader") + ) + )); + } + + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "threadSummary", threadSummary, + "topThreads", topThreads, + "stackSamples", stackSamples + ); + return resultHelper.analysisSummary( + fileView, + summary, + findings, + List.of("Inspect the sampled thread stacks and locals in the Jifa Web UI if you suspect thread-local retention."), + evidence + ); + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpResultHelper.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpResultHelper.java new file mode 100644 index 00000000..b02f7e5d --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpResultHelper.java @@ -0,0 +1,342 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpFinding; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * responsible for MCP result assembly, Map/List reading, and display formatting. + */ +@Component +final class JifaMcpResultHelper { + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private static final TypeReference>> LIST_OF_MAPS_TYPE = new TypeReference<>() {}; + + private final ObjectMapper objectMapper; + + JifaMcpResultHelper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + McpAnalysisSummary analysisSummary(FileView fileView, + String summary, + List findings, + List recommendations, + Map evidence) { + return new McpAnalysisSummary( + fileView.uniqueName(), + fileView.originalName(), + fileView.type().getApiNamespace(), + displayName(fileView.type()), + summary, + findings, + recommendations, + evidence + ); + } + + Map buildGcAnalysisConfig(Map metadata) { + Map metadataConfig = getMap(metadata, "analysisConfig"); + if (metadataConfig != null) { + return metadataConfig; + } + + boolean pauseless = getBoolean(metadata, "pauseless", false); + boolean generational = getBoolean(metadata, "generational", true); + return orderedMap( + "timeRange", resolveGcAnalysisRange(metadata), + "longPauseThreshold", pauseless ? 30 : 400, + "longConcurrentThreshold", 30000, + "youngGCFrequentIntervalThreshold", 1000, + "oldGCFrequentIntervalThreshold", 15000, + "fullGCFrequentIntervalThreshold", generational ? 60000 : 2000, + "highOldUsageThreshold", 80, + "highHumongousUsageThreshold", 50, + "highHeapUsageThreshold", 60, + "highMetaspaceUsageThreshold", 80, + "smallGenerationThreshold", 10, + "highPromotionThreshold", 3, + "badThroughputThreshold", 90, + "tooManyOldGCThreshold", 20, + "highSysThreshold", 50, + "lowUsrThreshold", 100 + ); + } + + Map resolveGcAnalysisRange(Map metadata) { + Map metadataConfig = getMap(metadata, "analysisConfig"); + Map timeRange = metadataConfig == null ? null : getMap(metadataConfig, "timeRange"); + if (timeRange != null) { + return timeRange; + } + return orderedMap( + "start", getDouble(metadata, "startTime", 0), + "end", getDouble(metadata, "endTime", 0) + ); + } + + void addUsageFinding(List findings, + String title, + Map area, + double thresholdPercent) { + if (area == null || thresholdPercent < 0) { + return; + } + long capacity = getLong(area, "capacityAvg", -1); + if (capacity <= 0) { + return; + } + long used = getLong(area, "usedAvgAfterFullGC", -1); + if (used < 0) { + used = getLong(area, "usedMax", -1); + } + if (used < 0) { + return; + } + + double percent = used * 100.0 / capacity; + if (percent >= thresholdPercent) { + findings.add(new McpFinding( + "medium", + title, + "Observed usage is above the configured threshold.", + orderedMap( + "used", used, + "capacity", capacity, + "usagePercent", percent, + "thresholdPercent", thresholdPercent + ) + )); + } + } + + List> mergeLists(List>> lists) { + List> result = new ArrayList<>(); + for (List> list : lists) { + result.addAll(list); + } + return result; + } + + int monitorStateCount(Map counts, String state) { + if (counts == null) { + return 0; + } + return getInt(counts, state, 0); + } + + int severityScore(String severity) { + return switch (severity) { + case "critical" -> 4; + case "high" -> 3; + case "medium" -> 2; + case "low" -> 1; + default -> 0; + }; + } + + String formatMonitorReference(Map monitor) { + long address = getLong(monitor, "address", -1); + String clazz = getString(monitor, "class"); + if (StringUtils.isBlank(clazz)) { + clazz = getString(monitor, "clazz"); + } + String suffix = address >= 0 ? "@0x" + Long.toHexString(address) : ""; + return defaultIfBlank(clazz, "monitor") + suffix; + } + + String displayName(FileType fileType) { + return switch (fileType) { + case HEAP_DUMP -> "Heap Dump"; + case GC_LOG -> "GC Log"; + case THREAD_DUMP -> "Thread Dump"; + case JFR_FILE -> "JFR"; + }; + } + + List extractRecommendations(Map diagnoseInfo) { + Map mostSeriousProblem = getMap(diagnoseInfo, "mostSeriousProblem"); + if (mostSeriousProblem == null) { + return List.of(); + } + return getList(mostSeriousProblem.get("suggestions")).stream() + .map(this::formatI18n) + .filter(StringUtils::isNotBlank) + .toList(); + } + + Map orderedMap(Object... items) { + LinkedHashMap map = new LinkedHashMap<>(); + for (int i = 0; i < items.length; i += 2) { + Object value = items[i + 1]; + if (value != null) { + map.put(String.valueOf(items[i]), value); + } + } + return map; + } + + Map toMap(Object source) { + return objectMapper.convertValue(source, MAP_TYPE); + } + + @SuppressWarnings("unchecked") + List getList(Object value) { + return value instanceof List list ? (List) list : List.of(); + } + + List> getListOfMaps(Object value) { + return objectMapper.convertValue(value == null ? List.of() : value, LIST_OF_MAPS_TYPE); + } + + Map getMap(Map source, String key) { + return getMap(source == null ? null : source.get(key)); + } + + Map getMap(Object value) { + if (value == null) { + return null; + } + return objectMapper.convertValue(value, MAP_TYPE); + } + + List getList(Object value, String key) { + Map map = getMap(value); + return map == null ? List.of() : getList(map.get(key)); + } + + int sum(List values) { + return values.stream() + .filter(Objects::nonNull) + .mapToInt(value -> ((Number) value).intValue()) + .sum(); + } + + int getCountAt(List values, int index) { + if (index < 0 || index >= values.size()) { + return 0; + } + Object value = values.get(index); + return value instanceof Number number ? number.intValue() : 0; + } + + String getString(Map map, String key) { + if (map == null) { + return null; + } + Object value = map.get(key); + return value == null ? null : String.valueOf(value); + } + + int getInt(Map map, String key, int defaultValue) { + Number number = getNumber(map, key); + return number == null ? defaultValue : number.intValue(); + } + + long getLong(Map map, String key, long defaultValue) { + Number number = getNumber(map, key); + return number == null ? defaultValue : number.longValue(); + } + + double getDouble(Map map, String key, double defaultValue) { + Number number = getNumber(map, key); + return number == null ? defaultValue : number.doubleValue(); + } + + boolean getBoolean(Map map, String key, boolean defaultValue) { + if (map == null) { + return defaultValue; + } + Object value = map.get(key); + return value instanceof Boolean bool ? bool : defaultValue; + } + + Number getNumber(Map map, String key) { + if (map == null) { + return null; + } + Object value = map.get(key); + return value instanceof Number number ? number : null; + } + + String formatI18n(Object value) { + if (value == null) { + return null; + } + if (!(value instanceof Map map)) { + return String.valueOf(value); + } + Object name = map.get("name"); + Object params = map.get("params"); + if (!(params instanceof Map paramMap) || paramMap.isEmpty()) { + return name == null ? null : String.valueOf(name); + } + return String.valueOf(name) + "(" + paramMap.entrySet().stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.joining(", ")) + + ")"; + } + + String formatBytes(long bytes) { + if (bytes < 0) { + return "unknown"; + } + double value = bytes; + String[] units = {"B", "KB", "MB", "GB", "TB"}; + int unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + return String.format(Locale.ROOT, "%.1f %s", value, units[unitIndex]); + } + + String formatMilliseconds(double milliseconds) { + if (milliseconds < 0) { + return "unknown"; + } + if (milliseconds >= 1000) { + return String.format(Locale.ROOT, "%.2f s", milliseconds / 1000.0); + } + return String.format(Locale.ROOT, "%.0f ms", milliseconds); + } + + String formatPercent(double value) { + if (value < 0) { + return "unknown"; + } + double normalized = value <= 1.0 ? value * 100.0 : value; + return String.format(Locale.ROOT, "%.1f%%", normalized); + } + + String defaultIfBlank(String value, String defaultValue) { + return StringUtils.isBlank(value) ? defaultValue : value; + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpThreadDumpToolService.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpThreadDumpToolService.java new file mode 100644 index 00000000..dde309dd --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpThreadDumpToolService.java @@ -0,0 +1,280 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpFinding; +import org.eclipse.jifa.server.mcp.dto.McpThreadContentResult; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@Component +final class JifaMcpThreadDumpToolService { + + private static final int MAX_THREAD_SAMPLE_SIZE = 20; + + private static final int MAX_MONITOR_SAMPLE_SIZE = 50; + + private static final int BLOCKED_ON_MONITOR_ENTER_INDEX = 7; + + private final JifaMcpAnalysisInvoker analysisInvoker; + + private final JifaMcpFileResolver fileResolver; + + private final JifaMcpResultHelper resultHelper; + + JifaMcpThreadDumpToolService(JifaMcpAnalysisInvoker analysisInvoker, + JifaMcpFileResolver fileResolver, + JifaMcpResultHelper resultHelper) { + this.analysisInvoker = analysisInvoker; + this.fileResolver = fileResolver; + this.resultHelper = resultHelper; + } + + McpAnalysisSummary analyzeThreadDumpSummary(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.THREAD_DUMP); + Map overview = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "overview", null); + int totalThreads = resultHelper.sum(resultHelper.getList(overview.get("threadStat"), "counts")); + int javaThreads = resultHelper.sum(resultHelper.getList(overview.get("javaThreadStat"), "javaCounts")); + int daemonThreads = resultHelper.getInt(resultHelper.getMap(overview, "javaThreadStat"), "daemonCount", -1); + int deadLockCount = resultHelper.getInt(overview, "deadLockCount", 0); + int errorCount = resultHelper.getInt(overview, "errorCount", 0); + int blockedJavaThreads = resultHelper.getCountAt( + resultHelper.getList(resultHelper.getMap(overview, "javaThreadStat").get("javaCounts")), + BLOCKED_ON_MONITOR_ENTER_INDEX + ); + + String summary = "Thread dump summary for " + fileView.originalName() + + ": total threads=" + totalThreads + + ", java threads=" + javaThreads + + ", daemon threads=" + daemonThreads + + ", deadlocks=" + deadLockCount + + ", parser errors=" + errorCount + "."; + + List findings = new ArrayList<>(); + if (deadLockCount > 0) { + findings.add(new McpFinding( + "critical", + "Deadlock detected", + "Jifa reported one or more deadlocked threads in this dump.", + resultHelper.orderedMap("deadLockCount", deadLockCount) + )); + } + if (blockedJavaThreads > 0) { + findings.add(new McpFinding( + "medium", + "Blocked Java threads detected", + "Some Java threads are blocked on monitor enter.", + resultHelper.orderedMap("blockedJavaThreads", blockedJavaThreads) + )); + } + if (errorCount > 0) { + findings.add(new McpFinding( + "medium", + "Thread dump parsing reported errors", + "The analyzer reported parse errors, so some details may be incomplete.", + resultHelper.orderedMap("errorCount", errorCount) + )); + } + + List recommendations = new ArrayList<>(); + if (deadLockCount > 0 || blockedJavaThreads > 0) { + recommendations.add("Use `analyze_thread_dump_blocking_chains` to inspect monitor owners and waiters."); + } + recommendations.add("Use `get_thread_dump_thread_content` for any suspicious thread ID to inspect its raw stack."); + + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "overview", overview, + "computed", resultHelper.orderedMap( + "totalThreads", totalThreads, + "javaThreads", javaThreads, + "daemonThreads", daemonThreads, + "blockedJavaThreads", blockedJavaThreads + ) + ); + return resultHelper.analysisSummary(fileView, summary, findings, recommendations, evidence); + } + + McpAnalysisSummary analyzeThreadDumpDetails(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.THREAD_DUMP); + Map overview = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "overview", null); + Map threads = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "threads", + resultHelper.orderedMap("page", 1, "pageSize", MAX_THREAD_SAMPLE_SIZE)); + Map monitors = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "monitors", + resultHelper.orderedMap("page", 1, "pageSize", MAX_THREAD_SAMPLE_SIZE)); + + List> threadData = resultHelper.getListOfMaps(threads.get("data")); + List> monitorData = resultHelper.getListOfMaps(monitors.get("data")); + + String summary = "Thread dump details for " + fileView.originalName() + + ": sampled " + threadData.size() + " thread(s) and " + + monitorData.size() + " monitor(s) from the current dump."; + + List findings = new ArrayList<>(); + if (resultHelper.getInt(threads, "totalSize", 0) > threadData.size()) { + findings.add(new McpFinding( + "low", + "Thread list was sampled", + "Only the first page of threads is returned to keep MCP responses compact.", + resultHelper.orderedMap("sampled", threadData.size(), "total", resultHelper.getInt(threads, "totalSize", 0)) + )); + } + if (resultHelper.getInt(monitors, "totalSize", 0) > monitorData.size()) { + findings.add(new McpFinding( + "low", + "Monitor list was sampled", + "Only the first page of monitors is returned to keep MCP responses compact.", + resultHelper.orderedMap("sampled", monitorData.size(), "total", resultHelper.getInt(monitors, "totalSize", 0)) + )); + } + + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "overview", overview, + "sampleThreads", threadData, + "sampleMonitors", monitorData + ); + return resultHelper.analysisSummary( + fileView, + summary, + findings, + List.of( + "Use `analyze_thread_dump_blocking_chains` to focus on monitors with waiters.", + "Use `get_thread_dump_thread_content` when a sampled thread looks suspicious." + ), + evidence + ); + } + + McpAnalysisSummary analyzeThreadDumpBlockingChains(String uniqueName) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.THREAD_DUMP); + Map monitors = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "monitors", + resultHelper.orderedMap("page", 1, "pageSize", MAX_MONITOR_SAMPLE_SIZE)); + List> monitorData = resultHelper.getListOfMaps(monitors.get("data")); + List> chains = new ArrayList<>(); + List findings = new ArrayList<>(); + + for (Map monitor : monitorData) { + int monitorId = resultHelper.getInt(monitor, "id", -1); + Map stateCounts = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "threadCountsByMonitor", + resultHelper.orderedMap("id", monitorId)); + int blockedCount = resultHelper.monitorStateCount(stateCounts, "WAITING_TO_LOCK") + + resultHelper.monitorStateCount(stateCounts, "WAITING_TO_RE_LOCK"); + int waitingCount = resultHelper.monitorStateCount(stateCounts, "WAITING_ON") + + resultHelper.monitorStateCount(stateCounts, "WAITING_ON_CLASS_INITIALIZATION") + + resultHelper.monitorStateCount(stateCounts, "WAITING_ON_NO_OBJECT_REFERENCE_AVAILABLE") + + resultHelper.monitorStateCount(stateCounts, "PARKING"); + int ownerCount = resultHelper.monitorStateCount(stateCounts, "LOCKED"); + if (blockedCount == 0 && waitingCount == 0) { + continue; + } + + Map chain = resultHelper.orderedMap( + "monitor", resultHelper.orderedMap( + "id", monitorId, + "reference", resultHelper.formatMonitorReference(monitor) + ), + "counts", stateCounts, + "owners", loadThreadsByMonitorState(fileView, uniqueName, monitorId, "LOCKED", ownerCount), + "blocked", resultHelper.mergeLists(List.of( + loadThreadsByMonitorState(fileView, uniqueName, monitorId, "WAITING_TO_LOCK", + resultHelper.monitorStateCount(stateCounts, "WAITING_TO_LOCK")), + loadThreadsByMonitorState(fileView, uniqueName, monitorId, "WAITING_TO_RE_LOCK", + resultHelper.monitorStateCount(stateCounts, "WAITING_TO_RE_LOCK")) + )), + "waiting", resultHelper.mergeLists(List.of( + loadThreadsByMonitorState(fileView, uniqueName, monitorId, "WAITING_ON", + resultHelper.monitorStateCount(stateCounts, "WAITING_ON")), + loadThreadsByMonitorState(fileView, uniqueName, monitorId, "PARKING", + resultHelper.monitorStateCount(stateCounts, "PARKING")), + loadThreadsByMonitorState(fileView, uniqueName, monitorId, + "WAITING_ON_CLASS_INITIALIZATION", + resultHelper.monitorStateCount(stateCounts, "WAITING_ON_CLASS_INITIALIZATION")), + loadThreadsByMonitorState(fileView, uniqueName, monitorId, + "WAITING_ON_NO_OBJECT_REFERENCE_AVAILABLE", + resultHelper.monitorStateCount(stateCounts, "WAITING_ON_NO_OBJECT_REFERENCE_AVAILABLE")) + )) + ); + chains.add(chain); + findings.add(new McpFinding( + blockedCount > 0 ? "high" : "medium", + "Blocking chain on " + resultHelper.formatMonitorReference(monitor), + "This monitor has " + blockedCount + " blocked thread(s) and " + waitingCount + " waiting thread(s).", + resultHelper.orderedMap("counts", stateCounts) + )); + } + + chains.sort(Comparator.comparingInt(this::blockingChainScore).reversed()); + findings.sort(Comparator.comparing((McpFinding finding) -> resultHelper.severityScore(finding.severity())).reversed()); + + String summary = "Thread dump blocking chains for " + fileView.originalName() + + ": detected " + chains.size() + " monitor(s) with blocked or waiting threads."; + Map evidence = resultHelper.orderedMap( + "fileInfo", resultHelper.toMap(fileResolver.toFileInfo(fileView)), + "chains", chains + ); + return resultHelper.analysisSummary( + fileView, + summary, + findings, + List.of("Use `get_thread_dump_thread_content` with the returned thread IDs to inspect raw stacks."), + evidence + ); + } + + McpThreadContentResult getThreadDumpThreadContent(String uniqueName, int threadId) { + FileView fileView = fileResolver.requireFile(uniqueName, FileType.THREAD_DUMP); + List content = analysisInvoker.invokeAnalysisAsListOfStrings(fileView.type(), uniqueName, "rawContentOfThread", + resultHelper.orderedMap("id", threadId)); + return new McpThreadContentResult( + fileView.uniqueName(), + fileView.originalName(), + threadId, + content.size(), + String.join("\n", content) + ); + } + + private List> loadThreadsByMonitorState(FileView fileView, + String uniqueName, + int monitorId, + String state, + int totalSize) { + if (totalSize <= 0) { + return List.of(); + } + Map pageView = analysisInvoker.invokeAnalysisAsMap(fileView.type(), uniqueName, "threadsByMonitor", + resultHelper.orderedMap( + "id", monitorId, + "state", state, + "page", 1, + "pageSize", Math.min(totalSize, 10) + )); + return resultHelper.getListOfMaps(pageView.get("data")); + } + + private int blockingChainScore(Map chain) { + Map counts = resultHelper.getMap(chain, "counts"); + return resultHelper.monitorStateCount(counts, "WAITING_TO_LOCK") + + resultHelper.monitorStateCount(counts, "WAITING_TO_RE_LOCK") + + resultHelper.monitorStateCount(counts, "WAITING_ON") + + resultHelper.monitorStateCount(counts, "PARKING"); + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolDefinition.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolDefinition.java new file mode 100644 index 00000000..670046a9 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolDefinition.java @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.function.Function; + +public record JifaMcpToolDefinition(String name, + String description, + McpSchema.JsonSchema inputSchema, + Function handler) { +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolRegistry.java b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolRegistry.java new file mode 100644 index 00000000..37aa8f05 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/JifaMcpToolRegistry.java @@ -0,0 +1,191 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import io.modelcontextprotocol.spec.McpSchema; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JifaMcpToolRegistry { + + private static final McpSchema.JsonSchema LIST_MY_FILES_SCHEMA = new McpSchema.JsonSchema( + "object", + Map.of( + "type", Map.of( + "type", "string", + "description", "Optional file type filter such as gc-log, thread-dump, heap-dump, or jfr-file." + ), + "page", Map.of( + "type", "integer", + "description", "Optional page number starting from 1." + ), + "pageSize", Map.of( + "type", "integer", + "description", "Optional page size between 1 and 100." + ) + ), + null, + false, + null, + null + ); + + private static final McpSchema.JsonSchema FILE_ARGUMENT_SCHEMA = new McpSchema.JsonSchema( + "object", + Map.of( + "file", Map.of( + "type", "string", + "description", "The unique file name returned by list_my_files." + ) + ), + List.of("file"), + false, + null, + null + ); + + private static final McpSchema.JsonSchema THREAD_CONTENT_ARGUMENT_SCHEMA = new McpSchema.JsonSchema( + "object", + Map.of( + "file", Map.of( + "type", "string", + "description", "The unique thread dump file name." + ), + "threadId", Map.of( + "type", "integer", + "description", "The thread id returned by analyze_thread_dump_details or blocking chains." + ) + ), + List.of("file", "threadId"), + false, + null, + null + ); + + private final List definitions; + + public JifaMcpToolRegistry(JifaMcpFileToolService fileToolService, + JifaMcpGcLogToolService gcLogToolService, + JifaMcpThreadDumpToolService threadDumpToolService, + JifaMcpHeapDumpToolService heapDumpToolService) { + this.definitions = List.of( + definition("list_my_files", + "List the current user's Jifa files. Optionally filter by file type.", + LIST_MY_FILES_SCHEMA, + request -> fileToolService.listMyFiles(optionalStringArgument(request, "type"), + optionalIntArgument(request, "page"), + optionalIntArgument(request, "pageSize"))), + definition("get_file_info", + "Get basic metadata and supported MCP analyses for one Jifa file.", + FILE_ARGUMENT_SCHEMA, + request -> fileToolService.getFileInfo(requiredStringArgument(request, "file"))), + definition("analyze_file_summary", + "Return a high-level summary for a Jifa file. Dispatches to the supported file-type summary when possible.", + FILE_ARGUMENT_SCHEMA, + request -> fileToolService.analyzeFileSummary(requiredStringArgument(request, "file"))), + definition("analyze_gc_log_summary", + "Return a high-level GC log summary with findings and recommendations.", + FILE_ARGUMENT_SCHEMA, + request -> gcLogToolService.analyzeGcLogSummary(requiredStringArgument(request, "file"))), + definition("analyze_gc_log_metrics", + "Return key GC metrics such as pause, throughput, allocation, and memory usage.", + FILE_ARGUMENT_SCHEMA, + request -> gcLogToolService.analyzeGcLogMetrics(requiredStringArgument(request, "file"))), + definition("analyze_thread_dump_summary", + "Return a high-level thread dump summary including deadlocks and blocking hints.", + FILE_ARGUMENT_SCHEMA, + request -> threadDumpToolService.analyzeThreadDumpSummary(requiredStringArgument(request, "file"))), + definition("analyze_thread_dump_details", + "Return sampled thread and monitor details from a thread dump.", + FILE_ARGUMENT_SCHEMA, + request -> threadDumpToolService.analyzeThreadDumpDetails(requiredStringArgument(request, "file"))), + definition("analyze_thread_dump_blocking_chains", + "Return monitors with owners, blocked threads, and waiting threads.", + FILE_ARGUMENT_SCHEMA, + request -> threadDumpToolService.analyzeThreadDumpBlockingChains(requiredStringArgument(request, "file"))), + definition("get_thread_dump_thread_content", + "Return the raw text content for one thread in a thread dump.", + THREAD_CONTENT_ARGUMENT_SCHEMA, + request -> threadDumpToolService.getThreadDumpThreadContent( + requiredStringArgument(request, "file"), + requiredIntArgument(request, "threadId") + )), + definition("analyze_heap_dump_summary", + "Return a high-level heap dump summary including object and classloader overview.", + FILE_ARGUMENT_SCHEMA, + request -> heapDumpToolService.analyzeHeapDumpSummary(requiredStringArgument(request, "file"))), + definition("analyze_heap_dump_hotspots", + "Return retained-heap hotspots based on the biggest objects and class histogram.", + FILE_ARGUMENT_SCHEMA, + request -> heapDumpToolService.analyzeHeapDumpHotspots(requiredStringArgument(request, "file"))), + definition("analyze_heap_dump_thread_details", + "Return retained-heap details for thread objects in a heap dump.", + FILE_ARGUMENT_SCHEMA, + request -> heapDumpToolService.analyzeHeapDumpThreadDetails(requiredStringArgument(request, "file"))) + ); + } + + public List definitions() { + return definitions; + } + + private JifaMcpToolDefinition definition(String name, + String description, + McpSchema.JsonSchema inputSchema, + Function handler) { + return new JifaMcpToolDefinition(name, description, inputSchema, handler); + } + + private String optionalStringArgument(McpSchema.CallToolRequest request, String name) { + Object value = request.arguments().get(name); + return value == null ? null : String.valueOf(value); + } + + private String requiredStringArgument(McpSchema.CallToolRequest request, String name) { + String value = optionalStringArgument(request, name); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing required argument: " + name); + } + return value; + } + + private int requiredIntArgument(McpSchema.CallToolRequest request, String name) { + Integer value = optionalIntArgument(request, name); + if (value == null) { + throw new IllegalArgumentException("Missing required integer argument: " + name); + } + return value; + } + + private Integer optionalIntArgument(McpSchema.CallToolRequest request, String name) { + Object value = request.arguments().get(name); + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String stringValue && !stringValue.isBlank()) { + try { + return Integer.parseInt(stringValue); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Argument '" + name + "' must be an integer.", e); + } + } + throw new IllegalArgumentException("Argument '" + name + "' must be an integer."); + } +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpAnalysisSummary.java b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpAnalysisSummary.java new file mode 100644 index 00000000..93b8a531 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpAnalysisSummary.java @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp.dto; + +import java.util.List; +import java.util.Map; + +public record McpAnalysisSummary(String file, + String originalName, + String fileTypeId, + String fileType, + String summary, + List findings, + List recommendations, + Map evidence) { +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFileInfo.java b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFileInfo.java new file mode 100644 index 00000000..56b66a8f --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFileInfo.java @@ -0,0 +1,25 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp.dto; + +import java.util.List; + +public record McpFileInfo(long id, + String uniqueName, + String originalName, + String typeId, + String type, + long size, + String createdTime, + List analysisCapabilities) { +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFinding.java b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFinding.java new file mode 100644 index 00000000..69399a73 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpFinding.java @@ -0,0 +1,21 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp.dto; + +import java.util.Map; + +public record McpFinding(String severity, + String title, + String detail, + Map evidence) { +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpListMyFilesResult.java b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpListMyFilesResult.java new file mode 100644 index 00000000..4beae347 --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpListMyFilesResult.java @@ -0,0 +1,25 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp.dto; + +import java.util.List; + +public record McpListMyFilesResult(String requestedType, + String normalizedType, + int page, + int pageSize, + int totalSize, + int returnedSize, + boolean truncated, + List files) { +} diff --git a/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpThreadContentResult.java b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpThreadContentResult.java new file mode 100644 index 00000000..e90db2fd --- /dev/null +++ b/server/src/main/java/org/eclipse/jifa/server/mcp/dto/McpThreadContentResult.java @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp.dto; + +public record McpThreadContentResult(String file, + String originalName, + int threadId, + int lineCount, + String content) { +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/JifaMcpToolServiceTestSupport.java b/server/src/test/java/org/eclipse/jifa/server/mcp/JifaMcpToolServiceTestSupport.java new file mode 100644 index 00000000..10472097 --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/JifaMcpToolServiceTestSupport.java @@ -0,0 +1,315 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.jifa.common.domain.vo.PageView; +import org.eclipse.jifa.server.domain.dto.AnalysisApiRequest; +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.service.AnalysisApiService; +import org.eclipse.jifa.server.service.FileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; + +@ExtendWith(MockitoExtension.class) +abstract class JifaMcpToolServiceTestSupport { + + protected static final FileView GC_FILE = new FileView( + 1L, + "gc-file", + "app.gc.log", + FileType.GC_LOG, + 1024L, + LocalDateTime.of(2026, 3, 27, 10, 0) + ); + + protected static final FileView THREAD_FILE = new FileView( + 2L, + "thread-file", + "threads.txt", + FileType.THREAD_DUMP, + 2048L, + LocalDateTime.of(2026, 3, 27, 10, 5) + ); + + protected static final FileView HEAP_FILE = new FileView( + 3L, + "heap-file", + "dump.hprof", + FileType.HEAP_DUMP, + 4096L, + LocalDateTime.of(2026, 3, 27, 10, 10) + ); + + protected static final FileView JFR_FILE = new FileView( + 4L, + "jfr-file", + "recording.jfr", + FileType.JFR_FILE, + 512L, + LocalDateTime.of(2026, 3, 27, 10, 15) + ); + + @Mock + protected FileService fileService; + + @Mock + protected AnalysisApiService analysisApiService; + + protected final ObjectMapper objectMapper = new ObjectMapper(); + + protected JifaMcpFileToolService fileToolService; + + protected JifaMcpGcLogToolService gcLogToolService; + + protected JifaMcpThreadDumpToolService threadDumpToolService; + + protected JifaMcpHeapDumpToolService heapDumpToolService; + + protected Map filesByUniqueName; + + protected Map> analysisReplies; + + @BeforeEach + public void setUp() { + JifaMcpResultHelper resultHelper = new JifaMcpResultHelper(objectMapper); + JifaMcpAnalysisInvoker analysisInvoker = new JifaMcpAnalysisInvoker(analysisApiService, objectMapper); + JifaMcpFileResolver fileResolver = new JifaMcpFileResolver(fileService, resultHelper); + gcLogToolService = new JifaMcpGcLogToolService(analysisInvoker, fileResolver, resultHelper); + threadDumpToolService = new JifaMcpThreadDumpToolService(analysisInvoker, fileResolver, resultHelper); + heapDumpToolService = new JifaMcpHeapDumpToolService(analysisInvoker, fileResolver, resultHelper); + fileToolService = new JifaMcpFileToolService(fileResolver, + resultHelper, + gcLogToolService, + threadDumpToolService, + heapDumpToolService); + filesByUniqueName = new HashMap<>(); + analysisReplies = new HashMap<>(); + + filesByUniqueName.put(GC_FILE.uniqueName(), GC_FILE); + filesByUniqueName.put(THREAD_FILE.uniqueName(), THREAD_FILE); + filesByUniqueName.put(HEAP_FILE.uniqueName(), HEAP_FILE); + filesByUniqueName.put(JFR_FILE.uniqueName(), JFR_FILE); + + lenient().when(fileService.getFileViewByUniqueName(anyString())).thenAnswer(invocation -> + filesByUniqueName.get(invocation.getArgument(0, String.class))); + lenient().when(analysisApiService.invoke(any())).thenAnswer(invocation -> { + AnalysisApiRequest request = invocation.getArgument(0, AnalysisApiRequest.class); + Function reply = analysisReplies.get(request.api()); + return CompletableFuture.completedFuture(reply == null ? null : reply.apply(request)); + }); + } + + protected void registerGcResponses() { + reply("metadata", request -> Map.of( + "collector", "G1", + "logStyle", "UNIFIED", + "startTime", 0.0, + "endTime", 5000.0, + "analysisConfig", Map.of( + "timeRange", Map.of("start", 0.0, "end", 5000.0), + "longPauseThreshold", 400.0, + "badThroughputThreshold", 90.0, + "highHeapUsageThreshold", 60.0, + "highOldUsageThreshold", 80.0, + "highMetaspaceUsageThreshold", 80.0 + ) + )); + reply("pauseStatistics", request -> Map.of( + "throughput", 0.82, + "pauseAvg", 180.0, + "pauseMedian", 120.0, + "pauseP99", 520.0, + "pauseP999", 540.0, + "pauseMax", 610.0 + )); + reply("diagnoseInfo", request -> Map.of( + "mostSeriousProblem", Map.of( + "problem", Map.of("name", "longYoungGCPause"), + "sites", List.of(Map.of("start", 1000.0, "end", 2000.0)), + "suggestions", List.of( + Map.of("name", "checkPauseTime"), + Map.of("name", "inspectYoungGen") + ) + ), + "seriousProblems", Map.of( + "longYoungGCPause", List.of(1000.0, 2000.0), + "frequentYoungGC", List.of(2500.0) + ) + )); + reply("memoryStatistics", request -> Map.of( + "heap", Map.of( + "capacityAvg", 1024L * 1024L * 1024L, + "usedMax", 900L * 1024L * 1024L, + "usedAvgAfterFullGC", 850L * 1024L * 1024L + ), + "old", Map.of( + "capacityAvg", 700L * 1024L * 1024L, + "usedMax", 600L * 1024L * 1024L, + "usedAvgAfterFullGC", 590L * 1024L * 1024L + ), + "metaspace", Map.of( + "capacityAvg", 200L * 1024L * 1024L, + "usedMax", 170L * 1024L * 1024L, + "usedAvgAfterFullGC", 165L * 1024L * 1024L + ) + )); + reply("objectStatistics", request -> Map.of( + "objectCreationSpeed", 2048.0, + "objectPromotionSpeed", 1024.0, + "objectPromotionAvg", 4096L, + "objectPromotionMax", 8192L + )); + } + + protected void registerThreadDumpResponses() { + reply("overview", request -> Map.of( + "threadStat", Map.of("counts", List.of(3, 2, 1)), + "javaThreadStat", Map.of( + "javaCounts", List.of(0, 2, 0, 0, 0, 0, 0, 1, 0, 0), + "daemonCount", 1 + ), + "deadLockCount", 1, + "errorCount", 0 + )); + reply("threads", request -> new PageView<>(1, 20, 3, List.of( + Map.of("id", 101, "name", "main"), + Map.of("id", 102, "name", "worker") + ))); + reply("monitors", request -> new PageView<>(1, 20, 1, List.of( + Map.of("id", 11, "clazz", "java.lang.Object", "address", 42L) + ))); + reply("threadCountsByMonitor", request -> Map.of( + "LOCKED", 1, + "WAITING_TO_LOCK", 1, + "WAITING_ON", 1 + )); + reply("threadsByMonitor", request -> { + String state = request.parameters().get("state").getAsString(); + return switch (state) { + case "LOCKED" -> new PageView<>(1, 10, 1, List.of(Map.of("id", 101, "name", "main"))); + case "WAITING_TO_LOCK" -> new PageView<>(1, 10, 1, List.of(Map.of("id", 102, "name", "worker"))); + case "WAITING_ON" -> new PageView<>(1, 10, 1, List.of(Map.of("id", 103, "name", "waiter"))); + default -> new PageView<>(1, 10, 0, List.of()); + }; + }); + reply("rawContentOfThread", request -> List.of( + "\"main\" #101 prio=5 os_prio=31 tid=0x1 nid=0x2 waiting on condition", + " java.lang.Thread.State: RUNNABLE" + )); + } + + protected void registerHeapDumpResponses() { + reply("details", request -> Map.of( + "usedHeapSize", 2L * 1024L * 1024L * 1024L, + "numberOfObjects", 12345, + "numberOfClasses", 321, + "numberOfClassLoaders", 12 + )); + reply("biggestObjects", request -> List.of( + Map.of("label", "com.example.Cache", "value", 12.5, "description", "Retained by cache root"), + Map.of("label", "byte[]", "value", 7.2, "description", "Large buffer") + )); + reply("classLoaderExplorer.summary", request -> Map.of( + "totalSize", 12, + "definedClasses", 300, + "numberOfInstances", 1000 + )); + reply("histogram", request -> new PageView<>(1, 20, 2, List.of( + Map.of( + "label", "com.example.Cache", + "retainedSize", 900L * 1024L * 1024L, + "numberOfObjects", 20 + ), + Map.of( + "label", "byte[]", + "retainedSize", 300L * 1024L * 1024L, + "numberOfObjects", 50 + ) + ))); + reply("threadsSummary", request -> Map.of( + "totalSize", 2, + "shallowHeap", 1024L, + "retainedHeap", 20L * 1024L * 1024L + )); + reply("threads", request -> new PageView<>(1, 10, 2, List.of( + Map.of( + "objectId", 2001, + "name", "main", + "retainedSize", 12L * 1024L * 1024L, + "contextClassLoader", "app-loader", + "hasStack", true + ), + Map.of( + "objectId", 2002, + "name", "worker", + "retainedSize", 8L * 1024L * 1024L, + "contextClassLoader", "app-loader", + "hasStack", true + ) + ))); + reply("stackTrace", request -> List.of( + Map.of("stack", "com.example.Work.run(Work.java:10)", "hasLocal", true), + Map.of("stack", "java.lang.Thread.run(Thread.java:840)", "hasLocal", false) + )); + } + + protected void reply(String api, Object value) { + reply(api, request -> value); + } + + protected void reply(String api, Function resolver) { + analysisReplies.put(api, resolver); + } + + protected void replyAsJsonBytes(String api, Function resolver) { + analysisReplies.put(api, request -> { + try { + return objectMapper.writeValueAsBytes(resolver.apply(request)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + protected void registerGcResponsesAsJsonBytes() { + registerGcResponses(); + convertRepliesToJsonBytes("metadata", "pauseStatistics", "diagnoseInfo", "memoryStatistics", "objectStatistics"); + } + + protected void registerThreadDumpResponsesAsJsonBytes() { + registerThreadDumpResponses(); + convertRepliesToJsonBytes("overview", "threads", "monitors", "threadCountsByMonitor", "threadsByMonitor", "rawContentOfThread"); + } + + protected void convertRepliesToJsonBytes(String... apis) { + for (String api : apis) { + Function resolver = analysisReplies.get(api); + replyAsJsonBytes(api, resolver); + } + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpAuthenticatedIntegration.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpAuthenticatedIntegration.java new file mode 100644 index 00000000..123f7050 --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpAuthenticatedIntegration.java @@ -0,0 +1,236 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.commons.io.FileUtils; +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.domain.entity.shared.user.UserEntity; +import org.eclipse.jifa.server.domain.security.JifaAuthenticationToken; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.repository.UserRepo; +import org.eclipse.jifa.server.service.FileService; +import org.eclipse.jifa.server.service.JwtService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.IOException; +import java.net.URI; +import java.net.ServerSocket; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + properties = { + "jifa.role=standalone-worker", + "jifa.mcp-enabled=true", + "jifa.allow-login=true", + "jifa.allow-anonymous-access=false", + "jifa.database-host=", + "spring.jpa.hibernate.ddl-auto=create-drop" + } +) +public class TestJifaMcpAuthenticatedIntegration { + + private static final int TEST_PORT = findAvailablePort(); + + private static final Path TEST_STORAGE = createTempDirectory("jifa-mcp-auth-storage"); + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private static final TypeReference> LIST_TYPE = new TypeReference<>() {}; + + @Autowired + private FileService fileService; + + @Autowired + private UserRepo userRepo; + + @Autowired + private JwtService jwtService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("jifa.port", () -> TEST_PORT); + registry.add("jifa.storage-path", () -> TEST_STORAGE.toString()); + } + + @AfterAll + static void cleanUp() throws IOException { + FileUtils.deleteDirectory(TEST_STORAGE.toFile()); + } + + @Test + public void testOwnerCanListOnlyOwnFiles() throws Throwable { + UserEntity owner = createUser("owner-" + UUID.randomUUID()); + String uniqueName = uploadGcLogAs(owner, "owner.gc.log"); + + try (McpSyncClient client = initializeClient(owner)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "list_my_files", + Map.of("type", FileType.GC_LOG.getApiNamespace()) + )); + Map structured = asMap(result.structuredContent()); + List files = asList(structured.get("files")); + + assertThat(result.isError()).isEqualTo(false); + assertThat(files).hasSize(1); + assertThat(asMap(files.get(0)).get("uniqueName")).isEqualTo(uniqueName); + } + } + + @Test + public void testAccessDeniedReturnsStructuredErrorCode() throws Throwable { + UserEntity owner = createUser("owner-" + UUID.randomUUID()); + UserEntity outsider = createUser("outsider-" + UUID.randomUUID()); + String uniqueName = uploadGcLogAs(owner, "private.gc.log"); + + try (McpSyncClient client = initializeClient(outsider)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "get_file_info", + Map.of("file", uniqueName) + )); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(true); + assertThat(structured.get("errorCode")).isEqualTo("ACCESS_DENIED"); + } + } + + @Test + public void testMissingFileReturnsStructuredErrorCode() { + UserEntity owner = createUser("owner-" + UUID.randomUUID()); + + try (McpSyncClient client = initializeClient(owner)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "get_file_info", + Map.of("file", "missing-file") + )); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(true); + assertThat(structured.get("errorCode")).isEqualTo("FILE_NOT_FOUND"); + } + } + + private UserEntity createUser(String name) { + UserEntity user = new UserEntity(); + user.setName(name); + user.setAdmin(false); + return userRepo.save(user); + } + + private String uploadGcLogAs(UserEntity user, String originalName) throws Throwable { + MockMultipartFile file = new MockMultipartFile( + "file", + originalName, + "text/plain", + """ + OpenJDK 64-Bit Server VM (25.472-b08) for bsd-aarch64 JRE (1.8.0_472-b08) + CommandLine flags: -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+UseParallelGC + 2026-03-23T18:46:22.549+0800: 13.372: [GC (Metadata GC Threshold) [PSYoungGen: 88829K->12434K(114688K)] 88829K->12523K(376832K), 0.0049924 secs] [Times: user=0.01 sys=0.01, real=0.01 secs] + 2026-03-23T18:46:22.554+0800: 13.377: [Full GC (Metadata GC Threshold) [PSYoungGen: 12434K->0K(114688K)] [ParOldGen: 88K->11844K(110592K)] 12523K->11844K(225280K), [Metaspace: 20756K->20756K(1069056K)], 0.0122272 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] + """.getBytes(StandardCharsets.UTF_8) + ); + + return withAuthentication(jwtService.generateToken(user), () -> { + long fileId = fileService.handleUploadRequest(FileType.GC_LOG, file); + FileView fileView = fileService.getFileViewById(fileId); + return fileView.uniqueName(); + }); + } + + private McpSyncClient initializeClient(UserEntity user) { + McpSyncClient client = createClient(user); + client.initialize(); + return client; + } + + private McpSyncClient createClient(UserEntity user) { + return McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + TEST_PORT) + .endpoint("/jifa-api/mcp") + .customizeRequest(request -> request.header(HttpHeaders.AUTHORIZATION, + "Bearer " + jwtService.generateToken(user).getToken())) + .build()) + .build(); + } + + private Map asMap(Object value) { + return objectMapper.convertValue(value, MAP_TYPE); + } + + private List asList(Object value) { + return objectMapper.convertValue(value, LIST_TYPE); + } + + private T withAuthentication(JifaAuthenticationToken authentication, ThrowingSupplier supplier) throws Throwable { + SecurityContext previous = SecurityContextHolder.getContext(); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + try { + return supplier.get(); + } finally { + SecurityContextHolder.setContext(previous); + } + } + + private static int findAvailablePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Failed to allocate a test port", e); + } + } + + private static Path createTempDirectory(String prefix) { + try { + return Files.createTempDirectory(prefix); + } catch (IOException e) { + throw new IllegalStateException("Failed to create temp directory", e); + } + } + + @FunctionalInterface + private interface ThrowingSupplier { + + T get() throws Throwable; + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpFileToolService.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpFileToolService.java new file mode 100644 index 00000000..c8b96330 --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpFileToolService.java @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.eclipse.jifa.common.domain.vo.PageView; +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpFileInfo; +import org.eclipse.jifa.server.mcp.dto.McpListMyFilesResult; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +public class TestJifaMcpFileToolService extends JifaMcpToolServiceTestSupport { + + @Test + public void testListMyFiles() { + when(fileService.getUserFileViews(null, 2, 1)) + .thenReturn(new PageView<>(2, 1, 3, List.of(THREAD_FILE))); + + McpListMyFilesResult result = fileToolService.listMyFiles(null, 2, 1); + + assertThat(result.totalSize()).isEqualTo(3); + assertThat(result.page()).isEqualTo(2); + assertThat(result.pageSize()).isEqualTo(1); + assertThat(result.returnedSize()).isEqualTo(1); + assertThat(result.truncated()).isTrue(); + assertThat(result.files()).extracting(McpFileInfo::uniqueName) + .containsExactly("thread-file"); + assertThat(result.files().get(0).analysisCapabilities()).contains("analyze_thread_dump_summary"); + } + + @Test + public void testGetFileInfo() { + McpFileInfo result = fileToolService.getFileInfo(GC_FILE.uniqueName()); + + assertThat(result.uniqueName()).isEqualTo("gc-file"); + assertThat(result.typeId()).isEqualTo("gc-log"); + assertThat(result.analysisCapabilities()).contains("analyze_gc_log_metrics"); + } + + @Test + public void testAnalyzeFileSummaryDispatchesToGcLogSummary() { + registerGcResponses(); + + McpAnalysisSummary result = fileToolService.analyzeFileSummary(GC_FILE.uniqueName()); + + assertThat(result.file()).isEqualTo("gc-file"); + assertThat(result.summary()).contains("GC log summary"); + assertThat(result.findings()).isNotEmpty(); + } + + @Test + public void testAnalyzeFileSummaryForJfrFallsBackToUnsupportedMessage() { + McpAnalysisSummary result = fileToolService.analyzeFileSummary(JFR_FILE.uniqueName()); + + assertThat(result.fileTypeId()).isEqualTo("jfr-file"); + assertThat(result.summary()).contains("JFR"); + assertThat(result.findings()).hasSize(1); + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpGcLogToolService.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpGcLogToolService.java new file mode 100644 index 00000000..c9373b4d --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpGcLogToolService.java @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestJifaMcpGcLogToolService extends JifaMcpToolServiceTestSupport { + + @Test + public void testAnalyzeGcLogSummary() { + registerGcResponses(); + + McpAnalysisSummary result = gcLogToolService.analyzeGcLogSummary(GC_FILE.uniqueName()); + + assertThat(result.summary()).contains("collector=G1"); + assertThat(result.findings()).extracting(f -> f.title()).contains("longYoungGCPause"); + assertThat(result.recommendations()).isNotEmpty(); + } + + @Test + public void testAnalyzeGcLogMetrics() { + registerGcResponses(); + + McpAnalysisSummary result = gcLogToolService.analyzeGcLogMetrics(GC_FILE.uniqueName()); + + assertThat(result.summary()).contains("p99 pause"); + assertThat(result.findings()).extracting(f -> f.title()) + .contains("Pause time exceeds threshold", "Low throughput"); + assertThat(result.evidence()).containsKeys("pauseStatistics", "memoryStatistics", "objectStatistics"); + } + + @Test + public void testAnalyzeGcLogSummaryWithWorkerStyleJsonBytes() { + registerGcResponsesAsJsonBytes(); + + McpAnalysisSummary result = gcLogToolService.analyzeGcLogSummary(GC_FILE.uniqueName()); + + assertThat(result.summary()).contains("collector=G1"); + assertThat(result.findings()).extracting(f -> f.title()).contains("longYoungGCPause"); + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpHeapDumpToolService.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpHeapDumpToolService.java new file mode 100644 index 00000000..6b46949b --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpHeapDumpToolService.java @@ -0,0 +1,51 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestJifaMcpHeapDumpToolService extends JifaMcpToolServiceTestSupport { + + @Test + public void testAnalyzeHeapDumpSummary() { + registerHeapDumpResponses(); + + McpAnalysisSummary result = heapDumpToolService.analyzeHeapDumpSummary(HEAP_FILE.uniqueName()); + + assertThat(result.summary()).contains("used heap"); + assertThat(result.evidence()).containsKeys("details", "classLoaderSummary", "largestObjects"); + } + + @Test + public void testAnalyzeHeapDumpHotspots() { + registerHeapDumpResponses(); + + McpAnalysisSummary result = heapDumpToolService.analyzeHeapDumpHotspots(HEAP_FILE.uniqueName()); + + assertThat(result.summary()).contains("top retained class=com.example.Cache"); + assertThat(result.findings()).isNotEmpty(); + } + + @Test + public void testAnalyzeHeapDumpThreadDetails() { + registerHeapDumpResponses(); + + McpAnalysisSummary result = heapDumpToolService.analyzeHeapDumpThreadDetails(HEAP_FILE.uniqueName()); + + assertThat(result.summary()).contains("thread objects=2"); + assertThat(result.evidence()).containsKeys("threadSummary", "topThreads", "stackSamples"); + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpIntegration.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpIntegration.java new file mode 100644 index 00000000..6442da97 --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpIntegration.java @@ -0,0 +1,495 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.eclipse.jifa.common.domain.vo.PageView; +import org.eclipse.jifa.server.domain.dto.AnalysisApiRequest; +import org.eclipse.jifa.server.domain.dto.FileView; +import org.eclipse.jifa.server.enums.FileType; +import org.eclipse.jifa.server.repository.LoginDataRepo; +import org.eclipse.jifa.server.service.AnalysisApiService; +import org.eclipse.jifa.server.service.FileService; +import org.eclipse.jifa.server.service.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.http.HttpHeaders; + +import java.io.IOException; +import java.net.URI; +import java.net.ServerSocket; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + properties = { + "jifa.role=standalone-worker", + "jifa.storage-path=${java.io.tmpdir}/jifa-mcp-test-storage", + "jifa.mcp-enabled=true", + "jifa.allow-login=true", + "jifa.allow-anonymous-access=false", + "jifa.database-host=", + "spring.jpa.hibernate.ddl-auto=create-drop" + } +) +public class TestJifaMcpIntegration { + + private static final int TEST_PORT = findAvailablePort(); + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private static final TypeReference> LIST_TYPE = new TypeReference<>() {}; + + private static final TypeReference>> LIST_OF_MAPS_TYPE = new TypeReference<>() {}; + + private static final FileView GC_FILE = new FileView( + 1L, + "gc-file", + "app.gc.log", + FileType.GC_LOG, + 1024L, + LocalDateTime.of(2026, 3, 27, 11, 0) + ); + + private static final FileView THREAD_FILE = new FileView( + 2L, + "thread-file", + "threads.txt", + FileType.THREAD_DUMP, + 2048L, + LocalDateTime.of(2026, 3, 27, 11, 5) + ); + + private static final FileView HEAP_FILE = new FileView( + 3L, + "heap-file", + "dump.hprof", + FileType.HEAP_DUMP, + 4096L, + LocalDateTime.of(2026, 3, 27, 11, 10) + ); + + @Autowired + private JwtService jwtService; + + @Autowired + private LoginDataRepo loginDataRepo; + + @MockBean + private FileService fileService; + + @MockBean + private AnalysisApiService analysisApiService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + private Map filesByUniqueName; + + private Map> analysisReplies; + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("jifa.port", () -> TEST_PORT); + } + + @BeforeEach + public void setUp() { + filesByUniqueName = new HashMap<>(); + analysisReplies = new HashMap<>(); + + filesByUniqueName.put(GC_FILE.uniqueName(), GC_FILE); + filesByUniqueName.put(THREAD_FILE.uniqueName(), THREAD_FILE); + filesByUniqueName.put(HEAP_FILE.uniqueName(), HEAP_FILE); + + Mockito.when(fileService.getFileViewByUniqueName(Mockito.anyString())).thenAnswer(invocation -> + filesByUniqueName.get(invocation.getArgument(0, String.class))); + Mockito.when(fileService.getUserFileViews(Mockito.any(), Mockito.anyInt(), Mockito.anyInt())) + .thenAnswer(invocation -> pageViewFor(invocation.getArgument(0, FileType.class), + invocation.getArgument(1, Integer.class), + invocation.getArgument(2, Integer.class))); + Mockito.when(analysisApiService.invoke(Mockito.any())).thenAnswer(invocation -> { + AnalysisApiRequest request = invocation.getArgument(0, AnalysisApiRequest.class); + Function reply = analysisReplies.get(request.api()); + return CompletableFuture.completedFuture(reply == null ? null : reply.apply(request)); + }); + + registerGcResponses(); + registerThreadDumpResponses(); + registerHeapDumpResponses(); + } + + @Test + public void testUnauthenticatedAccessRejected() throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl() + "/jifa-api/mcp")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.ACCEPT, "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}} + """)) + .build(); + + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + public void testListToolsContainsExactlyIssue382Tools() { + try (McpSyncClient client = initializeClient(true)) { + List> tools = asListOfMaps(client.listTools().tools()); + + List toolNames = tools.stream().map(tool -> tool.get("name").toString()).toList(); + + assertThat(toolNames).containsExactlyInAnyOrder( + "list_my_files", + "get_file_info", + "analyze_file_summary", + "analyze_gc_log_summary", + "analyze_gc_log_metrics", + "analyze_thread_dump_summary", + "analyze_thread_dump_details", + "analyze_thread_dump_blocking_chains", + "get_thread_dump_thread_content", + "analyze_heap_dump_summary", + "analyze_heap_dump_hotspots", + "analyze_heap_dump_thread_details" + ); + assertThat(client.getServerInfo().version()).isEqualTo("dev"); + } + } + + @Test + public void testListMyFilesToolCall() { + try (McpSyncClient client = initializeClient(true)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("list_my_files", Map.of())); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("returnedSize")).isEqualTo(3); + assertThat(asList(structured.get("files"))).hasSize(3); + } + } + + @Test + public void testListMyFilesSupportsPagination() { + try (McpSyncClient client = initializeClient(true)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("list_my_files", Map.of( + "page", 2, + "pageSize", 1 + ))); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("page")).isEqualTo(2); + assertThat(structured.get("pageSize")).isEqualTo(1); + assertThat(structured.get("returnedSize")).isEqualTo(1); + assertThat(structured.get("truncated")).isEqualTo(true); + assertThat(asMap(asList(structured.get("files")).get(0)).get("uniqueName")).isEqualTo(THREAD_FILE.uniqueName()); + } + } + + @Test + public void testAnalyzeGcLogSummaryToolCall() { + try (McpSyncClient client = initializeClient(true)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "analyze_gc_log_summary", + Map.of("file", GC_FILE.uniqueName()) + )); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("summary").toString()).contains("GC log summary"); + assertThat(asList(structured.get("findings"))).isNotEmpty(); + } + } + + @Test + public void testAnalyzeThreadDumpSummaryToolCall() { + try (McpSyncClient client = initializeClient(true)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "analyze_thread_dump_summary", + Map.of("file", THREAD_FILE.uniqueName()) + )); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("summary").toString()).contains("Thread dump summary"); + assertThat(asList(structured.get("findings"))).isNotEmpty(); + } + } + + @Test + public void testAnalyzeHeapDumpSummaryToolCall() { + try (McpSyncClient client = initializeClient(true)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "analyze_heap_dump_summary", + Map.of("file", HEAP_FILE.uniqueName()) + )); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("summary").toString()).contains("Heap dump summary"); + assertThat(asMap(structured.get("evidence"))).containsKey("details"); + } + } + + @Test + public void testGetThreadDumpThreadContentToolCall() { + try (McpSyncClient client = initializeClient(true)) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "get_thread_dump_thread_content", + Map.of( + "file", THREAD_FILE.uniqueName(), + "threadId", 101 + ) + )); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("threadId")).isEqualTo(101); + assertThat(structured.get("content").toString()).contains("RUNNABLE"); + } + } + + private String baseUrl() { + return "http://localhost:" + TEST_PORT; + } + + private static int findAvailablePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Failed to allocate a test port", e); + } + } + + private Map asMap(Object value) { + return objectMapper.convertValue(value, MAP_TYPE); + } + + private List asList(Object value) { + return objectMapper.convertValue(value, LIST_TYPE); + } + + private List> asListOfMaps(Object value) { + return objectMapper.convertValue(value, LIST_OF_MAPS_TYPE); + } + + private String bearerToken() { + return jwtService.generateToken(loginDataRepo.findByUsername("admin").orElseThrow().getUser()).getToken(); + } + + private McpSyncClient initializeClient(boolean authenticated) { + McpSyncClient client = createClient(authenticated); + client.initialize(); + return client; + } + + private McpSyncClient createClient(boolean authenticated) { + HttpClientStreamableHttpTransport.Builder transportBuilder = + HttpClientStreamableHttpTransport.builder(baseUrl()).endpoint("/jifa-api/mcp"); + if (authenticated) { + transportBuilder.customizeRequest(request -> request.header(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken())); + } + return McpClient.sync(transportBuilder.build()).build(); + } + + private PageView pageViewFor(FileType type, int page, int pageSize) { + List filtered = new ArrayList<>(filesByUniqueName.values().stream() + .filter(file -> type == null || file.type() == type) + .sorted((left, right) -> Long.compare(left.id(), right.id())) + .toList()); + int fromIndex = Math.max(0, (page - 1) * pageSize); + int toIndex = Math.min(filtered.size(), fromIndex + pageSize); + List pageData = fromIndex >= filtered.size() ? List.of() : filtered.subList(fromIndex, toIndex); + return new PageView<>(page, pageSize, filtered.size(), pageData); + } + + private void registerGcResponses() { + reply("metadata", request -> Map.of( + "collector", "G1", + "logStyle", "UNIFIED", + "startTime", 0.0, + "endTime", 5000.0, + "analysisConfig", Map.of( + "timeRange", Map.of("start", 0.0, "end", 5000.0), + "longPauseThreshold", 400.0, + "badThroughputThreshold", 90.0, + "highHeapUsageThreshold", 60.0, + "highOldUsageThreshold", 80.0, + "highMetaspaceUsageThreshold", 80.0 + ) + )); + reply("pauseStatistics", request -> Map.of( + "throughput", 0.82, + "pauseAvg", 180.0, + "pauseMedian", 120.0, + "pauseP99", 520.0, + "pauseP999", 540.0, + "pauseMax", 610.0 + )); + reply("diagnoseInfo", request -> Map.of( + "mostSeriousProblem", Map.of( + "problem", Map.of("name", "longYoungGCPause"), + "sites", List.of(Map.of("start", 1000.0, "end", 2000.0)), + "suggestions", List.of(Map.of("name", "checkPauseTime")) + ), + "seriousProblems", Map.of("longYoungGCPause", List.of(1000.0)) + )); + reply("memoryStatistics", request -> Map.of( + "heap", Map.of( + "capacityAvg", 1024L * 1024L * 1024L, + "usedMax", 900L * 1024L * 1024L, + "usedAvgAfterFullGC", 850L * 1024L * 1024L + ), + "old", Map.of( + "capacityAvg", 700L * 1024L * 1024L, + "usedMax", 600L * 1024L * 1024L, + "usedAvgAfterFullGC", 590L * 1024L * 1024L + ), + "metaspace", Map.of( + "capacityAvg", 200L * 1024L * 1024L, + "usedMax", 170L * 1024L * 1024L, + "usedAvgAfterFullGC", 165L * 1024L * 1024L + ) + )); + reply("objectStatistics", request -> Map.of( + "objectCreationSpeed", 2048.0, + "objectPromotionSpeed", 1024.0, + "objectPromotionAvg", 4096L, + "objectPromotionMax", 8192L + )); + } + + private void registerThreadDumpResponses() { + reply("overview", request -> Map.of( + "threadStat", Map.of("counts", List.of(3, 2, 1)), + "javaThreadStat", Map.of( + "javaCounts", List.of(0, 2, 0, 0, 0, 0, 0, 1, 0, 0), + "daemonCount", 1 + ), + "deadLockCount", 1, + "errorCount", 0 + )); + reply("threads", request -> new PageView<>(1, 20, 3, List.of( + Map.of("id", 101, "name", "main"), + Map.of("id", 102, "name", "worker") + ))); + reply("monitors", request -> new PageView<>(1, 20, 1, List.of( + Map.of("id", 11, "clazz", "java.lang.Object", "address", 42L) + ))); + reply("threadCountsByMonitor", request -> Map.of( + "LOCKED", 1, + "WAITING_TO_LOCK", 1, + "WAITING_ON", 1 + )); + reply("threadsByMonitor", request -> { + String state = request.parameters().get("state").getAsString(); + return switch (state) { + case "LOCKED" -> new PageView<>(1, 10, 1, List.of(Map.of("id", 101, "name", "main"))); + case "WAITING_TO_LOCK" -> new PageView<>(1, 10, 1, List.of(Map.of("id", 102, "name", "worker"))); + case "WAITING_ON" -> new PageView<>(1, 10, 1, List.of(Map.of("id", 103, "name", "waiter"))); + default -> new PageView<>(1, 10, 0, List.of()); + }; + }); + reply("rawContentOfThread", request -> List.of( + "\"main\" #101 prio=5 os_prio=31 tid=0x1 nid=0x2 waiting on condition", + " java.lang.Thread.State: RUNNABLE" + )); + } + + private void registerHeapDumpResponses() { + reply("details", request -> Map.of( + "usedHeapSize", 2L * 1024L * 1024L * 1024L, + "numberOfObjects", 12345, + "numberOfClasses", 321, + "numberOfClassLoaders", 12 + )); + reply("biggestObjects", request -> List.of( + Map.of("label", "com.example.Cache", "value", 12.5, "description", "Retained by cache root"), + Map.of("label", "byte[]", "value", 7.2, "description", "Large buffer") + )); + reply("classLoaderExplorer.summary", request -> Map.of( + "totalSize", 12, + "definedClasses", 300, + "numberOfInstances", 1000 + )); + reply("histogram", request -> new PageView<>(1, 20, 2, List.of( + Map.of( + "label", "com.example.Cache", + "retainedSize", 900L * 1024L * 1024L, + "numberOfObjects", 20 + ), + Map.of( + "label", "byte[]", + "retainedSize", 300L * 1024L * 1024L, + "numberOfObjects", 50 + ) + ))); + reply("threadsSummary", request -> Map.of( + "totalSize", 2, + "shallowHeap", 1024L, + "retainedHeap", 20L * 1024L * 1024L + )); + reply("threads", request -> new PageView<>(1, 10, 2, List.of( + Map.of( + "objectId", 2001, + "name", "main", + "retainedSize", 12L * 1024L * 1024L, + "contextClassLoader", "app-loader", + "hasStack", true + ), + Map.of( + "objectId", 2002, + "name", "worker", + "retainedSize", 8L * 1024L * 1024L, + "contextClassLoader", "app-loader", + "hasStack", true + ) + ))); + reply("stackTrace", request -> List.of( + Map.of("stack", "com.example.Work.run(Work.java:10)", "hasLocal", true), + Map.of("stack", "java.lang.Thread.run(Thread.java:840)", "hasLocal", false) + )); + } + + private void reply(String api, Function resolver) { + analysisReplies.put(api, resolver); + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpRealIntegration.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpRealIntegration.java new file mode 100644 index 00000000..838f4396 --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpRealIntegration.java @@ -0,0 +1,182 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.commons.io.FileUtils; +import org.eclipse.jifa.server.enums.FileType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.IOException; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + properties = { + "jifa.role=standalone-worker", + "jifa.mcp-enabled=true", + "jifa.allow-login=false", + "jifa.allow-anonymous-access=true", + "jifa.database-host=", + "spring.jpa.hibernate.ddl-auto=create-drop" + } +) +public class TestJifaMcpRealIntegration { + + private static final int TEST_PORT = findAvailablePort(); + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private static final TypeReference> LIST_TYPE = new TypeReference<>() {}; + + private static final Path TEST_STORAGE = createTempDirectory("jifa-mcp-real-storage"); + + private static final Path GC_LOG = createGcLogFile(); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("jifa.port", () -> TEST_PORT); + registry.add("jifa.storage-path", () -> TEST_STORAGE.toString()); + registry.add("jifa.input-files[0]", () -> GC_LOG.toString()); + } + + @AfterAll + static void cleanUp() throws IOException { + FileUtils.deleteDirectory(TEST_STORAGE.toFile()); + Files.deleteIfExists(GC_LOG); + } + + @Test + public void testListMyFilesUsesRealFileService() { + try (McpSyncClient client = initializeClient()) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "list_my_files", + Map.of("type", FileType.GC_LOG.getApiNamespace()) + )); + Map structured = asMap(result.structuredContent()); + List files = asList(structured.get("files")); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("totalSize")).isEqualTo(1); + assertThat(files).hasSize(1); + assertThat(asMap(files.get(0)).get("typeId")).isEqualTo(FileType.GC_LOG.getApiNamespace()); + } + } + + @Test + public void testAnalyzeGcLogSummaryUsesRealAnalysisApi() { + try (McpSyncClient client = initializeClient()) { + String uniqueName = findImportedGcLogUniqueName(client); + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "analyze_gc_log_summary", + Map.of("file", uniqueName) + )); + Map structured = asMap(result.structuredContent()); + + assertThat(result.isError()).isEqualTo(false); + assertThat(structured.get("summary").toString()).contains("GC log summary"); + assertThat(asMap(structured.get("evidence")).get("metadata")).isNotNull(); + } + } + + private String findImportedGcLogUniqueName(McpSyncClient client) { + for (int attempt = 0; attempt < 20; attempt++) { + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest( + "list_my_files", + Map.of("type", FileType.GC_LOG.getApiNamespace()) + )); + List files = asList(asMap(result.structuredContent()).get("files")); + if (!files.isEmpty()) { + return asMap(files.get(0)).get("uniqueName").toString(); + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for imported GC log", e); + } + } + throw new IllegalStateException("GC log imported by input-files was not visible to MCP"); + } + + private McpSyncClient initializeClient() { + McpSyncClient client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + TEST_PORT) + .endpoint("/jifa-api/mcp") + .build()) + .build(); + client.initialize(); + return client; + } + + private Map asMap(Object value) { + return objectMapper.convertValue(value, MAP_TYPE); + } + + private List asList(Object value) { + return objectMapper.convertValue(value, LIST_TYPE); + } + + private static int findAvailablePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Failed to allocate a test port", e); + } + } + + private static Path createTempDirectory(String prefix) { + try { + return Files.createTempDirectory(prefix); + } catch (IOException e) { + throw new IllegalStateException("Failed to create temp directory", e); + } + } + + private static Path createGcLogFile() { + try { + Path path = Files.createTempFile("jifa-mcp-real-gc", ".log"); + Files.writeString(path, """ + OpenJDK 64-Bit Server VM (25.472-b08) for bsd-aarch64 JRE (1.8.0_472-b08) + CommandLine flags: -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+UseParallelGC + 2026-03-23T18:46:22.549+0800: 13.372: [GC (Metadata GC Threshold) [PSYoungGen: 88829K->12434K(114688K)] 88829K->12523K(376832K), 0.0049924 secs] [Times: user=0.01 sys=0.01, real=0.01 secs] + 2026-03-23T18:46:22.554+0800: 13.377: [Full GC (Metadata GC Threshold) [PSYoungGen: 12434K->0K(114688K)] [ParOldGen: 88K->11844K(110592K)] 12523K->11844K(225280K), [Metaspace: 20756K->20756K(1069056K)], 0.0122272 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] + 2026-03-23T18:46:27.500+0800: 18.323: [GC (Allocation Failure) [PSYoungGen: 98304K->14423K(114688K)] 110148K->26276K(225280K), 0.0125330 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] + Heap + PSYoungGen total 114688K, used 45458K [0x0000000535000000, 0x000000053d000000, 0x00000005b5000000) + eden space 98304K, 31% used [0x0000000535000000,0x0000000536e4e850,0x000000053b000000) + ParOldGen total 110592K, used 11852K [0x0000000435000000, 0x000000043bc00000, 0x0000000535000000) + Metaspace used 33225K, capacity 33940K, committed 34048K, reserved 1079296K + """); + return path; + } catch (IOException e) { + throw new IllegalStateException("Failed to create test GC log", e); + } + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpThreadDumpToolService.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpThreadDumpToolService.java new file mode 100644 index 00000000..c513aa87 --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestJifaMcpThreadDumpToolService.java @@ -0,0 +1,85 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import org.eclipse.jifa.server.mcp.dto.McpAnalysisSummary; +import org.eclipse.jifa.server.mcp.dto.McpThreadContentResult; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestJifaMcpThreadDumpToolService extends JifaMcpToolServiceTestSupport { + + @Test + public void testAnalyzeThreadDumpSummary() { + registerThreadDumpResponses(); + + McpAnalysisSummary result = threadDumpToolService.analyzeThreadDumpSummary(THREAD_FILE.uniqueName()); + + assertThat(result.summary()).contains("deadlocks=1"); + assertThat(result.findings()).extracting(f -> f.title()) + .contains("Deadlock detected", "Blocked Java threads detected"); + } + + @Test + public void testAnalyzeThreadDumpDetails() { + registerThreadDumpResponses(); + + McpAnalysisSummary result = threadDumpToolService.analyzeThreadDumpDetails(THREAD_FILE.uniqueName()); + + assertThat(result.summary()).contains("sampled 2 thread(s)"); + assertThat(result.evidence()).containsKeys("sampleThreads", "sampleMonitors"); + } + + @Test + public void testAnalyzeThreadDumpBlockingChains() { + registerThreadDumpResponses(); + + McpAnalysisSummary result = threadDumpToolService.analyzeThreadDumpBlockingChains(THREAD_FILE.uniqueName()); + + assertThat(result.summary()).contains("detected 1 monitor"); + assertThat(result.findings()).extracting(f -> f.title()) + .contains("Blocking chain on java.lang.Object@0x2a"); + } + + @Test + public void testGetThreadDumpThreadContent() { + registerThreadDumpResponses(); + + McpThreadContentResult result = threadDumpToolService.getThreadDumpThreadContent(THREAD_FILE.uniqueName(), 101); + + assertThat(result.threadId()).isEqualTo(101); + assertThat(result.lineCount()).isEqualTo(2); + assertThat(result.content()).contains("java.lang.Thread.State"); + } + + @Test + public void testAnalyzeThreadDumpDetailsWithWorkerStyleJsonBytes() { + registerThreadDumpResponsesAsJsonBytes(); + + McpAnalysisSummary result = threadDumpToolService.analyzeThreadDumpDetails(THREAD_FILE.uniqueName()); + + assertThat(result.summary()).contains("sampled 2 thread(s)"); + assertThat(result.evidence()).containsKeys("sampleThreads", "sampleMonitors"); + } + + @Test + public void testGetThreadDumpThreadContentWithWorkerStyleJsonBytes() { + registerThreadDumpResponsesAsJsonBytes(); + + McpThreadContentResult result = threadDumpToolService.getThreadDumpThreadContent(THREAD_FILE.uniqueName(), 101); + + assertThat(result.threadId()).isEqualTo(101); + assertThat(result.content()).contains("RUNNABLE"); + } +} diff --git a/server/src/test/java/org/eclipse/jifa/server/mcp/TestMcpConfigurerCondition.java b/server/src/test/java/org/eclipse/jifa/server/mcp/TestMcpConfigurerCondition.java new file mode 100644 index 00000000..2babdd12 --- /dev/null +++ b/server/src/test/java/org/eclipse/jifa/server/mcp/TestMcpConfigurerCondition.java @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.jifa.server.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import org.eclipse.jifa.server.configurer.McpConfigurer; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestMcpConfigurerCondition { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(McpConfigurer.class) + .withBean(ObjectMapper.class, ObjectMapper::new) + .withBean(JifaMcpToolRegistry.class, () -> { + JifaMcpToolRegistry registry = Mockito.mock(JifaMcpToolRegistry.class); + Mockito.when(registry.definitions()).thenReturn(List.of()); + return registry; + }); + + @Test + public void testMcpBeansEnabledForStandaloneWorker() { + contextRunner.withPropertyValues("jifa.mcp-enabled=true", "jifa.role=STANDALONE_WORKER") + .run(context -> { + assertThat(context).hasSingleBean(HttpServletStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(McpSyncServer.class); + }); + } + + @Test + public void testMcpBeansEnabledForMaster() { + contextRunner.withPropertyValues("jifa.mcp-enabled=true", "jifa.role=MASTER") + .run(context -> { + assertThat(context).hasSingleBean(HttpServletStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(McpSyncServer.class); + }); + } + + @Test + public void testMcpBeansDisabledForStaticWorker() { + contextRunner.withPropertyValues("jifa.mcp-enabled=true", "jifa.role=STATIC_WORKER") + .run(context -> { + assertThat(context).doesNotHaveBean(HttpServletStreamableServerTransportProvider.class); + assertThat(context).doesNotHaveBean(McpSyncServer.class); + }); + } +} diff --git a/site/docs/.vitepress/config.mts b/site/docs/.vitepress/config.mts index 0c516549..eb4d0d95 100644 --- a/site/docs/.vitepress/config.mts +++ b/site/docs/.vitepress/config.mts @@ -71,6 +71,12 @@ export default defineConfig({ {text: 'Configuration', link: '/guide/configuration.md'}, ] }, + { + text: 'Integrations', + items: [ + {text: 'MCP (Experimental)', link: '/guide/mcp'}, + ] + }, { items: [ {text: 'Changelog', link: '/guide/changelog.md'}, @@ -145,6 +151,12 @@ export default defineConfig({ {text: '配置', link: '/zh/guide/configuration.md'}, ] }, + { + text: '集成', + items: [ + {text: 'MCP(实验性)', link: '/zh/guide/mcp'}, + ] + }, { items: [ {text: '更新日志', link: '/zh/guide/changelog.md'}, diff --git a/site/docs/guide/configuration.md b/site/docs/guide/configuration.md index 9ecc62ef..699ab5e9 100644 --- a/site/docs/guide/configuration.md +++ b/site/docs/guide/configuration.md @@ -179,6 +179,18 @@ Use this option if you intend to customize JIFA with an alternative authenticati Default: true +## mcp-enabled + +Whether to enable the experimental MCP endpoint. + +Type: boolean + +Default: false + +When enabled, the MCP endpoint is exposed at `/jifa-api/mcp` on the same server and port as the existing Jifa application. + +This endpoint is only created on `STANDALONE_WORKER` and `MASTER` nodes. + ## input-files Local files to be analyzed, used only in `STANDALONE_WORKER` role. diff --git a/site/docs/guide/mcp.md b/site/docs/guide/mcp.md new file mode 100644 index 00000000..61b8a15c --- /dev/null +++ b/site/docs/guide/mcp.md @@ -0,0 +1,182 @@ +# MCP + +This page describes the experimental MCP endpoint in Eclipse Jifa. + +Jifa provides an experimental [Model Context Protocol](https://modelcontextprotocol.io/) endpoint for read-only access to files and analysis results under the corresponding authentication and authorization model. + +## Overview + +- Endpoint: `/jifa-api/mcp` +- Deployment model: same server, same port, same application +- Supported node roles: `STANDALONE_WORKER` and `MASTER` +- Scope: high-level tools for files, GC logs, thread dumps, and heap dumps + +## Enable MCP + +Enable the experimental endpoint in your server configuration: + +```yaml +jifa: + mcp-enabled: true +``` + +The endpoint address is: + +```text +http://:/jifa-api/mcp +``` + +## Authentication and Authorization + +The MCP endpoint reuses the existing Jifa authentication and authorization model. + +- If anonymous access is enabled, MCP calls can be made without a Bearer token. +- If login is enabled, send `Authorization: Bearer ` with each MCP request. +- File access checks are still enforced, so MCP can only analyze files visible to the current user. + +## Client Configuration + +Different MCP clients use slightly different configuration formats, but the transport is the same: streamable HTTP pointing to the Jifa MCP endpoint. + +Example without authentication: + +```json +{ + "mcpServers": { + "jifa": { + "type": "streamable-http", + "url": "http://127.0.0.1:8102/jifa-api/mcp" + } + } +} +``` + +Example with bearer authentication: + +```json +{ + "mcpServers": { + "jifa": { + "type": "streamable-http", + "url": "http://127.0.0.1:8102/jifa-api/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +## Available Tools + +### File tools + +- `list_my_files(type?, page?, pageSize?)` +- `get_file_info` +- `analyze_file_summary` + +### GC log tools + +- `analyze_gc_log_summary` +- `analyze_gc_log_metrics` + +### Thread dump tools + +- `analyze_thread_dump_summary` +- `analyze_thread_dump_details` +- `analyze_thread_dump_blocking_chains` +- `get_thread_dump_thread_content` + +### Heap dump tools + +- `analyze_heap_dump_summary` +- `analyze_heap_dump_hotspots` +- `analyze_heap_dump_thread_details` + +## Typical Workflow + +The recommended MCP workflow is: + +1. Call `list_my_files` to discover available analysis files. Use `page` and `pageSize` when the file list is large. The maximum `pageSize` is `100`. +2. Call `get_file_info` to confirm the file type and supported tools. +3. Choose a matching high-level analysis tool such as `analyze_gc_log_summary` or `analyze_heap_dump_summary`. +4. Use the structured result to summarize findings, and open the Jifa Web UI when deeper drill-down is needed. + +## Result Shape + +Most high-level analysis tools return AI-friendly structured fields such as: + +- `summary` +- `findings` +- `evidence` +- `recommendations` + +`list_my_files` also returns pagination fields such as: + +- `page` +- `pageSize` +- `totalSize` +- `returnedSize` + +When a tool call fails, the structured result may also include: + +- `error` +- `errorCode` + +For example, insufficient permissions may return `ACCESS_DENIED`, and missing files return `FILE_NOT_FOUND`. + +## Calling MCP Directly + +When calling the endpoint directly over HTTP, use JSON-RPC and include both `application/json` and `text/event-stream` in the `Accept` header. + +Because this endpoint uses streamable HTTP, the `initialize` response returns an `mcp-session-id` header. Reuse that header on later requests. Per the MCP protocol, later requests after initialization should also include the `MCP-Protocol-Version` header. In practice, it is still recommended to use an MCP client SDK so initialization, session handling, and protocol headers are managed automatically. + +Example `initialize` request: + +```bash +curl -i -X POST http://127.0.0.1:8102/jifa-api/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { + "name": "example-client", + "version": "1.0.0" + } + } + }' +``` + +Example `tools/call` request with bearer authentication after initialization: + +```bash +curl -X POST http://127.0.0.1:8102/jifa-api/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'mcp-session-id: ' \ + -H 'Authorization: Bearer ' \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "tools/call", + "params": { + "name": "analyze_gc_log_summary", + "arguments": { + "file": "" + } + } + }' +``` + +## Limitations + +- The endpoint is only created on `STANDALONE_WORKER` and `MASTER` nodes. +- File upload and download are still handled through the existing Jifa Web / HTTP APIs. +- JFR files may appear in file listings, but JFR-specific MCP tools are not exposed in the current experimental scope. +- `list_my_files` is intentionally paginated and does not expose the entire file list in a single response. +- The current experimental version does not include API keys or PATs. diff --git a/site/docs/zh/guide/configuration.md b/site/docs/zh/guide/configuration.md index 32856cf7..e69acb54 100644 --- a/site/docs/zh/guide/configuration.md +++ b/site/docs/zh/guide/configuration.md @@ -177,6 +177,18 @@ Default: false 默认值: true +## mcp-enabled + +是否启用实验性的 MCP 端点。 + +类型:boolean + +默认值:false + +启用后,MCP 端点会以与现有 Jifa 应用相同的服务和端口对外暴露,路径为 `/jifa-api/mcp`。 + +当前该端点只会在 `STANDALONE_WORKER` 和 `MASTER` 节点上启用。 + ## input-files 本地的待分析文件,仅在 `STANDALONE_WORKER` 角色中使用。 diff --git a/site/docs/zh/guide/mcp.md b/site/docs/zh/guide/mcp.md new file mode 100644 index 00000000..f9a2d230 --- /dev/null +++ b/site/docs/zh/guide/mcp.md @@ -0,0 +1,182 @@ +# MCP + +本页介绍 Eclipse Jifa 中实验性的 MCP 端点。 + +Jifa 提供了一个实验性的 [Model Context Protocol](https://modelcontextprotocol.io/) 端点,用于在对应认证与授权模式下,以只读方式访问文件及分析结果。 + +## 概览 + +- 端点:`/jifa-api/mcp` +- 部署方式:同一个 server、同一个端口、同一个应用 +- 支持的节点角色:`STANDALONE_WORKER` 和 `MASTER` +- 当前范围:文件、GC 日志、线程快照、堆快照的高层分析工具 + +## 启用 MCP + +在服务端配置中启用实验性 MCP 端点: + +```yaml +jifa: + mcp-enabled: true +``` + +端点地址为: + +```text +http://:/jifa-api/mcp +``` + +## 认证与授权 + +MCP 端点复用了当前 Jifa 的认证和授权体系。 + +- 如果开启了匿名访问,则可以不带 Bearer Token 调用 MCP。 +- 如果启用了登录,则需要在每次请求中传递 `Authorization: Bearer `。 +- 文件访问权限仍然会被校验,因此 MCP 只能分析当前用户有权限访问的文件。 + +## 客户端配置 + +不同 MCP 客户端的配置格式会略有不同,但底层传输方式相同,都是指向 Jifa MCP 端点的 streamable HTTP。 + +无认证示例: + +```json +{ + "mcpServers": { + "jifa": { + "type": "streamable-http", + "url": "http://127.0.0.1:8102/jifa-api/mcp" + } + } +} +``` + +带 Bearer 认证示例: + +```json +{ + "mcpServers": { + "jifa": { + "type": "streamable-http", + "url": "http://127.0.0.1:8102/jifa-api/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +## 当前工具 + +### 文件工具 + +- `list_my_files(type?, page?, pageSize?)` +- `get_file_info` +- `analyze_file_summary` + +### GC 日志工具 + +- `analyze_gc_log_summary` +- `analyze_gc_log_metrics` + +### 线程快照工具 + +- `analyze_thread_dump_summary` +- `analyze_thread_dump_details` +- `analyze_thread_dump_blocking_chains` +- `get_thread_dump_thread_content` + +### 堆快照工具 + +- `analyze_heap_dump_summary` +- `analyze_heap_dump_hotspots` +- `analyze_heap_dump_thread_details` + +## 典型工作流 + +推荐的 MCP 使用方式如下: + +1. 先调用 `list_my_files` 发现当前可分析文件。文件较多时可使用 `page` 和 `pageSize` 分页,`pageSize` 最大为 `100`。 +2. 再调用 `get_file_info` 确认文件类型和支持的工具。 +3. 根据文件类型选择高层分析工具,例如 `analyze_gc_log_summary` 或 `analyze_heap_dump_summary`。 +4. 使用结构化结果生成总结;如果需要更细粒度的钻取分析,再回到 Jifa Web UI。 + +## 返回结果结构 + +大多数高层分析工具都会返回适合 AI 处理的结构化字段,例如: + +- `summary` +- `findings` +- `evidence` +- `recommendations` + +其中 `list_my_files` 还会返回分页字段,例如: + +- `page` +- `pageSize` +- `totalSize` +- `returnedSize` + +如果工具调用失败,结构化结果中还可能包含: + +- `error` +- `errorCode` + +例如权限不足时可能返回 `ACCESS_DENIED`,文件不存在时返回 `FILE_NOT_FOUND`。 + +## 直接调用 MCP + +如果直接通过 HTTP 调用该端点,需要使用 JSON-RPC,并且在 `Accept` 头中同时带上 `application/json` 和 `text/event-stream`。 + +由于当前端点使用的是 streamable HTTP,`initialize` 响应会返回 `mcp-session-id` 头。后续请求需要复用这个 header。根据 MCP 协议,初始化完成后的后续请求还应带上 `MCP-Protocol-Version` 头。实际使用时更推荐直接使用 MCP 客户端 SDK,让它自动处理初始化、会话和协议头。 + +下面是一个 `initialize` 请求示例: + +```bash +curl -i -X POST http://127.0.0.1:8102/jifa-api/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { + "name": "example-client", + "version": "1.0.0" + } + } + }' +``` + +下面是一个初始化完成之后、带 Bearer 认证的 `tools/call` 请求示例: + +```bash +curl -X POST http://127.0.0.1:8102/jifa-api/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'mcp-session-id: ' \ + -H 'Authorization: Bearer ' \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "tools/call", + "params": { + "name": "analyze_gc_log_summary", + "arguments": { + "file": "" + } + } + }' +``` + +## 当前限制 + +- 当前端点只会在 `STANDALONE_WORKER` 和 `MASTER` 节点上启用。 +- 文件上传和下载仍然通过现有的 Web / HTTP API 处理。 +- 文件列表中可能会出现 JFR 文件,但当前实验版本还未支持 JFR 的 MCP 功能。 +- `list_my_files` 会刻意分页返回,而不是一次性暴露完整文件列表。 +- 当前实验版本未包含 API Key / PAT 功能。