diff --git a/.github/workflows/angular_test.yml b/.github/workflows/angular_test.yml index d2af844388f..4e1fb43c5db 100644 --- a/.github/workflows/angular_test.yml +++ b/.github/workflows/angular_test.yml @@ -16,10 +16,10 @@ jobs: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: # https://github.com/actions/setup-node - node-version: 20 + node-version: 24 cache: npm cache-dependency-path: matchbox-frontend/package-lock.json diff --git a/docs/changelog.md b/docs/changelog.md index a2102250d5d..c71b9d5c6f2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,7 @@ +2025/10/21 Release 4.0.14 + +- Further MCP Server integration [#398](https://github.com/ahdis/matchbox/issues/398) + 2025/10/04 Release 4.0.13 - MCP Server integration (#398) diff --git a/docs/validation-with-ai-tutorial.md b/docs/validation-with-ai-tutorial.md index adf3d4aa923..c5f8946af92 100644 --- a/docs/validation-with-ai-tutorial.md +++ b/docs/validation-with-ai-tutorial.md @@ -87,7 +87,7 @@ spring: enabled: true ``` -The MCP-Server can be reached at the endpoint `http://<>/matchbox/mcp/sse` +The MCP-Server can be reached at the endpoint `http://<>/matchbox(v3)/mcp/message` ### Setting up matchbox for Claude Desktop @@ -98,6 +98,20 @@ For Free users connecting to remote servers is currently not officially supporte Example Claude Desktop: ![Claude Desktop MCP example](assets/claude_desktop_mcp_example.png) +```json + { + "mcpServers": { + "matchbox": { + "command": "npx", + "args": [ + "mcp-remote@latest", + "http://localhost:8080/matchboxv3/mcp/message" + ] + } + } + } +``` + ### Setting up matchbox for VS Code GitHub Copilot First, make sure the `chat.mcp.enabled` setting in VS Code is enabled. @@ -112,7 +126,7 @@ Add matchbox as MCP-Server as such: "mcp": { "servers": { "matchbox": { - "url": "http://<>/matchbox/mcp/sse" + "url": "http://<>/matchboxv3/mcp/message" } } }, diff --git a/matchbox-engine/pom.xml b/matchbox-engine/pom.xml index 41baae80129..4e046f78d6d 100644 --- a/matchbox-engine/pom.xml +++ b/matchbox-engine/pom.xml @@ -6,7 +6,7 @@ matchbox health.matchbox - 4.0.13 + 4.0.14 matchbox-engine diff --git a/matchbox-engine/src/main/java/ch/ahdis/matchbox/engine/MatchboxEngine.java b/matchbox-engine/src/main/java/ch/ahdis/matchbox/engine/MatchboxEngine.java index 3c7baddf2db..2ac1b4d57ac 100644 --- a/matchbox-engine/src/main/java/ch/ahdis/matchbox/engine/MatchboxEngine.java +++ b/matchbox-engine/src/main/java/ch/ahdis/matchbox/engine/MatchboxEngine.java @@ -889,6 +889,8 @@ public List validate(final @NonNull FhirFormat format, final List messages = new ArrayList<>(); final InstanceValidator validator = getValidator(format); validator.validate(null, messages, stream, format, (sd != null) ? new ArrayList<>(List.of(sd)) : new ArrayList<>()); + log.info("finished validation " + sd.getUrl() + "|" + sd.getVersion() + " " + + (sd.getDateElement() != null ? "(" + sd.getDateElement().asStringValue() + ")" : "")); return this.filterValidationMessages(messages); } diff --git a/matchbox-frontend/package-lock.json b/matchbox-frontend/package-lock.json index ee4fedec5c0..405da6c5760 100644 --- a/matchbox-frontend/package-lock.json +++ b/matchbox-frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "matchbox", - "version": "3.9.13", + "version": "3.9.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "matchbox", - "version": "3.9.13", + "version": "3.9.14", "license": "MIT", "dependencies": { "@ngx-translate/core": "^16.0.3", diff --git a/matchbox-frontend/package.json b/matchbox-frontend/package.json index 05d108abfb0..44ca854cd4d 100644 --- a/matchbox-frontend/package.json +++ b/matchbox-frontend/package.json @@ -1,6 +1,6 @@ { "name": "matchbox", - "version": "3.9.13", + "version": "3.9.14", "license": "MIT", "scripts": { "ng": "ng", diff --git a/matchbox-frontend/src/app/home/home.component.html b/matchbox-frontend/src/app/home/home.component.html index 876a4932671..19dbb012462 100644 --- a/matchbox-frontend/src/app/home/home.component.html +++ b/matchbox-frontend/src/app/home/home.component.html @@ -24,7 +24,7 @@

- matchbox version: {{ version }} | + Matchbox version: {{ version }} | contact

diff --git a/matchbox-server/pom.xml b/matchbox-server/pom.xml index 66ad707a353..a9bb0d8d042 100644 --- a/matchbox-server/pom.xml +++ b/matchbox-server/pom.xml @@ -5,7 +5,7 @@ matchbox health.matchbox - 4.0.13 + 4.0.14 matchbox-server diff --git a/matchbox-server/src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java b/matchbox-server/src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java index 66727d941c1..3a4141a46c8 100644 --- a/matchbox-server/src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java +++ b/matchbox-server/src/main/java/ca/uhn/fhir/jpa/starter/mcp/RequestBuilder.java @@ -114,6 +114,15 @@ public MockHttpServletRequest buildRequest() { if (profile != null && !profile.isBlank()) { req.addParameter("profile", profile); } + Map sp = null; + if (config.get("query") instanceof Map q) { + sp = q; + } else if (config.get("searchParams") instanceof Map s) { + sp = s; + } + if (sp != null) { + sp.forEach((k, v) -> req.addParameter(k.toString(), v.toString())); + } req.addHeader("Content-Type", "text/plain; charset=UTF-8"); } default -> throw new IllegalArgumentException("Unsupported interaction: " + interaction); diff --git a/matchbox-server/src/main/java/ca/uhn/fhir/rest/server/McpMatchboxBridge.java b/matchbox-server/src/main/java/ca/uhn/fhir/rest/server/McpMatchboxBridge.java index d9cb280e728..79304819afc 100644 --- a/matchbox-server/src/main/java/ca/uhn/fhir/rest/server/McpMatchboxBridge.java +++ b/matchbox-server/src/main/java/ca/uhn/fhir/rest/server/McpMatchboxBridge.java @@ -2,7 +2,8 @@ import ca.uhn.fhir.context.FhirContext; import ch.ahdis.matchbox.mcp.ToolFactory; -import ca.uhn.fhir.jpa.starter.AppProperties; +import ch.ahdis.matchbox.util.CrossVersionResourceUtils; +import ch.ahdis.matchbox.util.http.MatchboxFhirFormat; import ca.uhn.fhir.jpa.starter.mcp.CallToolResultFactory; import ca.uhn.fhir.jpa.starter.mcp.Interaction; import ca.uhn.fhir.jpa.starter.mcp.RequestBuilder; @@ -11,12 +12,16 @@ import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; +import org.hl7.fhir.r5.model.OperationDefinition.OperationDefinitionParameterComponent; +import org.hl7.fhir.r5.model.Resource; +import org.hl7.fhir.r5.model.Enumerations.OperationParameterUse; import org.hl7.fhir.r5.model.OperationDefinition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.stereotype.Component; +import java.io.StringWriter; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -46,6 +51,10 @@ public List generateTools() { new McpServerFeatures.SyncToolSpecification.Builder() .tool(ToolFactory.listFhirProfilesToValidateFor()) .callHandler((exchange, request) -> getFhirProfilesToValidateFor(request, Interaction.READ)) + .build(), + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(ToolFactory.listValidationParameters()) + .callHandler((exchange, request) -> getExtraValidationParameters(request, Interaction.READ)) .build() ); } catch (JsonProcessingException e) { @@ -97,6 +106,52 @@ private McpSchema.CallToolResult getFhirProfilesToValidateFor(McpSchema.CallTool } } + private McpSchema.CallToolResult getExtraValidationParameters(McpSchema.CallToolRequest contextMap, Interaction interaction) { + var response = new MockHttpServletResponse(); + contextMap.arguments().put("resourceType", "OperationDefinition"); + contextMap.arguments().put("id", "-s-validate"); + var request = new RequestBuilder(restfulServer, contextMap.arguments(), interaction).buildRequest(); + try { + restfulServer.handleRequest(interaction.asRequestType(), request, response); + var status = response.getStatus(); + var body = response.getContentAsString(); + + if (status >= 200 && status < 300) { + if (body.isBlank()) { + return CallToolResultFactory.failure("Empty successful response for " + interaction); + } + + FhirContext fhirR5Context = FhirContext.forR5Cached(); + OperationDefinition operationDefinition = fhirR5Context.newJsonParser().parseResource(OperationDefinition.class, body); + List parameters = operationDefinition.getParameter().stream() + .filter(p -> OperationParameterUse.IN.equals(p.getUse())) + .filter(p -> !"resource".equals(p.getName())) + .filter(p -> !"profile".equals(p.getName())) + .map(p -> { + p.setUse(null); + return p; + }).toList(); + + + StringWriter result = new StringWriter(); + result.append("[ "); + for (int i = 0; i < parameters.size(); i++) { + CrossVersionResourceUtils.serializeR5(parameters.get(i), MatchboxFhirFormat.JSON, result); + result.append(","); + } + result.append("]"); + + String json = result.toString(); + return CallToolResultFactory.successFhirBody(json); + } else { + return CallToolResultFactory.failure(String.format("FHIR server error %d: %s", status, body)); + } + } catch (Exception e) { + logger.error(e.getMessage(), e); + return CallToolResultFactory.failure("Unexpected error: " + e.getMessage()); + } + } + private McpSchema.CallToolResult getFhirImplementationGuides(McpSchema.CallToolRequest contextMap, Interaction interaction) { var response = new MockHttpServletResponse(); contextMap.arguments().put("resourceType", "ImplementationGuide"); @@ -135,12 +190,25 @@ private McpSchema.CallToolResult getFhirImplementationGuides(McpSchema.CallToolR private McpSchema.CallToolResult getValidationResult(McpSchema.CallToolRequest contextMap, Interaction interaction) { var response = new MockHttpServletResponse(); - var request = new RequestBuilder(restfulServer, contextMap.arguments(), interaction).buildRequest(); + if (contextMap.arguments().containsKey("validationparams")) { + Map map = new java.util.HashMap<>(); + String validationParams = (String) contextMap.arguments().get("validationparams"); + // Parse the validationParams string into a Map + String[] params = validationParams.split(","); + for (String param : params) { + String[] keyValue = param.split("="); + if (keyValue.length == 2) { + map.put(keyValue[0].trim(), keyValue[1].trim()); + } + } + contextMap.arguments().put("query", map); + } else { + Map map = new java.util.HashMap<>(); + map.put("analyzeOutcomeWithAI", "false"); + contextMap.arguments().put("query", map); + } - Map map = new java.util.HashMap<>(); - // we do not want to use AI for validation, this can do mcp client itself - map.put("analyzeOutcomeWithAI", "false"); - contextMap.arguments().put("query", map); + var request = new RequestBuilder(restfulServer, contextMap.arguments(), interaction).buildRequest(); try { restfulServer.handleRequest(interaction.asRequestType(), request, response); diff --git a/matchbox-server/src/main/java/ch/ahdis/matchbox/mcp/ToolFactory.java b/matchbox-server/src/main/java/ch/ahdis/matchbox/mcp/ToolFactory.java index f40b2fc6eb8..5c29a4cae6e 100644 --- a/matchbox-server/src/main/java/ch/ahdis/matchbox/mcp/ToolFactory.java +++ b/matchbox-server/src/main/java/ch/ahdis/matchbox/mcp/ToolFactory.java @@ -21,6 +21,10 @@ public class ToolFactory { "profile": { "type": "string", "description": "The FHIR profile to validate against" + }, + "validationparams": { + "type": "string", + "description": "Additional validation parameters separated by \\",\\". For example: \\"txServer=http://tx.fhir.org,txUseEcosystem=false\\"." } }, "required": ["resource", "profile"] @@ -35,9 +39,9 @@ public class ToolFactory { "includeVersions": { "type": "boolean", "description": "include older versions of the installed FHIR Implementation Guides, defaults to false" + } } } - } """; private static final String LIST_PROFILES_SCHEMA = @@ -57,6 +61,15 @@ public class ToolFactory { } """; + private static final String LIST_VALIDATIONPARAMETERS_SCHEMA = + """ + { + "type": "object", + "properties": { + } + } + """; + private static final String LIST_FHIR_IGS_OUTPUT_SCHEMA = """ { @@ -123,6 +136,16 @@ public static Tool listFhirProfilesToValidateFor() throws JsonProcessingExceptio .build(); } + public static Tool listValidationParameters() throws JsonProcessingException { + return new Tool.Builder() + .name("list-validation-parameters") + .description("List additional available parameters for validation") + .inputSchema(mapper.readValue(LIST_VALIDATIONPARAMETERS_SCHEMA, McpSchema.JsonSchema.class)) +// .outputSchema(LIST_FHIR_IGS_OUTPUT_SCHEMA) + .build(); + } + + public static final ObjectMapper mapper = new ObjectMapper() .enable(JsonParser.Feature.ALLOW_COMMENTS) .enable(JsonParser.Feature.ALLOW_SINGLE_QUOTES) diff --git a/matchbox-server/src/main/java/ch/ahdis/matchbox/util/CrossVersionResourceUtils.java b/matchbox-server/src/main/java/ch/ahdis/matchbox/util/CrossVersionResourceUtils.java index 8de1c5b55eb..8540bfc2c02 100644 --- a/matchbox-server/src/main/java/ch/ahdis/matchbox/util/CrossVersionResourceUtils.java +++ b/matchbox-server/src/main/java/ch/ahdis/matchbox/util/CrossVersionResourceUtils.java @@ -8,6 +8,7 @@ import org.hl7.fhir.convertors.factory.VersionConvertorFactory_43_50; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.r5.model.Resource; +import org.hl7.fhir.instance.model.api.IBase; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -67,6 +68,14 @@ public static void serializeR5(final Resource resource, parser.encodeResourceToWriter(resource, writer); } + public static void serializeR5(final IBase theElement, + final MatchboxFhirFormat format, + final Writer writer) throws IOException { + final var parser = getHapiParser(format, FhirVersionEnum.R5); + parser.setPrettyPrint(true); + writer.append(parser.encodeToString(theElement)); + } + public static org.hl7.fhir.r4.model.Resource convertResource(final org.hl7.fhir.r4b.model.Resource resource) { // Is there a better way of doing this? return VersionConvertorFactory_40_50.convertResource(VersionConvertorFactory_43_50.convertResource(resource)); diff --git a/matchbox-server/src/main/java/ch/ahdis/matchbox/validation/McpValidationService.java b/matchbox-server/src/main/java/ch/ahdis/matchbox/validation/McpValidationService.java deleted file mode 100644 index 4e071f835eb..00000000000 --- a/matchbox-server/src/main/java/ch/ahdis/matchbox/validation/McpValidationService.java +++ /dev/null @@ -1,80 +0,0 @@ -package ch.ahdis.matchbox.validation; - -import ca.uhn.fhir.rest.api.EncodingEnum; -import ch.ahdis.matchbox.CliContext; -import ch.ahdis.matchbox.engine.MatchboxEngine; -import ch.ahdis.matchbox.util.MatchboxEngineSupport; -import org.hl7.fhir.utilities.validation.ValidationMessage; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.ai.tool.annotation.Tool; -import org.springframework.ai.tool.annotation.ToolParam; - -import java.util.List; - -/** - * The service that runs FHIR validations. - */ -public class McpValidationService { - private static final Logger log = LoggerFactory.getLogger(McpValidationService.class); - - private final MatchboxEngineSupport matchboxEngineSupport; - private final CliContext cliContext; - - public McpValidationService(final MatchboxEngineSupport matchboxEngineSupport, - final CliContext cliContext) { - this.matchboxEngineSupport = matchboxEngineSupport; - this.cliContext = cliContext; - } - - /** - * This method is the entry point for the MCP tool. - */ - @Tool(name = "validateResource", description = "Validate a FHIR resource against a profile") - public List mcpToolGetValidation(@ToolParam(description = "The FHIR resource to validate (JSON or XML)") final String resource, - @ToolParam(description = "The FHIR profile to use") final String profile) { - // Check if the parameters are empty - if (resource == null || resource.isBlank()) { - return List.of(new ValidationMessage() - .setLevel(ValidationMessage.IssueSeverity.ERROR) - .setMessage("Resource to validate must not be empty")); - } - if (profile == null || profile.isBlank()) { - return List.of(new ValidationMessage() - .setLevel(ValidationMessage.IssueSeverity.ERROR) - .setMessage("Profile to validate against must not be empty")); - } - // 1. Get a Matchbox engine for the given profile - final MatchboxEngine engine; - try { - engine = this.matchboxEngineSupport.getMatchboxEngine(profile, cliContext, true, false); - } catch (final Exception e) { - log.error("Error while initializing the validation engine", e); - return List.of(new ValidationMessage() - .setLevel(ValidationMessage.IssueSeverity.ERROR) - .setMessage("Error while initializing the validation engine: %s".formatted(e.getMessage())) - ); - } - if (engine == null) { - return List.of(new ValidationMessage() - .setLevel(ValidationMessage.IssueSeverity.ERROR) - .setMessage("Matchbox engine for profile '%s' could not be created, check the installed IGs".formatted( - profile))); - } - - // 2. Run the validation - final var encoding = EncodingEnum.detectEncoding(resource); - final List messages; - try { - messages = ValidationProvider.doValidate(engine, resource, encoding, profile); - } catch (final Exception e) { - log.error("Error during validation", e); - return List.of(new ValidationMessage() - .setLevel(ValidationMessage.IssueSeverity.ERROR) - .setMessage("Error during validation: %s".formatted(e.getMessage())) - ); - } - - return messages; - } -} diff --git a/mkdocs.yml b/mkdocs.yml index 793be169cff..84225fe3363 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ nav: - docker.md - cda.md - 'Tutorial: validation': validation-tutorial.md + - 'Tutorial: validation with ai': validation-with-ai-tutorial.md markdown_extensions: - pymdownx.highlight: anchor_linenums: true diff --git a/pom.xml b/pom.xml index 423a3c1db11..59414e08f19 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ health.matchbox matchbox - 4.0.13 + 4.0.14 pom matchbox An open-source implementation to support testing and implementation of FHIR based solutions and map or