Skip to content
Draft
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ceb788d
Added MCP support using SSE on http://localhost:8080/sse
jkiddo Aug 4, 2025
1cffe6f
Reverted change that IntelliJ complains about
jkiddo Aug 4, 2025
2a37f2e
Pre-rework
jkiddo Aug 4, 2025
5dcbfdb
Cleaned up the code a fair bit
jkiddo Aug 5, 2025
56f9b8f
Renamed
jkiddo Aug 5, 2025
70c07c2
Renamed
jkiddo Aug 5, 2025
4fbab92
Running spotless
jkiddo Aug 5, 2025
a930457
Reuse FhirContext in result serialization to make MCP server work wit…
adamzkover Aug 5, 2025
1059bbf
Merge pull request #4 from adamzkover/feature/mcp
jkiddo Aug 5, 2025
1bb3e53
Added support for transactions
jkiddo Aug 6, 2025
37a5a72
Merge branch 'feature/mcp' of github.com:jkiddo/hapi-fhir-jpaserver-s…
jkiddo Aug 6, 2025
785b0a4
PoC tool for CDS Hooks
adamzkover Aug 10, 2025
c672b79
Merge pull request #5 from adamzkover/feature/mcp
jkiddo Aug 11, 2025
0f96854
some cleanup
jkiddo Aug 16, 2025
758e395
Upgrade of model protocol
jkiddo Aug 20, 2025
8eff66e
Added comments
jkiddo Aug 20, 2025
bd2abc3
Removed field injection ... CDS to be changed to AutoConfig eventually
jkiddo Aug 24, 2025
56ba7e3
Merge branch 'master' into feature/mcp
jkiddo Aug 24, 2025
269c50c
Adjusted to new builder pattern
jkiddo Aug 24, 2025
b63b1ae
Update src/main/java/ca/uhn/fhir/rest/server/MCPBridge.java
jkiddo Aug 24, 2025
6f66ecf
A bit of restructuring
jkiddo Aug 26, 2025
1d851e9
Merge branch 'hapifhir:master' into feature/mcp
jkiddo Aug 26, 2025
3b7eb46
More rework
jkiddo Aug 26, 2025
7246f29
Merge branch 'hapifhir:master' into feature/mcp
jkiddo Aug 27, 2025
154d01c
Removing (suspected unnecessary) formatting
jkiddo Aug 27, 2025
dfc1b99
Add more example doc
jkiddo Aug 27, 2025
8d7b35d
Merge branch 'hapifhir:master' into feature/mcp
jkiddo Aug 29, 2025
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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ target/maven-*
target/ROOT
target/test-classes/
target/war
target/duplicate-finder-result.xml
target/jacoco.exec
target/*.original
.idea
Expand Down
25 changes: 25 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,31 @@
<version>5.0.1</version>
</dependency>

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp</artifactId>
<version>1.0.1</version>
</dependency>
<!--
This will be included as well as using Spring Automatic Configuration
once spring-ai and io.modelcontextprotocol.sdk are on par
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
<version>1.0.1</version>
</dependency>
-->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.11.2</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>

</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@

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<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
34 changes: 34 additions & 0 deletions src/main/java/ca/uhn/fhir/jpa/starter/mcp/Interaction.java
Original file line number Diff line number Diff line change
@@ -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;
};
}
}
74 changes: 74 additions & 0 deletions src/main/java/ca/uhn/fhir/jpa/starter/mcp/McpServerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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 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 {

@Bean
public McpSyncServer syncServer(
List<McpBridge> mcpBridges, HttpServletStreamableServerTransportProvider 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() {
return HttpServletStreamableServerTransportProvider.builder()
.disallowDelete(false)
.mcpEndpoint("/mcp/message")
.objectMapper(new ObjectMapper())
.contextExtractor((serverRequest, context) -> context)
.build();
}

@Bean
public ServletRegistrationBean customServletBean(HttpServletStreamableServerTransportProvider transportProvider) {
return new ServletRegistrationBean<>(transportProvider, "/mcp/message", "/sse");
}
}
111 changes: 111 additions & 0 deletions src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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);
if (config.get("searchParams") instanceof Map<?, ?> sp) {
Comment on lines +42 to +43
Copy link
Preview

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The search parameters are checked for a key 'searchParams' but the tool schema defines 'query' as the parameter name. This will cause search parameters to never be applied to search requests.

Suggested change
req = new MockHttpServletRequest(method, basePath);
if (config.get("searchParams") instanceof Map<?, ?> sp) {
if (config.get("query") instanceof Map<?, ?> sp) {

Copilot uses AI. Check for mistakes.

Copy link
Preview

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code checks for 'searchParams' but the SEARCH_FHIR_RESOURCES_SCHEMA defines the parameter as 'query'. This mismatch will cause search parameters to never be applied.

Suggested change
if (config.get("searchParams") instanceof Map<?, ?> sp) {
if (config.get("query") instanceof Map<?, ?> sp) {

Copilot uses AI. Check for mistakes.

sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString()));
Comment on lines +43 to +44
Copy link
Preview

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code should handle the case where the 'query' parameter (as defined in the schema) needs to be parsed into individual search parameters. Currently it expects a Map but the schema defines it as a comma-separated string.

Suggested change
if (config.get("searchParams") instanceof Map<?, ?> sp) {
sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString()));
Object searchParamsObj = config.get("searchParams");
if (searchParamsObj instanceof Map<?, ?> sp) {
sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString()));
} else if (searchParamsObj instanceof String spStr && !spStr.isBlank()) {
// Parse comma-separated string, e.g. "name=John,age=30"
String[] pairs = spStr.split(",");
for (String pair : pairs) {
String[] kv = pair.split("=", 2);
if (kv.length == 2) {
req.addParameter(kv[0].trim(), kv[1].trim());
}
}

Copilot uses AI. Check for mistakes.

}
}
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 = new Gson().toJson(resourceObj, Map.class);
req.setContent(json.getBytes(StandardCharsets.UTF_8));
}

private void applyPatchBody(MockHttpServletRequest req) {
Object patchBody = config.get("patch");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'patch' for patch interaction");
Comment on lines +89 to +91
Copy link
Preview

Copilot AI Aug 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RequestBuilder looks for a 'patch' parameter but the ToolFactory schemas define the parameter as 'resource' for patch operations. This will cause patch operations to fail.

Suggested change
Object patchBody = config.get("patch");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'patch' for patch interaction");
Object patchBody = config.get("resource");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'resource' for patch interaction");

Copilot uses AI. Check for mistakes.

Comment on lines +89 to +91
Copy link
Preview

Copilot AI Aug 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PATCH case expects 'patch' parameter but the ToolFactory schema defines the parameter as 'resource'. This inconsistency will prevent patch operations from working correctly.

Suggested change
Object patchBody = config.get("patch");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'patch' for patch interaction");
Object patchBody = config.get("resource");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'resource' for patch interaction");

Copilot uses AI. Check for mistakes.

Comment on lines +89 to +91
Copy link
Preview

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The patch method looks for a 'patch' key but the tool schema defines 'resource' as the property name for patch content. This will cause patch operations to always fail with a missing patch error.

Suggested change
Object patchBody = config.get("patch");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'patch' for patch interaction");
Object patchBody = config.get("resource");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'resource' for patch interaction");

Copilot uses AI. Check for mistakes.

Comment on lines +89 to +91
Copy link
Preview

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks for 'patch' key but the PATCH_FHIR_RESOURCE_SCHEMA and CONDITIONAL_PATCH_FHIR_RESOURCE_SCHEMA define the parameter as 'resource'. This will cause patch operations to fail.

Suggested change
Object patchBody = config.get("patch");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'patch' for patch interaction");
Object patchBody = config.get("resource");
if (patchBody == null) {
throw new IllegalArgumentException("Missing 'resource' for patch interaction");

Copilot uses AI. Check for mistakes.

}
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;
}
}
Loading
Loading