Skip to content

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

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,30 @@
<version>5.0.1</version>
</dependency>

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp</artifactId>
<version>1.0.1</version>
</dependency>

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>1.0.1</version>
</dependency>

<!-- https://mvnrepository.com/artifact/io.modelcontextprotocol.sdk/mcp -->
<!--<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.11.0</version>
</dependency>-->

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

</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package ca.uhn.fhir.jpa.starter.mcp;

import ca.uhn.fhir.context.FhirContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class CallToolResultFactory {

@Autowired
private FhirContext fhirContext;

public McpSchema.CallToolResult success(
String resourceType, Interaction interaction, Object response, int status) {
Map<String, Object> payload = Map.of(
"resourceType", resourceType,
"interaction", interaction,
"response", fhirContext.newJsonParser().encodeResourceToString((IBaseResource) 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 McpSchema.CallToolResult failure(String message) {
return McpSchema.CallToolResult.builder()
.isError(true)
.addTextContent(message)
.build();
}
}
35 changes: 35 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,35 @@
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 -> RequestTypeEnum.POST;
case UPDATE -> RequestTypeEnum.PUT;
case DELETE -> RequestTypeEnum.DELETE;
case PATCH -> RequestTypeEnum.PATCH;
case CALL_CDS_HOOK -> RequestTypeEnum.POST;
};
}
}
47 changes: 47 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,47 @@
package ca.uhn.fhir.jpa.starter.mcp;

import ca.uhn.fhir.rest.server.MCPBridge;
import ca.uhn.fhir.rest.server.RestfulServer;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

// 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

@Configuration
@EnableWebMvc
public class McpServerConfig implements WebMvcConfigurer {

@Bean
public MCPBridge mcpBridge(RestfulServer restfulServer, CallToolResultFactory callToolResultFactory) {
return new MCPBridge(restfulServer, callToolResultFactory);
}

@Bean
public McpSyncServer syncServer(McpSyncServer mcpSyncServer, MCPBridge mcpBridge) {

mcpBridge.generateTools().stream().forEach(mcpSyncServer::addTool);
return mcpSyncServer;
}

@Bean
public HttpServletSseServerTransportProvider servletSseServerTransportProvider() {
return new HttpServletSseServerTransportProvider(new ObjectMapper(), "/mcp/message");
}

@Bean
public ServletRegistrationBean customServletBean(HttpServletSseServerTransportProvider transportProvider) {
var servetRegistrationBean = new ServletRegistrationBean<>(transportProvider, "/mcp/message", "/sse");
return servetRegistrationBean;
// return new ServletRegistrationBean(transportProvider);
}
}
117 changes: 117 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,117 @@
package ca.uhn.fhir.jpa.starter.mcp;

import ca.uhn.fhir.context.FhirContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
private final ObjectMapper mapper = new ObjectMapper();
private final String headers;
private String resource;

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.resourceType = contextMap.get("resourceType") instanceof String rt ? rt : null;
this.headers = contextMap.get("headers") instanceof String h ? h : null;
this.resource = null;
try {
resource = mapper.writeValueAsString(contextMap.get("resource"));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
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) {
sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString()));
}
}
case READ -> {
method = "GET";
String id = requireString("id");
req = new MockHttpServletRequest(method, basePath + "/" + id);
}
case CREATE, TRANSACTION -> {
method = "POST";
req = new MockHttpServletRequest(method, basePath);
applyResourceBody(req, "resource");
}
case UPDATE -> {
method = "PUT";
String id = requireString("id");
req = new MockHttpServletRequest(method, basePath + "/" + id);
applyResourceBody(req, "resource");
}
case DELETE -> {
method = "DELETE";
String id = requireString("id");
req = new MockHttpServletRequest(method, basePath + "/" + id);
}
case PATCH -> {
method = "PATCH";
String id = requireString("id");
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, String key) {
Object resourceObj = config.get(key);
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");
}
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(String key) {
Object val = config.get(key);
if (!(val instanceof String s) || s.isBlank()) {
throw new IllegalArgumentException("Missing or invalid '" + key + "'");
}
return (String) val;
}
}
Loading