Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions server/server.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions server/src/main/java/org/eclipse/jifa/server/Constant.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object>> 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<HttpServletStreamableServerTransportProvider> jifaMcpServletRegistration(
HttpServletStreamableServerTransportProvider transport) {
ServletRegistrationBean<HttpServletStreamableServerTransportProvider> 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<McpServerFeatures.SyncToolSpecification> 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> T withAuthentication(McpTransportContext transportContext, Supplier<T> 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<String, Object> errorContent = errorStructuredContent(e);
return McpSchema.CallToolResult.builder()
.structuredContent(errorContent)
.addTextContent("Tool execution failed: " + errorContent.get("error"))
.isError(true)
.build();
}
}));
}

private Map<String, Object> 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<String, Object> errorStructuredContent(Throwable throwable) {
LinkedHashMap<String, Object> 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";
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object>> MAP_TYPE = new TypeReference<>() {};

private static final TypeReference<List<Map<String, Object>>> LIST_OF_MAPS_TYPE = new TypeReference<>() {};

private static final TypeReference<List<String>> 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<String, Object> 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<String, Object> invokeAnalysisAsMap(FileType fileType,
String uniqueName,
String api,
Map<String, Object> parameters) {
Object result = invokeAnalysisRaw(fileType, uniqueName, api, parameters);
return result == null ? Map.of() : objectMapper.convertValue(result, MAP_TYPE);
}

List<Map<String, Object>> invokeAnalysisAsListOfMaps(FileType fileType,
String uniqueName,
String api,
Map<String, Object> parameters) {
Object result = invokeAnalysisRaw(fileType, uniqueName, api, parameters);
return result == null ? List.of() : objectMapper.convertValue(result, LIST_OF_MAPS_TYPE);
}

List<String> invokeAnalysisAsListOfStrings(FileType fileType,
String uniqueName,
String api,
Map<String, Object> 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);
}
}
}
Loading
Loading