Feature/mcp (#846)

* Added MCP support using SSE on http://localhost:8080/sse

* Reverted change that IntelliJ complains about

* Pre-rework

* Cleaned up the code a fair bit

* Renamed

* Renamed

* Running spotless

* Reuse FhirContext in result serialization to make MCP server work with R5

* Added support for transactions

* PoC tool for CDS Hooks

* some cleanup

* Upgrade of model protocol

* Added comments

* Removed field injection ... CDS to be changed to AutoConfig eventually

* Adjusted to new builder pattern

* Update src/main/java/ca/uhn/fhir/rest/server/MCPBridge.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* A bit of restructuring

* More rework

* Removing (suspected unnecessary) formatting

* Add more example doc

* Added a smoke- / passthrough-test

* Applied spotless

* Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Formatting

* Added some documentation

* spotless cares about MD?

* Reverting back to default values

* minor refinements

* Fixed CDS hooks configuration

* Fixed some wirings

* Revert "Fixed some wirings"

This reverts commit c9d3bc0b3b6756d7b15f5d2cf6100c99784fb868.

* Revert "Fixed CDS hooks configuration"

This reverts commit 67c4279100bf14432c164906235ea6348ee8af22.

---------

Co-authored-by: Ádám Z. Kövér <adamzkover@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jens Kristian Villadsen
2025-09-17 06:51:19 +02:00
committed by GitHub
parent 5585170c7d
commit 680255ff62
18 changed files with 1080 additions and 2 deletions

View File

@@ -0,0 +1,9 @@
package ca.uhn.fhir.rest.server;
import io.modelcontextprotocol.server.McpServerFeatures;
import java.util.List;
public interface McpBridge {
List<McpServerFeatures.SyncToolSpecification> generateTools();
}

View File

@@ -0,0 +1,117 @@
package ca.uhn.fhir.rest.server;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.starter.cdshooks.CdsHooksRequest;
import ca.uhn.fhir.jpa.starter.mcp.Interaction;
import ca.uhn.fhir.jpa.starter.mcp.ToolFactory;
import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestContextJson;
import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
public class McpCdsBridge implements McpBridge {
private static final Logger logger = LoggerFactory.getLogger(McpCdsBridge.class);
private final ICdsServiceRegistry cdsServiceRegistry;
private final ObjectMapper objectMapper;
private final FhirContext fhirContext;
public McpCdsBridge(FhirContext fhirContext, ICdsServiceRegistry cdsServiceRegistry, ObjectMapper objectMapper) {
this.fhirContext = fhirContext;
this.cdsServiceRegistry = cdsServiceRegistry;
this.objectMapper = objectMapper;
}
public List<McpServerFeatures.SyncToolSpecification> generateTools() {
try {
return List.of(new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.callCdsHook())
.callHandler((exchange, request) -> getToolResult(request, Interaction.CALL_CDS_HOOK))
.build());
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private McpSchema.CallToolResult getToolResult(McpSchema.CallToolRequest contextMap, Interaction interaction) {
if (interaction != Interaction.CALL_CDS_HOOK)
throw new UnsupportedOperationException("Unsupported interaction: " + interaction);
var cdsInvocation = constructCdsHooksRequest(contextMap);
var serviceResponseJson = cdsServiceRegistry.callService(
contextMap.arguments().get("service").toString(), cdsInvocation);
final String content;
try {
content = objectMapper.writeValueAsString(serviceResponseJson);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return McpSchema.CallToolResult.builder()
.addContent(new McpSchema.TextContent(content))
.build();
}
private @NotNull CdsHooksRequest constructCdsHooksRequest(McpSchema.CallToolRequest callToolRequest) {
// TODO Build up CDS Hooks request JSON from contextMap
var contextMap = callToolRequest.arguments();
var request = new CdsHooksRequest();
request.setHook(contextMap.get("hook").toString());
request.setHookInstance(contextMap.get("hookInstance").toString());
// Context
var context = new CdsServiceRequestContextJson();
Object hookContextObj = contextMap.get("hookContext");
if (hookContextObj instanceof Map<?, ?> hookContext) {
if (hookContext.containsKey("userId")) {
context.put("userId", String.valueOf(hookContext.get("userId")));
}
if (hookContext.containsKey("patientId")) {
context.put("patientId", String.valueOf(hookContext.get("patientId")));
}
if (hookContext.containsKey("encounterId")) {
context.put("encounterId", String.valueOf(hookContext.get("encounterId")));
}
}
request.setContext(context);
// Prefetch
if (contextMap.containsKey("prefetch")) {
var prefetch = contextMap.get("prefetch");
if (prefetch instanceof Map) {
@SuppressWarnings("unchecked")
var prefetchMap = (Map<String, Object>) prefetch;
for (Map.Entry<String, Object> entry : prefetchMap.entrySet()) {
var key = entry.getKey();
var value = entry.getValue();
// Object is a String -> Object map
// Use a standard JSON library to convert it
var resource = fhirContext.newJsonParser().parseResource(new Gson().toJson(value));
request.addPrefetch(key, resource);
}
} else {
logger.warn(
"Prefetch object is not a Map: {}",
prefetch == null ? "null" : prefetch.getClass().getName());
}
}
return request;
}
}

View File

@@ -0,0 +1,101 @@
package ca.uhn.fhir.rest.server;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.starter.mcp.CallToolResultFactory;
import ca.uhn.fhir.jpa.starter.mcp.Interaction;
import ca.uhn.fhir.jpa.starter.mcp.RequestBuilder;
import ca.uhn.fhir.jpa.starter.mcp.ToolFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class McpFhirBridge implements McpBridge {
private static final Logger logger = LoggerFactory.getLogger(McpFhirBridge.class);
private final RestfulServer restfulServer;
private final FhirContext fhirContext;
public McpFhirBridge(RestfulServer restfulServer) {
this.restfulServer = restfulServer;
this.fhirContext = restfulServer.getFhirContext();
}
public List<McpServerFeatures.SyncToolSpecification> generateTools() {
try {
return List.of(
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.createFhirResource())
.callHandler((exchange, request) -> getToolResult(request, Interaction.CREATE))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.readFhirResource())
.callHandler((exchange, request) -> getToolResult(request, Interaction.READ))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.updateFhirResource())
.callHandler((exchange, request) -> getToolResult(request, Interaction.UPDATE))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.deleteFhirResource())
.callHandler((exchange, request) -> getToolResult(request, Interaction.DELETE))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.conditionalPatchFhirResource())
.callHandler((exchange, request) -> getToolResult(request, Interaction.PATCH))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.searchFhirResources())
.callHandler((exchange, request) -> getToolResult(request, Interaction.SEARCH))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.conditionalUpdateFhirResource())
.callHandler((exchange, request) -> getToolResult(request, Interaction.UPDATE))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.patchFhirResource())
.callHandler((exchange, request) -> getToolResult(request, Interaction.PATCH))
.build(),
new McpServerFeatures.SyncToolSpecification.Builder()
.tool(ToolFactory.createFhirTransaction())
.callHandler((exchange, request) -> getToolResult(request, Interaction.TRANSACTION))
.build());
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private McpSchema.CallToolResult getToolResult(McpSchema.CallToolRequest contextMap, Interaction interaction) {
var response = new MockHttpServletResponse();
var request = new RequestBuilder(fhirContext, contextMap.arguments(), interaction).buildRequest();
try {
restfulServer.handleRequest(interaction.asRequestType(), request, response);
var status = response.getStatus();
var body = response.getContentAsString();
if (status >= 200 && status < 300) {
if (body.isBlank()) {
return CallToolResultFactory.failure("Empty successful response for " + interaction);
}
return CallToolResultFactory.success(
contextMap.arguments().get("resourceType").toString(), interaction, body, status);
} else {
return CallToolResultFactory.failure(String.format("FHIR server error %d: %s", status, body));
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
return CallToolResultFactory.failure("Unexpected error: " + e.getMessage());
}
}
}