-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Feature/mcp #846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Feature/mcp #846
Changes from 23 commits
ceb788d
1cffe6f
2a37f2e
5dcbfdb
56f9b8f
70c07c2
4fbab92
a930457
1059bbf
1bb3e53
37a5a72
785b0a4
c672b79
0f96854
758e395
8eff66e
bd2abc3
56ba7e3
269c50c
b63b1ae
6f66ecf
1d851e9
3b7eb46
7246f29
154d01c
dfc1b99
8d7b35d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(); | ||
} | ||
} |
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; | ||
}; | ||
} | ||
} |
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"); | ||
} | ||
} |
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) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||||||||
sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString())); | ||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+43
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback
Comment on lines
+89
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback
Comment on lines
+89
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback
Comment on lines
+89
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||
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; | ||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||
} |
There was a problem hiding this comment.
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.
Copilot uses AI. Check for mistakes.