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

@@ -21,9 +21,9 @@ import java.util.Set;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
@EnableConfigurationProperties
@ConfigurationProperties(prefix = "hapi.fhir")
@Configuration
@EnableConfigurationProperties
public class AppProperties {
private final Set<String> auto_version_reference_at_paths = new HashSet<>();

View File

@@ -0,0 +1,40 @@
package ca.uhn.fhir.jpa.starter.mcp;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
public class CallToolResultFactory {
public static McpSchema.CallToolResult success(
String resourceType, Interaction interaction, String response, int status) {
Map<String, Object> payload = Map.of(
"resourceType", resourceType,
"interaction", interaction,
"response", response,
"status", status);
ObjectMapper objectMapper = new ObjectMapper();
String jacksonData;
try {
jacksonData = objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return McpSchema.CallToolResult.builder()
.addContent(new McpSchema.TextContent(jacksonData))
.build();
}
public static McpSchema.CallToolResult failure(String message) {
return McpSchema.CallToolResult.builder()
.isError(true)
.addTextContent(message)
.build();
}
}

View File

@@ -0,0 +1,34 @@
package ca.uhn.fhir.jpa.starter.mcp;
import ca.uhn.fhir.rest.api.RequestTypeEnum;
public enum Interaction {
CALL_CDS_HOOK("call-cds-hook"),
SEARCH("search"),
READ("read"),
CREATE("create"),
UPDATE("update"),
DELETE("delete"),
PATCH("patch"),
TRANSACTION("transaction");
private final String name;
Interaction(String name) {
this.name = name;
}
public String getName() {
return name;
}
public RequestTypeEnum asRequestType() {
return switch (this) {
case SEARCH, READ -> RequestTypeEnum.GET;
case CREATE, TRANSACTION, CALL_CDS_HOOK -> RequestTypeEnum.POST;
case UPDATE -> RequestTypeEnum.PUT;
case DELETE -> RequestTypeEnum.DELETE;
case PATCH -> RequestTypeEnum.PATCH;
};
}
}

View File

@@ -0,0 +1,81 @@
package ca.uhn.fhir.jpa.starter.mcp;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.server.McpBridge;
import ca.uhn.fhir.rest.server.McpCdsBridge;
import ca.uhn.fhir.rest.server.McpFhirBridge;
import ca.uhn.fhir.rest.server.RestfulServer;
import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry;
import ca.uhn.hapi.fhir.cdshooks.module.CdsHooksObjectMapperFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
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 java.util.List;
// https://mcp-cn.ssshooter.com/sdk/java/mcp-server#sse-servlet
// https://www.baeldung.com/spring-ai-model-context-protocol-mcp
// https://github.com/spring-projects/spring-ai-examples/blob/main/model-context-protocol/weather/manual-webflux-server/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java
// https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-stdio-server/src/main/java/org/springframework/ai/mcp/sample/server
// https://github.com/spring-projects/spring-ai-examples/blob/main/model-context-protocol/sampling/mcp-weather-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java
// https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html
@Configuration
@ConditionalOnProperty(
prefix = "spring.ai.mcp.server",
name = {"enabled"},
havingValue = "true")
public class McpServerConfig {
private static final String SSE_ENDPOINT = "/sse";
private static final String SSE_MESSAGE_ENDPOINT = "/mcp/message";
@Bean
public McpSyncServer syncServer(
List<McpBridge> mcpBridges, McpStreamableServerTransportProvider transportProvider) {
return McpServer.sync(transportProvider)
.tools(mcpBridges.stream()
.flatMap(bridge -> bridge.generateTools().stream())
.toList())
.build();
}
@Bean
public McpFhirBridge mcpFhirBridge(RestfulServer restfulServer) {
return new McpFhirBridge(restfulServer);
}
@Bean
@ConditionalOnProperty(
prefix = "hapi.fhir.cr",
name = {"enabled"},
havingValue = "true")
public McpCdsBridge mcpCdsBridge(FhirContext fhirContext, ICdsServiceRegistry cdsServiceRegistry) {
return new McpCdsBridge(
fhirContext, cdsServiceRegistry, new CdsHooksObjectMapperFactory(fhirContext).newMapper());
}
@Bean
public HttpServletStreamableServerTransportProvider servletSseServerTransportProvider(
/*McpServerProperties properties*/ ) {
return HttpServletStreamableServerTransportProvider.builder()
.disallowDelete(false)
.mcpEndpoint(SSE_MESSAGE_ENDPOINT)
.objectMapper(new ObjectMapper())
// .contextExtractor((serverRequest, context) -> context)
.build();
}
@Bean
public ServletRegistrationBean customServletBean(
HttpServletStreamableServerTransportProvider transportProvider /*, McpServerProperties properties*/) {
return new ServletRegistrationBean<>(transportProvider, SSE_MESSAGE_ENDPOINT, SSE_ENDPOINT);
}
}

View File

@@ -0,0 +1,120 @@
package ca.uhn.fhir.jpa.starter.mcp;
import ca.uhn.fhir.context.FhirContext;
import com.google.gson.Gson;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.springframework.mock.web.MockHttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public class RequestBuilder {
private final FhirContext fhirContext;
private final String resourceType;
private final Interaction interaction;
private final Map<String, Object> config;
/**
* Constructs a RequestBuilder for a specific FHIR interaction.
*
* @param fhirContext the FHIR context
* @param contextMap a map containing configuration parameters, including 'resourceType'
* @param interaction the type of interaction (e.g., SEARCH, READ, CREATE, etc.)
*/
public RequestBuilder(FhirContext fhirContext, Map<String, Object> contextMap, Interaction interaction) {
this.config = contextMap;
if (interaction == Interaction.TRANSACTION) this.resourceType = "";
else if (contextMap.get("resourceType") instanceof String rt && !rt.isBlank()) this.resourceType = rt;
else throw new IllegalArgumentException("Missing or invalid 'resourceType' in contextMap");
this.interaction = interaction;
this.fhirContext = fhirContext;
}
public MockHttpServletRequest buildRequest() {
String basePath = "/" + resourceType;
String method;
MockHttpServletRequest req;
switch (interaction) {
case SEARCH -> {
method = "GET";
req = new MockHttpServletRequest(method, basePath);
Map<?, ?> sp = null;
if (config.get("query") instanceof Map<?, ?> q) {
sp = q;
} else if (config.get("searchParams") instanceof Map<?, ?> s) {
sp = s;
}
if (sp != null) {
sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString()));
}
}
case READ -> {
method = "GET";
String id = requireString();
req = new MockHttpServletRequest(method, basePath + "/" + id);
}
case CREATE, TRANSACTION -> {
method = "POST";
req = new MockHttpServletRequest(method, basePath);
applyResourceBody(req);
}
case UPDATE -> {
method = "PUT";
String id = requireString();
req = new MockHttpServletRequest(method, basePath + "/" + id);
applyResourceBody(req);
}
case DELETE -> {
method = "DELETE";
String id = requireString();
req = new MockHttpServletRequest(method, basePath + "/" + id);
}
case PATCH -> {
method = "PATCH";
String id = requireString();
req = new MockHttpServletRequest(method, basePath + "/" + id);
applyPatchBody(req);
}
default -> throw new IllegalArgumentException("Unsupported interaction: " + interaction);
}
req.setContentType("application/fhir+json");
req.addHeader("Accept", "application/fhir+json");
return req;
}
private void applyResourceBody(MockHttpServletRequest req) {
Object resourceObj = config.get("resource");
String json;
if (resourceObj instanceof Map<?, ?>) json = new Gson().toJson(resourceObj, Map.class);
else if (resourceObj instanceof String) json = resourceObj.toString();
else throw new IllegalArgumentException("Unsupported resource body type: " + resourceObj.getClass());
req.setContent(json.getBytes(StandardCharsets.UTF_8));
}
private void applyPatchBody(MockHttpServletRequest req) {
Object patchBody = config.get("resource");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'resource' for patch interaction");
}
String content;
if (patchBody instanceof String s) {
content = s;
} else if (patchBody instanceof IBaseResource r) {
content = fhirContext.newJsonParser().encodeResourceToString(r);
} else {
throw new IllegalArgumentException("Unsupported patch body type: " + patchBody.getClass());
}
req.setContent(content.getBytes(StandardCharsets.UTF_8));
}
private String requireString() {
Object val = config.get("id");
if (!(val instanceof String s) || s.isBlank()) {
throw new IllegalArgumentException("Missing or invalid '" + "id" + "'");
}
return s;
}
}

View File

@@ -0,0 +1,337 @@
package ca.uhn.fhir.jpa.starter.mcp;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.Tool;
public class ToolFactory {
private static final String READ_FHIR_RESOURCE_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "type of the resource to read"
},
"id": {
"type": "string",
"description": "id of the resource to read"
}
}
}
""";
private static final String CREATE_FHIR_RESOURCE_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "Type of the resource to create"
},
"resource": {
"type": "object",
"description": "Resource content in JSON format"
},
"headers": {
"type": "object",
"description": "Headers for create request.\\nAvailable headers: If-None-Exist header for conditional create where the value is search param string.\\nFor example: {\\"If-None-Exist\\": \\"active=false\\"}"
}
},
"required": ["resourceType", "resource"]
}
""";
private static final String UPDATE_FHIR_RESOURCE_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "Type of the resource to update"
},
"id": {
"type": "string",
"description": "ID of the resource to update"
},
"resource": {
"type": "object",
"description": "Updated resource content in JSON format"
}
},
"required": ["resourceType", "id", "resource"]
}
""";
private static final String CONDITIONAL_UPDATE_FHIR_RESOURCE_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "Type of the resource to update"
},
"resource": {
"type": "object",
"description": "Updated resource content in JSON format"
},
"query": {
"type": "string",
"description": "Query string with search params separate by \\",\\". For example: \\"_id=pt-1,name=ivan\\". Uses for conditional update."
},
"headers": {
"type": "object",
"description": "Headers for create request.\\nAvailable headers: If-None-Match header for conditional update where the value is ETag.\\nFor example: {\\"If-None-Match\\": \\"12345\\"}"
}
},
"required": ["resourceType", "resource"]
}
""";
private static final String CONDITIONAL_PATCH_FHIR_RESOURCE_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "Type of the resource to patch"
},
"resource": {
"type": "object",
"description": "Resource content to patch in JSON format"
},
"query": {
"type": "string",
"description": "Query string with search params separate by \\",\\". For example: \\"_id=pt-1,name=ivan\\". Uses for conditional patch."
},
"headers": {
"type": "object",
"description": "Headers for create request.\\nAvailable headers: If-None-Match header for conditional patch where the value is ETag.\\nFor example: {\\"If-None-Match\\": \\"12345\\"}"
}
},
"required": ["resourceType", "resource"]
}
""";
private static final String PATCH_FHIR_RESOURCE_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "Type of the resource to patch"
},
"id": {
"type": "string",
"description": "ID of the resource to patch"
},
"resource": {
"type": "object",
"description": "Resource content to patch in JSON format"
}
},
"required": ["resourceType", "id", "resource"]
}
""";
private static final String DELETE_FHIR_RESOURCE_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "Type of the resource to delete"
},
"id": {
"type": "string",
"description": "ID of the resource to delete"
}
},
"required": ["resourceType", "id"]
}
""";
private static final String SEARCH_FHIR_RESOURCES_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "Type of the resource to search"
},
"query": {
"type": "string",
"description": "Query string with search params separate by \\",\\". For example: \\"_id=pt-1,name=ivan\\""
}
},
"required": ["resourceType", "query"]
}
""";
private static final String CREATE_FHIR_TRANSACTION_SCHEMA =
"""
{
"type": "object",
"properties": {
"resourceType": {
"type": "string",
"description": "A Bundle resource type with type 'transaction' containing multiple FHIR resources"
},
"resource": {
"type": "object",
"description": "A FHIR Bundle Resource content in JSON format"
}
},
"required": ["resourceType", "resource"]
}
""";
// TODO Add a tool for the CDS Hooks discovery endpoint
// Alternatively, should each service be a separate tool?
// TODO Add other fields from https://cds-hooks.hl7.org/STU2/#http-request-1
// TODO Context here is for the patient-view hook, https://cds-hooks.hl7.org/hooks/STU1/patient-view.html#context
private static final String CALL_CDS_HOOK_SCHEMA_2_0_1 =
"""
{
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "The CDS Service to call."
},
"hook": {
"type": "string",
"description": "The hook that triggered this CDS Service call."
},
"hookInstance": {
"type": "string",
"description": "A universally unique identifier (UUID) for this particular hook call."
},
"hookContext": {
"type": "object",
"description": "Hook-specific contextual data that the CDS service will need.",
"properties": {
"userId": {
"type": "string",
"description": "The id of the current user. Must be in the format [ResourceType]/[id]."
},
"patientId": {
"type": "string",
"description": "The FHIR Patient.id of the current patient in context"
},
"encounterId": {
"type": "string",
"description": "The FHIR Encounter.id of the current encounter in context."
}
}
},
"prefetch": {
"type": "object",
"description": "Additional data to prefetch for the CDS service call."
}
},
"required": ["service", "hook", "hookInstance", "hookContext"]
}
""";
public static Tool readFhirResource() throws JsonProcessingException {
return new Tool.Builder()
.name("read-fhir-resource")
.description("Read an individual FHIR resource")
.inputSchema(mapper.readValue(READ_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool createFhirResource() throws JsonProcessingException {
return new Tool.Builder()
.name("create-fhir-resource")
.description("Create a new FHIR resource")
.inputSchema(mapper.readValue(CREATE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool updateFhirResource() throws JsonProcessingException {
return new Tool.Builder()
.name("update-fhir-resource")
.description("Update an existing FHIR resource")
.inputSchema(mapper.readValue(UPDATE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool conditionalUpdateFhirResource() throws JsonProcessingException {
return new Tool.Builder()
.name("conditional-update-fhir-resource")
.description("Conditional update an existing FHIR resource")
.inputSchema(mapper.readValue(CONDITIONAL_UPDATE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool conditionalPatchFhirResource() throws JsonProcessingException {
return new Tool.Builder()
.name("conditional-patch-fhir-resource")
.description("Conditional patch an existing FHIR resource")
.inputSchema(mapper.readValue(CONDITIONAL_PATCH_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool patchFhirResource() throws JsonProcessingException {
return new Tool.Builder()
.name("patch-fhir-resource")
.description("Patch an existing FHIR resource")
.inputSchema(mapper.readValue(PATCH_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool deleteFhirResource() throws JsonProcessingException {
return new Tool.Builder()
.name("delete-fhir-resource")
.description("Delete an existing FHIR resource")
.inputSchema(mapper.readValue(DELETE_FHIR_RESOURCE_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool searchFhirResources() throws JsonProcessingException {
return new Tool.Builder()
.name("search-fhir-resources")
.description("Search an existing FHIR resources")
.inputSchema(mapper.readValue(SEARCH_FHIR_RESOURCES_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool createFhirTransaction() throws JsonProcessingException {
return new Tool.Builder()
.name("create-fhir-transaction")
.description("Create a FHIR transaction")
.inputSchema(mapper.readValue(CREATE_FHIR_TRANSACTION_SCHEMA, McpSchema.JsonSchema.class))
.build();
}
public static Tool callCdsHook() throws JsonProcessingException {
return new Tool.Builder()
.name("call-cds-hook")
.description("Call a CDS Hook")
.inputSchema(mapper.readValue(CALL_CDS_HOOK_SCHEMA_2_0_1, McpSchema.JsonSchema.class))
.build();
}
public static final ObjectMapper mapper = new ObjectMapper()
.enable(JsonParser.Feature.ALLOW_COMMENTS)
.enable(JsonParser.Feature.ALLOW_SINGLE_QUOTES)
.enable(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES)
.enable(JsonParser.Feature.INCLUDE_SOURCE_IN_LOCATION)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}

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());
}
}
}

View File

@@ -36,7 +36,65 @@ management:
export:
enabled: true
spring:
ai:
# Run e.g. `npx @modelcontextprotocol/inspector` and connect to http://localhost:8080/mcp/message using Streamable HTTP
# Add the following to the MCP server settings file in e.g. cursor or claude (Desktop applications) for local debugging:
# cursor:
# {
# "mcpServers": {
# "hapi": {
# "url": "http://localhost:8080/mcp/message"
# }
# }
# }
# or claude:
# {
# "mcpServers": {
# "hapi": {
# "command": "npx",
# "args": [
# "mcp-remote@latest",
# "http://localhost:8080/mcp/message"
# ]
# }
# }
# }
mcp:
server:
# Will be enabled once spring-ai-starter-mcp-server is added as dependency
# name: FHIR MCP Server
# version: 1.0.0
# type: SYNC
# instructions: "This server provides access to a FHIR RESTful API. You can use it to query FHIR resources, perform operations, and retrieve data in a structured format."
# sse-message-endpoint: /mcp/message
# capabilities:
# tool: true
# resource: true
# prompt: true
# completion: true
# stdio: false
enabled: true
#endpoint: /mcp
#schema:
# fhir-enabled: true
# fhir:
# base-url: http://localhost:8080/fhir
#query:
# prompt:
# template: |
# You are a FHIR assistant. Translate the following question into a valid FHIR RESTful API query:
# "{{query}}"
# Use the provided FHIR schema:
# {{schema}}
#base-url: /api/v1
main:
allow-bean-definition-overriding: false
allow-circular-references: true
flyway:
enabled: false