Skip to content

Commit 680255f

Browse files
jkiddoadamzkoverCopilot
authored
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 <[email protected]> * 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 <[email protected]> * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java Co-authored-by: Copilot <[email protected]> * Update src/main/java/ca/uhn/fhir/jpa/starter/mcp/ToolFactory.java Co-authored-by: Copilot <[email protected]> * Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java Co-authored-by: Copilot <[email protected]> * Update src/main/java/ca/uhn/fhir/rest/server/McpCdsBridge.java Co-authored-by: Copilot <[email protected]> * 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 c9d3bc0. * Revert "Fixed CDS hooks configuration" This reverts commit 67c4279. --------- Co-authored-by: Ádám Z. Kövér <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 5585170 commit 680255f

18 files changed

+1080
-2
lines changed

.dockerignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ target/maven-*
55
target/ROOT
66
target/test-classes/
77
target/war
8-
target/duplicate-finder-result.xml
98
target/jacoco.exec
109
target/*.original
1110
.idea

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,3 +588,7 @@ docker run --rm -it -p 8080:8080 \
588588
```
589589

590590
You can configure the agent using environment variables or Java system properties, see <https://opentelemetry.io/docs/instrumentation/java/automatic/agent-config/> for details.
591+
592+
## Enable MCP
593+
594+
MCP capabilities can be enabled by setting the `spring.ai.mcp.server.enabled` to `true`. This will enable the MCP server and expose the MCP endpoints. The MCP endpoint is currently hardcoded to `/mcp/message` and can be tried out by running e.g. `npx @modelcontextprotocol/inspector` and connect to http://localhost:8080/mcp/message using Streamable HTTP. Spring AI MCP Server Auto Configuration is currently not supported.

pom.xml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,32 @@
383383
<version>5.0.1</version>
384384
</dependency>
385385

386+
<dependency>
387+
<groupId>org.springframework.ai</groupId>
388+
<artifactId>spring-ai-mcp</artifactId>
389+
<version>1.0.2</version>
390+
</dependency>
391+
<!--
392+
This will be included as well as using Spring Automatic Configuration
393+
once spring-ai and io.modelcontextprotocol.sdk are on par
394+
-->
395+
<!--<dependency>
396+
<groupId>org.springframework.ai</groupId>
397+
<artifactId>spring-ai-starter-mcp-server</artifactId>
398+
<version>1.0.2</version>
399+
</dependency>-->
400+
401+
<dependency>
402+
<groupId>io.modelcontextprotocol.sdk</groupId>
403+
<artifactId>mcp</artifactId>
404+
<version>0.12.1</version>
405+
</dependency>
406+
407+
<dependency>
408+
<groupId>org.springframework</groupId>
409+
<artifactId>spring-test</artifactId>
410+
411+
</dependency>
386412

387413
<dependency>
388414
<groupId>org.junit.jupiter</groupId>

src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121

2222
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
2323

24+
@EnableConfigurationProperties
2425
@ConfigurationProperties(prefix = "hapi.fhir")
2526
@Configuration
26-
@EnableConfigurationProperties
2727
public class AppProperties {
2828

2929
private final Set<String> auto_version_reference_at_paths = new HashSet<>();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package ca.uhn.fhir.jpa.starter.mcp;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import io.modelcontextprotocol.spec.McpSchema;
6+
import org.springframework.stereotype.Component;
7+
8+
import java.util.Map;
9+
10+
@Component
11+
public class CallToolResultFactory {
12+
13+
public static McpSchema.CallToolResult success(
14+
String resourceType, Interaction interaction, String response, int status) {
15+
Map<String, Object> payload = Map.of(
16+
"resourceType", resourceType,
17+
"interaction", interaction,
18+
"response", response,
19+
"status", status);
20+
21+
ObjectMapper objectMapper = new ObjectMapper();
22+
String jacksonData;
23+
try {
24+
jacksonData = objectMapper.writeValueAsString(payload);
25+
} catch (JsonProcessingException e) {
26+
throw new RuntimeException(e);
27+
}
28+
29+
return McpSchema.CallToolResult.builder()
30+
.addContent(new McpSchema.TextContent(jacksonData))
31+
.build();
32+
}
33+
34+
public static McpSchema.CallToolResult failure(String message) {
35+
return McpSchema.CallToolResult.builder()
36+
.isError(true)
37+
.addTextContent(message)
38+
.build();
39+
}
40+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package ca.uhn.fhir.jpa.starter.mcp;
2+
3+
import ca.uhn.fhir.rest.api.RequestTypeEnum;
4+
5+
public enum Interaction {
6+
CALL_CDS_HOOK("call-cds-hook"),
7+
SEARCH("search"),
8+
READ("read"),
9+
CREATE("create"),
10+
UPDATE("update"),
11+
DELETE("delete"),
12+
PATCH("patch"),
13+
TRANSACTION("transaction");
14+
15+
private final String name;
16+
17+
Interaction(String name) {
18+
this.name = name;
19+
}
20+
21+
public String getName() {
22+
return name;
23+
}
24+
25+
public RequestTypeEnum asRequestType() {
26+
return switch (this) {
27+
case SEARCH, READ -> RequestTypeEnum.GET;
28+
case CREATE, TRANSACTION, CALL_CDS_HOOK -> RequestTypeEnum.POST;
29+
case UPDATE -> RequestTypeEnum.PUT;
30+
case DELETE -> RequestTypeEnum.DELETE;
31+
case PATCH -> RequestTypeEnum.PATCH;
32+
};
33+
}
34+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package ca.uhn.fhir.jpa.starter.mcp;
2+
3+
import ca.uhn.fhir.context.FhirContext;
4+
import ca.uhn.fhir.rest.server.McpBridge;
5+
import ca.uhn.fhir.rest.server.McpCdsBridge;
6+
import ca.uhn.fhir.rest.server.McpFhirBridge;
7+
import ca.uhn.fhir.rest.server.RestfulServer;
8+
import ca.uhn.hapi.fhir.cdshooks.api.ICdsServiceRegistry;
9+
import ca.uhn.hapi.fhir.cdshooks.module.CdsHooksObjectMapperFactory;
10+
import com.fasterxml.jackson.databind.ObjectMapper;
11+
import io.modelcontextprotocol.server.McpServer;
12+
import io.modelcontextprotocol.server.McpSyncServer;
13+
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
14+
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
15+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
16+
import org.springframework.boot.web.servlet.ServletRegistrationBean;
17+
import org.springframework.context.annotation.Bean;
18+
import org.springframework.context.annotation.Configuration;
19+
20+
import java.util.List;
21+
22+
// https://mcp-cn.ssshooter.com/sdk/java/mcp-server#sse-servlet
23+
// https://www.baeldung.com/spring-ai-model-context-protocol-mcp
24+
// 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
25+
// 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
26+
// 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
27+
// https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html
28+
@Configuration
29+
@ConditionalOnProperty(
30+
prefix = "spring.ai.mcp.server",
31+
name = {"enabled"},
32+
havingValue = "true")
33+
public class McpServerConfig {
34+
35+
private static final String SSE_ENDPOINT = "/sse";
36+
private static final String SSE_MESSAGE_ENDPOINT = "/mcp/message";
37+
38+
@Bean
39+
public McpSyncServer syncServer(
40+
List<McpBridge> mcpBridges, McpStreamableServerTransportProvider transportProvider) {
41+
return McpServer.sync(transportProvider)
42+
.tools(mcpBridges.stream()
43+
.flatMap(bridge -> bridge.generateTools().stream())
44+
.toList())
45+
.build();
46+
}
47+
48+
@Bean
49+
public McpFhirBridge mcpFhirBridge(RestfulServer restfulServer) {
50+
return new McpFhirBridge(restfulServer);
51+
}
52+
53+
@Bean
54+
@ConditionalOnProperty(
55+
prefix = "hapi.fhir.cr",
56+
name = {"enabled"},
57+
havingValue = "true")
58+
public McpCdsBridge mcpCdsBridge(FhirContext fhirContext, ICdsServiceRegistry cdsServiceRegistry) {
59+
60+
return new McpCdsBridge(
61+
fhirContext, cdsServiceRegistry, new CdsHooksObjectMapperFactory(fhirContext).newMapper());
62+
}
63+
64+
@Bean
65+
public HttpServletStreamableServerTransportProvider servletSseServerTransportProvider(
66+
/*McpServerProperties properties*/ ) {
67+
68+
return HttpServletStreamableServerTransportProvider.builder()
69+
.disallowDelete(false)
70+
.mcpEndpoint(SSE_MESSAGE_ENDPOINT)
71+
.objectMapper(new ObjectMapper())
72+
// .contextExtractor((serverRequest, context) -> context)
73+
.build();
74+
}
75+
76+
@Bean
77+
public ServletRegistrationBean customServletBean(
78+
HttpServletStreamableServerTransportProvider transportProvider /*, McpServerProperties properties*/) {
79+
return new ServletRegistrationBean<>(transportProvider, SSE_MESSAGE_ENDPOINT, SSE_ENDPOINT);
80+
}
81+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package ca.uhn.fhir.jpa.starter.mcp;
2+
3+
import ca.uhn.fhir.context.FhirContext;
4+
import com.google.gson.Gson;
5+
import org.hl7.fhir.instance.model.api.IBaseResource;
6+
import org.springframework.mock.web.MockHttpServletRequest;
7+
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.Map;
10+
11+
public class RequestBuilder {
12+
13+
private final FhirContext fhirContext;
14+
private final String resourceType;
15+
private final Interaction interaction;
16+
private final Map<String, Object> config;
17+
/**
18+
* Constructs a RequestBuilder for a specific FHIR interaction.
19+
*
20+
* @param fhirContext the FHIR context
21+
* @param contextMap a map containing configuration parameters, including 'resourceType'
22+
* @param interaction the type of interaction (e.g., SEARCH, READ, CREATE, etc.)
23+
*/
24+
public RequestBuilder(FhirContext fhirContext, Map<String, Object> contextMap, Interaction interaction) {
25+
this.config = contextMap;
26+
if (interaction == Interaction.TRANSACTION) this.resourceType = "";
27+
else if (contextMap.get("resourceType") instanceof String rt && !rt.isBlank()) this.resourceType = rt;
28+
else throw new IllegalArgumentException("Missing or invalid 'resourceType' in contextMap");
29+
30+
this.interaction = interaction;
31+
this.fhirContext = fhirContext;
32+
}
33+
34+
public MockHttpServletRequest buildRequest() {
35+
String basePath = "/" + resourceType;
36+
String method;
37+
MockHttpServletRequest req;
38+
39+
switch (interaction) {
40+
case SEARCH -> {
41+
method = "GET";
42+
req = new MockHttpServletRequest(method, basePath);
43+
Map<?, ?> sp = null;
44+
if (config.get("query") instanceof Map<?, ?> q) {
45+
sp = q;
46+
} else if (config.get("searchParams") instanceof Map<?, ?> s) {
47+
sp = s;
48+
}
49+
if (sp != null) {
50+
sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString()));
51+
}
52+
}
53+
case READ -> {
54+
method = "GET";
55+
String id = requireString();
56+
req = new MockHttpServletRequest(method, basePath + "/" + id);
57+
}
58+
case CREATE, TRANSACTION -> {
59+
method = "POST";
60+
req = new MockHttpServletRequest(method, basePath);
61+
applyResourceBody(req);
62+
}
63+
case UPDATE -> {
64+
method = "PUT";
65+
String id = requireString();
66+
req = new MockHttpServletRequest(method, basePath + "/" + id);
67+
applyResourceBody(req);
68+
}
69+
case DELETE -> {
70+
method = "DELETE";
71+
String id = requireString();
72+
req = new MockHttpServletRequest(method, basePath + "/" + id);
73+
}
74+
case PATCH -> {
75+
method = "PATCH";
76+
String id = requireString();
77+
req = new MockHttpServletRequest(method, basePath + "/" + id);
78+
applyPatchBody(req);
79+
}
80+
default -> throw new IllegalArgumentException("Unsupported interaction: " + interaction);
81+
}
82+
83+
req.setContentType("application/fhir+json");
84+
req.addHeader("Accept", "application/fhir+json");
85+
return req;
86+
}
87+
88+
private void applyResourceBody(MockHttpServletRequest req) {
89+
Object resourceObj = config.get("resource");
90+
String json;
91+
if (resourceObj instanceof Map<?, ?>) json = new Gson().toJson(resourceObj, Map.class);
92+
else if (resourceObj instanceof String) json = resourceObj.toString();
93+
else throw new IllegalArgumentException("Unsupported resource body type: " + resourceObj.getClass());
94+
req.setContent(json.getBytes(StandardCharsets.UTF_8));
95+
}
96+
97+
private void applyPatchBody(MockHttpServletRequest req) {
98+
Object patchBody = config.get("resource");
99+
if (patchBody == null) {
100+
throw new IllegalArgumentException("Missing 'resource' for patch interaction");
101+
}
102+
String content;
103+
if (patchBody instanceof String s) {
104+
content = s;
105+
} else if (patchBody instanceof IBaseResource r) {
106+
content = fhirContext.newJsonParser().encodeResourceToString(r);
107+
} else {
108+
throw new IllegalArgumentException("Unsupported patch body type: " + patchBody.getClass());
109+
}
110+
req.setContent(content.getBytes(StandardCharsets.UTF_8));
111+
}
112+
113+
private String requireString() {
114+
Object val = config.get("id");
115+
if (!(val instanceof String s) || s.isBlank()) {
116+
throw new IllegalArgumentException("Missing or invalid '" + "id" + "'");
117+
}
118+
return s;
119+
}
120+
}

0 commit comments

Comments
 (0)