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

@@ -0,0 +1,78 @@
package ca.uhn.fhir.jpa.starter;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.searchparam.config.NicknameServiceConfig;
import ca.uhn.fhir.jpa.starter.mcp.ToolFactory;
import ca.uhn.fhir.util.BundleUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.gson.Gson;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.hl7.fhir.r4.model.Bundle;
import org.junit.jupiter.api.Test;
import org.opencds.cqf.fhir.cr.hapi.config.RepositoryConfig;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class, NicknameServiceConfig.class, RepositoryConfig.class}, properties = {"spring.datasource.url=jdbc:h2:mem:dbr4", "hapi.fhir.fhir_version=r4", "hibernate.search.enabled=true", "spring.ai.mcp.server.enabled=true",})
public class McpTests {
@LocalServerPort
private int port;
@Test
public void mcpTests() throws JsonProcessingException {
var fhirContext = FhirContext.forR4();
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint("/mcp/message").build();
var client = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10)).capabilities(McpSchema.ClientCapabilities.builder().roots(true) // Enable roots capability
.sampling().build()).build();
var initializationResult = client.initialize();
var tools = client.listTools().tools();
assertThat(tools).isNotEmpty();
var searchToolName = ToolFactory.searchFhirResources().name();
var createToolName = ToolFactory.createFhirResource().name();
assertThat(tools.stream().filter(tool -> tool.name().equals(searchToolName)).findFirst().get()).isNotNull();
assertThat(tools.stream().filter(tool -> tool.name().equals(createToolName)).findFirst().get()).isNotNull();
var createMcpRequest = new McpSchema.CallToolRequest.Builder().arguments(Map.of("operation", "create", "resourceType", "Patient", "resource", """
{
"resourceType": "Patient",
"id": "example",
"identifier": [
{
"system": "urn:something",
"value": "uncleScrooge"
}
]
}""")).name(createToolName).build();
assertThat(client.callTool(createMcpRequest).isError()).isFalse();
var searchMcpRequest = new McpSchema.CallToolRequest.Builder().arguments(Map.of("operation", "search", "resourceType", "Patient", "query", "identifier=urn:something|uncleScrooge")).name(searchToolName).build();
var searchResult = client.callTool(searchMcpRequest);
assertThat(searchResult.isError()).isFalse();
assertThat(searchResult.content().size()).isEqualTo(1);
var content = ((McpSchema.TextContent) searchResult.content().get(0));
var embeddedResponseBundle = new Gson().fromJson(content.text(), LinkedHashMap.class).get("response");
var responseBundle = fhirContext.newJsonParser().parseResource(Bundle.class, embeddedResponseBundle.toString());
var entries = BundleUtil.toListOfEntries(fhirContext, responseBundle);
assertThat(entries.size()).isEqualTo(1);
client.closeGracefully();
}
}

View File

@@ -0,0 +1,18 @@
{
"hook": "patient-view",
"hookInstance": "8d5a3a2e-6d8b-4f7c-bb2d-2f1b8cf1d7a1",
"context": {
"userId": "Practitioner/123",
"patientId": "123",
"encounterId": "456"
},
"prefetch": {
"item1": {
"resourceType": "Patient",
"gender": "male",
"birthDate": "1989-10-23",
"id": "123",
"active": true
}
}
}

View File

@@ -0,0 +1,5 @@
{
"userId": "Practitioner/123",
"patientId": "123",
"encounterId": "456"
}

View File

@@ -0,0 +1,9 @@
{
"item1": {
"resourceType": "Patient",
"gender": "male",
"birthDate": "1989-10-23",
"id": "123",
"active": true
}
}

View File

@@ -0,0 +1,42 @@
<PlanDefinition xmlns="http://hl7.org/fhir">
<id value="HelloPatientPd" />
<meta>
<profile value="http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-recommendationdefinition" />
</meta>
<extension url="http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-knowledgeCapability">
<valueCode value="executable" />
</extension>
<url value="http://example.org/PlanDefinition/HelloPatientPd" />
<identifier>
<use value="official" />
<value value="PlanDefinition_HelloPatientPd" />
</identifier>
<name value="PlanDefinition_HelloPatientPd" />
<title value="PlanDefinition - Hello Patient" />
<type>
<coding>
<system value="http://terminology.hl7.org/CodeSystem/plan-definition-type" />
<code value="eca-rule" />
<display value="ECA Rule" />
</coding>
</type>
<status value="draft" />
<experimental value="true" />
<date value="2024-09-28" />
<description value="Demo PlanDefinition for Hello Patient" />
<action>
<title value="Hello, Patient!" />
<description value="Please state the nature of the medical emergency." />
<trigger>
<type value="named-event" />
<name value="patient-view" />
</trigger>
<condition>
<kind value="applicability" />
<expression>
<language value="text/cql" />
<expression value="true" />
</expression>
</condition>
</action>
</PlanDefinition>