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();
}
}