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:
committed by
GitHub
parent
5585170c7d
commit
680255ff62
9
src/main/java/ca/uhn/fhir/rest/server/McpBridge.java
Normal file
9
src/main/java/ca/uhn/fhir/rest/server/McpBridge.java
Normal 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();
|
||||
}
|
||||
117
src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java
Normal file
117
src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/main/java/ca/uhn/fhir/rest/server/McpFhirBridge.java
Normal file
101
src/main/java/ca/uhn/fhir/rest/server/McpFhirBridge.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user