Skip to content
Open
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,41 @@ Acceptable values are `true` and `false`.

Default parameters for each authentication provider are provided as examples in the embedded `application.yaml` file. All providers are disabled by default.

## MCP server for AI agents

WebAPI can expose a [Model Context Protocol](https://modelcontextprotocol.io) server so AI agents can search vocabularies, build concept sets and cohort definitions, validate the cohort SQL and generate cohorts. It is off by default. To turn it on:

```yaml
spring:
ai:
mcp:
server:
enabled: true
```

The server listens at `POST /mcp` (stateless Streamable HTTP; set the path with `spring.ai.mcp.server.streamable-http.mcp-endpoint`). It uses the same authentication as the REST API, so clients send a WebAPI JWT (`Authorization: Bearer ...`) or a personal API key (`X-API-KEY: ...`). Each tool runs with that user's permissions. For example, with Claude Code:

```bash
claude mcp add --transport http webapi https://your-host/WebAPI/mcp --header "X-API-KEY: <key>"
```

| Step | Tools |
|------|-------|
| 1 Concept exploration | `list_sources`, `search_concepts`, `get_concept_details`, `get_concept_hierarchy` |
| 2 Concept sets | `create_concept_set`, `get_concept_set`, `add_concept_to_set`, `remove_concept_from_set`, `count_concept_set_concepts` |
| 3 Cohort criteria | `create_cohort_definition`, `update_cohort_definition`, `get_cohort_definition` |
| 4 SQL validation | `validate_cohort_definition` (Circe checks, concept verification, SqlRender translation; the SQL is not run) |
| 5 Generation | `generate_cohort`, `get_cohort_generation_status` (status, counts, inclusion-rule attrition) |
| Audit | `get_audit_trail` |

Traceability rules the server enforces:

- **Rationale.** Tools that change concept sets or cohort definitions require `clinical_rationale` (at least `mcp.minRationaleLength` characters, default 20) and `evidence_reference`. A call that leaves them out is refused.
- **Verified concepts.** `add_concept_to_set`, `create_cohort_definition` and `update_cohort_definition` look up every concept id in the source vocabulary. If an id does not exist, or the name the caller gave does not match the vocabulary's name, the call is refused and nothing is saved.
- **Tracing.** Send a W3C `traceparent` in the call's `_meta`, plus an optional `reasoning_step`. Each tool call is a span that follows the [OpenTelemetry semantic conventions for MCP](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/): its parent is the `_meta` traceparent, and it links the HTTP request's span. WebAPI returns that span in the result's `_meta.traceparent`, along with `_meta.audit_entry_id`. A call without a traceparent starts a new trace, unless `mcp.requireTraceparent: true`. When WebAPI runs with the OpenTelemetry Java agent, the span is exported together with the SQL it ran, and the audit log records the exported span's ids. An audit row can then be found in the tracing backend by its trace id, and a span by its `ohdsi.mcp.audit_entry_id`. Spans carry the tool, step and outcome, but never the arguments, rationale or error messages. Those stay in the audit log. Without the agent, WebAPI allocates the ids itself.
- **Audit.** Every call is written to `mcp_audit_log` before it runs, then completed with its outcome: `SUCCESS`, `ERROR`, `REJECTED` or `DENIED`. A database trigger makes the table append-only. `get_audit_trail` returns a trace's entries in order, as the decision log for an audit manifest.
- **Provenance.** Calls that create, change, validate or generate a cohort definition or concept set are also recorded against that artifact. `GET /cohortdefinition/{id}/mcp-provenance` and `GET /conceptset/{id}/mcp-provenance` return that history (tool, reasoning step, rationale, evidence, outcome, user, time and trace id; not the argument or result payloads) to anyone who can read the artifact, so Atlas can show why an agent built it that way. These endpoints stay available when the MCP server is turned off.

## Geospatial support

Instructions can be found at [webapi-component-geospatial](https://github.com/OHDSI/webapi-component-geospatial)
Expand Down
27 changes: 27 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
<!-- Shiny -->
<shiny.enabled>false</shiny.enabled>

<!-- Spring AI 1.1.x is the line built for Spring Boot 3.5; 2.x requires Boot 4 -->
<spring-ai.version>1.1.8</spring-ai.version>

</properties>

<build>
Expand Down Expand Up @@ -322,6 +325,13 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.amazon.redshift</groupId>
<artifactId>redshift-jdbc42</artifactId>
Expand Down Expand Up @@ -448,6 +458,23 @@
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- MCP server (Streamable HTTP on Spring MVC); disabled unless mcp.enabled=true -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<!-- MCP tool-call spans. Only the API: without the OpenTelemetry Java agent it is a no-op; with the
agent, the agent provides the implementation. Version managed by Spring Boot. -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk-testing</artifactId>
<scope>test</scope>
</dependency>

<!-- Actuator + Micrometer for metrics and monitoring -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
98 changes: 98 additions & 0 deletions src/main/java/org/ohdsi/webapi/mcp/AuditMcpTools.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package org.ohdsi.webapi.mcp;

import static org.ohdsi.webapi.mcp.ToolDefinition.stringParam;
import static org.ohdsi.webapi.mcp.ToolDefinition.tool;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import org.ohdsi.webapi.mcp.audit.McpAuditEntry;
import org.ohdsi.webapi.mcp.audit.McpAuditService;
import org.ohdsi.webapi.security.authz.AuthorizationService;
import org.ohdsi.webapi.security.identity.WebApiPrincipal;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

/**
* Reads back the audit trail of a trace, as the decision_log of an Audit Manifest. A user sees
* only the entries they made themselves.
*/
@Component
@ConditionalOnProperty(prefix = "spring.ai.mcp.server", name = "enabled", havingValue = "true")
public class AuditMcpTools implements McpToolProvider {

private final McpAuditService audit;
private final AuthorizationService authorizationService;
private final ObjectMapper mapper;

public AuditMcpTools(McpAuditService audit, AuthorizationService authorizationService, ObjectMapper mapper) {
this.audit = audit;
this.authorizationService = authorizationService;
this.mapper = mapper;
}

@Override
public List<ToolDefinition> tools() {
return List.of(tool("get_audit_trail")
.title("Get audit trail")
.description("Returns, in order, every tool call recorded for a trace id by the current user: "
+ "span ids, reasoning step, arguments, clinical rationale, evidence and outcome. Use it to "
+ "build the decision_log of the Audit Manifest from what the server actually recorded.")
.step(ReasoningStep.AUDIT)
.readOnly()
.requiredParam("trace_id", stringParam("The 32-hex-digit W3C trace id."))
.handler(this::trail)
.build());
}

private Map<String, Object> trail(ToolArguments args) {
String traceId = args.string("trace_id").trim().toLowerCase(Locale.ROOT);
if (!traceId.matches("[0-9a-f]{32}")) {
throw new ToolContractException("'trace_id' must be 32 lowercase hex digits");
}
WebApiPrincipal me = authorizationService.getAuthenticatedPrincipal();
List<Map<String, Object>> log = new ArrayList<>();
for (McpAuditEntry e : audit.findByTrace(traceId)) {
if (!Objects.equals(e.getUserLogin(), me.getName())) {
continue;
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("audit_entry_id", e.getId());
m.put("span_id", e.getSpanId());
m.put("parent_span_id", e.getParentSpanId());
m.put("reasoning_step", e.getReasoningStep());
m.put("tool", e.getToolName());
m.put("arguments", readTree(e.getArgumentsJson()));
m.put("clinical_rationale", e.getClinicalRationale());
m.put("evidence_reference", e.getEvidenceReference());
m.put("outcome", e.getOutcome().name());
m.put("result", readTree(e.getResultJson()));
m.put("error_message", e.getErrorMessage());
m.put("started_at", e.getStartedAt() == null ? null : e.getStartedAt().toString());
m.put("finished_at", e.getFinishedAt() == null ? null : e.getFinishedAt().toString());
log.add(m);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("trace_id", traceId);
out.put("entry_count", log.size());
out.put("decision_log", log);
return out;
}

private Object readTree(String json) {
if (json == null) {
return null;
}
try {
return mapper.readTree(json);
} catch (JsonProcessingException e) {
// truncated results are no longer valid JSON; return them as text
return json;
}
}
}
Loading
Loading