Skip to content

Add an MCP server for AI agents, with traced and audited tool calls - #2580

Open
watilde wants to merge 4 commits into
OHDSI:masterfrom
watilde:feature/mcp-server
Open

watilde wants to merge 4 commits into
OHDSI:masterfrom
watilde:feature/mcp-server

Conversation

@watilde

@watilde watilde commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds a Model Context Protocol server to WebAPI so AI agents can run the phenotype workflow through WebAPI itself: search vocabularies, build concept sets and cohort definitions, validate the cohort SQL, generate cohorts and read back counts. Each call is traced and recorded in an audit log on the server.

It is off by default (spring.ai.mcp.server.enabled: false). When enabled it serves POST /mcp (Spring AI 1.1.8, stateless Streamable HTTP). The endpoint is behind the same security chain as the REST API, so clients authenticate with a JWT or X-API-KEY, and every tool runs under that user's existing @PreAuthorize rules.

Tools

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 executed.
5 Generation generate_cohort, get_cohort_generation_status: status, counts, inclusion-rule attrition
Audit get_audit_trail

Rules the server enforces

  • Rationale. Tools that change concept sets or cohorts require clinical_rationale (at least mcp.minRationaleLength characters, default 20) and evidence_reference. A call without them is refused.
  • Verified concepts. Before anything is saved, every concept is looked up in the source vocabulary by id and name. If an id does not exist, or its name does not match the vocabulary, the call is refused and nothing is saved.
  • Tracing. Each tool call is an OpenTelemetry server span that follows the MCP semantic conventions and SEP-414. The parent is _meta.traceparent, the HTTP request's span is linked, and the span is current while the tool runs, so its SQL nests under it. The result's _meta carries the span's traceparent and audit_entry_id. A call without a traceparent starts a new trace (set mcp.requireTraceparent: true to refuse it). Only opentelemetry-api is added. With the OpenTelemetry Java agent the span is exported and the audit row stores its ids, so the audit log and the tracing backend share trace ids. Without the agent the API is a no-op and WebAPI allocates the ids itself. Span attributes are the tool, step, outcome, audit entry id and entity. The arguments, rationale and error messages are not exported, because the tracing backend has no per-artifact access control and may sample.
  • Audit. Each call is written to the new mcp_audit_log table before it runs, then completed with its outcome: SUCCESS, ERROR, REJECTED or DENIED. A trigger makes the table append-only. get_audit_trail returns a trace's entries in order, as a decision log for that user.
  • Provenance. Calls that create, change, validate or generate a cohort definition or concept set are recorded against that artifact (entity_type, entity_id). GET /cohortdefinition/{id}/mcp-provenance and GET /conceptset/{id}/mcp-provenance return that history to anyone who can read the artifact, without the argument or result payloads, so Atlas can show why an agent built it that way. These endpoints are not switched off with the MCP server.

Implementation notes

  • The MCP server bean is excluded from WebAPI's global lazy initialization. If it were left lazy it would never be created, and /mcp would not respond.
  • Enabling MCP does not replace the application's Boot ObjectMapper. Spring AI's mcpServerObjectMapper is not a default candidate, and McpServerIT checks this.

Known limitation

generate_cohort queues a batch job, and the OpenTelemetry agent carries the submitting request's context into the job's thread pool. The generation SQL therefore lands in the tool call's trace. This is the same limitation the Atlas3 observability setup documents. A separate root span per job, linked to the request, is #2557.

Test plan

  • TraceContextTest, McpToolFactoryTest: traceparent parsing, rationale enforcement, audit-before-run, outcome mapping; OpenTelemetry span against an in-memory SDK (parent from _meta, link to the HTTP span, joining the HTTP trace without _meta, tool_error on refusal, no rationale in attributes, audit row stores the exported ids) and the no-op fallback
  • McpServerIT: JSON-RPC over HTTP against embedded Postgres. Covers tools/list, trace continuation, the audit row, refusal of mismatched concept names and of missing rationale, the append-only trigger, SQL translation, unverified concepts in cohorts, and calls without a traceparent
  • McpServerIT provenance: a concept set's and a cohort's mcp-provenance list their create/change/generate calls (including a refused one) and leave out read-only calls and payloads
  • McpServerIT end-to-end generation on the bundled Eunomia CDM (src/test/resources/eunomia.csv.zip, loaded with COPY): generate_cohort runs the batch job for a first-GI-hemorrhage cohort with an "Age 40+" rule, and get_cohort_generation_status reports COMPLETE with counts and attrition (479 at base, 204 kept) that match the same cohort counted with plain SQL
  • Full mvn test: 176 + 46 tests, 0 failures (before the generation IT was added; McpServerIT was rerun with it: 12/12)
  • generate_cohort against a full-size CDM

WebAPI can now serve the Model Context Protocol at POST /mcp (Spring AI
1.1.8, stateless Streamable HTTP), off by default behind
spring.ai.mcp.server.enabled. It sits behind the REST API's security
chain, so agents authenticate with a JWT or X-API-KEY and every tool runs
under that user's existing @PreAuthorize rules.

Tools cover the phenotype workflow: vocabulary search and hierarchy,
concept set editing, cohort definition create/update, Circe checks plus
SqlRender translation, cohort generation and status with attrition.

Calls are made traceable on the server side rather than trusted to the
client:
- tools that change concept sets or cohorts require clinical_rationale
  and evidence_reference;
- concept ids are re-checked against the vocabulary by id and name before
  anything is saved, so assumed ids are refused;
- _meta.traceparent is continued with a new span per call and returned in
  the result's _meta;
- each call is written to mcp_audit_log before it runs and completed with
  its outcome; a trigger makes the table append-only, and get_audit_trail
  reads a trace back as a decision log.

The MCP server bean is excluded from lazy initialization; left lazy it is
never created and /mcp cannot answer.
McpServerIT streams person, observation_period, condition_occurrence,
concept and concept_ancestor from src/test/resources/eunomia.csv.zip
into the embedded CDM with COPY. Through MCP it creates a cohort for the
first gastrointestinal hemorrhage with an "Age 40+" inclusion rule,
generates it, and polls get_cohort_generation_status until COMPLETE.

The counts and attrition it reports (479 at base, 204 kept by the rule)
are checked against the same cohort counted with plain SQL on the CDM,
and against the subjects written to results.cohort.
@watilde
watilde force-pushed the feature/mcp-server branch from 9bae5c1 to b3dee5f Compare November 4, 2026 12:00
Atlas needs to show, on a cohort definition or concept set, which agent
calls built it and why. The audit log only had the trace, so finding the
calls about one artifact meant searching result_json.

- mcp_audit_log gets entity_type and entity_id (with an index). Tools
  that create, change, validate or generate a cohort definition or
  concept set declare their entity; the id is taken from the arguments
  before the call runs, or for a create from the result once it has.
  The append-only trigger lets the entity be filled in once, never
  changed. Read-only tools (get_*, counts, status polls) record none.
- GET /cohortdefinition/{id}/mcp-provenance and
  GET /conceptset/{id}/mcp-provenance return those calls in order: tool,
  reasoning step, rationale, evidence, outcome, user, times and trace.
  They use the artifact's own read permission, leave out the argument
  and result payloads, and stay available when MCP is turned off.
watilde added a commit to watilde/Atlas3 that referenced this pull request Nov 4, 2026
The id is always the one in WebAPI's mcp_audit_log. It matches spans in
a tracing backend only when WebAPI runs with the OpenTelemetry Java
agent, which OHDSI/WebAPI#2580 now records the exported span ids for.
The tool-call span ids were WebAPI's own and never exported, so an audit
row's trace id could not be found in a tracing backend. Also, the
OpenTelemetry Java agent traced the HTTP request separately, under the
traceparent header rather than the one MCP carries in _meta.

Follow the OpenTelemetry semantic conventions for MCP and SEP-414 instead:
each tools/call is a SERVER span "tools/call <tool>". Its parent is the
W3C context in params._meta, it links the ambient (HTTP request) span,
and it is current while the tool runs, so the agent's JDBC spans nest
under it. The audit row and the result's _meta.traceparent carry that
span's ids, so the audit log and the tracing backend share trace ids.

Only opentelemetry-api is added (version from Spring Boot). With the agent
it is the agent's implementation; without it, it is a no-op and WebAPI
allocates the ids as before. Span attributes are mcp.method.name,
gen_ai.tool.name, gen_ai.operation.name, error.type=tool_error and
WebAPI's step, outcome, audit entry id and entity. The arguments,
rationale and error messages stay in the audit log.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant