Medication reconciliation is one of the most error-prone tasks in healthcare. When a patient moves between care settings - hospital admission, discharge, outpatient follow-up - their medication list has to be assembled from multiple sources that rarely agree. The hospital EHR says one thing, the pharmacy says another, the primary care doctor's chart says a third. Someone has to manually compare all three, catch the conflicts, and produce a single authoritative list.
That someone is usually a pharmacist or nurse, doing it by hand, under time pressure. The stakes are high: medication errors at care transitions cause roughly 30% of hospital readmissions. Two-thirds of adverse drug events are preventable if the right checks are run.
We built MedRecon to automate this workflow using a multi-agent AI system. Three agents, each with a specialized role, coordinating via the Agent-to-Agent (A2A) protocol, with a shared MCP server exposing clinical tools. The result is a full reconciliation report - sources, interactions, allergy checks, dose validation, alternatives - in about 30 seconds.
This is the technical breakdown.
The Architecture
Before explaining why we split this into three agents, here is what the system looks like:
graph TD
FE[Next.js Frontend] -->|HTTP| ORC[Orchestrator :8003]
ORC -->|A2A message/send| SC[Source Collector :8001]
ORC -->|A2A message/send| IC[Interaction Checker :8002]
SC -->|MCP tools| MCP[MedRecon MCP Server :5000]
IC -->|MCP tools| MCP
MCP --> FHIR[HAPI FHIR R4]
MCP --> FDA[OpenFDA API]
MCP --> RX[RxNorm / NLM]
The three agents:
- Source Collector (port 8001): Queries FHIR endpoints to gather medication lists. It calls the same FHIR server three times to simulate three real-world data silos - hospital EHR, pharmacy system, and primary care office. It merges the lists and flags discrepancies.
- Interaction Checker (port 8002): Takes a medication list and runs the full safety suite: drug-drug interactions, allergy cross-reference, dose validation, and therapeutic alternatives.
- Orchestrator (port 8003): Coordinates the other two. It sends tasks to each specialist agent via A2A, collects their outputs, and assembles the final reconciliation report. It never calls MCP tools directly.
Why not a single agent? A monolithic agent handling 11 medications across 7 tools drifts. It forgets to check allergies after checking interactions. It skips dose validation when the interaction check returns a long response. Splitting responsibilities across agents gives each one a narrow, testable job. The Orchestrator's only job is sequencing and synthesis - which is something LLMs do well.
The MCP server exposes 7 clinical tools:
| Tool | What it does |
|---|---|
get_medications |
Query FHIR for patient MedicationRequest resources |
check_interactions |
Check drug pairs against curated DB + OpenFDA |
check_allergies |
Cross-reference meds against patient FHIR AllergyIntolerance |
validate_dose |
Validate against reference ranges for 18 common drugs |
find_alternatives |
Suggest therapeutic alternatives via RxClass ATC |
lookup_drug_info |
Fetch drug details from RxNorm |
reconcile_lists |
Diff and merge medication lists |
Building the MCP Server
The MCP server is TypeScript, using @modelcontextprotocol/sdk. Each tool is a class that registers itself with the server.
The interaction checker is the most clinically interesting tool. It uses a curated database of known interactions as the primary source, then falls back to OpenFDA for anything not in the local DB:
// CheckInteractionsTool.ts
const KNOWN_INTERACTIONS: {
drugs: [string, string];
severity: string;
description: string;
}[] = [
{
drugs: ["warfarin", "amiodarone"],
severity: "SEVERE",
description:
"Amiodarone inhibits CYP2C9, significantly increasing warfarin levels. " +
"Dose reduction of 30-50% typically needed.",
},
{
drugs: ["metoprolol", "verapamil"],
severity: "SEVERE",
description:
"Combined use causes additive negative chronotropic and inotropic effects. " +
"Can result in severe bradycardia or heart block.",
},
// 30+ more pairs...
];
The curated database exists for a practical reason: OpenFDA's DDI data is comprehensive but slow, and the most clinically dangerous interactions deserve deterministic, reliable detection. If a pair matches the local DB, we return immediately with a vetted description. Unknown pairs go to OpenFDA.
The MCP server runs as an Express HTTP server. Agents connect to it at the /mcp endpoint using the standard MCP streamable HTTP transport:
// index.ts
const server = new McpServer({ name: "medrecon", version: "1.0.0" });
new CheckInteractionsTool().register(server);
new GetMedicationsTool().register(server);
// ... register all 7 tools
app.post("/mcp", async (req, res) => {
await transport.handleRequest(req, res, req.body);
});
The A2A Agent Network
Each agent is built with Google ADK and exposed as an A2A server using to_a2a(). The A2A protocol uses JSON-RPC 2.0 - you send a message/send request and get back a Task object with status, artifacts, and conversation history.
Here is the full A2A message function from the Orchestrator:
def _send_a2a_message(agent_url: str, message_text: str) -> dict:
"""Send a task to a sub-agent via A2A JSON-RPC."""
payload = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/send",
"params": {
"message": {
"messageId": str(uuid.uuid4()),
"role": "user",
"parts": [{"kind": "text", "text": message_text}],
}
},
}
response = httpx.post(
agent_url.rstrip("/") + "/",
json=payload,
headers={"Content-Type": "application/json", "X-API-Key": _get_api_key()},
timeout=120, # agents need time to reason + call MCP tools
)
response.raise_for_status()
result = response.json()
# Extract response from artifacts + history (ADK puts final output in artifacts)
task = result.get("result", {})
artifacts = task.get("artifacts", [])
# ... parse and return
One thing worth knowing about ADK: the agent's final response lands in artifacts, not in the status message. The status message is usually a brief summary or empty. If you only check task.status.message, you get incomplete data. Check artifacts first, then fall back to history.
The Orchestrator's agent definition is clean - it knows two tools, both of which are A2A calls to sub-agents:
# orchestrator/agent.py
root_agent = Agent(
name="medrecon_orchestrator",
model="gemini-2.5-flash",
instruction=(
"You coordinate a medication reconciliation workflow.\n"
"WORKFLOW:\n"
"1. Call collect_medications(patient_id, fhir_url)\n"
"2. Call check_safety(patient_id, medication_list)\n"
"3. Assemble the final reconciliation report.\n"
"You do NOT call MCP tools directly. Delegate to specialist agents."
),
tools=[collect_medications, check_safety],
)
The collect_medications and check_safety functions are regular Python functions that call _send_a2a_message internally. ADK registers them as tools automatically via introspection. The Orchestrator treats them like any other tool call - it decides when to call them, passes the right arguments, and receives the sub-agent's full text response back as a string.
The Interaction Checker, similarly, is just an ADK agent with MCP tools bound to it:
# interaction_checker/agent.py
root_agent = Agent(
name="medrecon_interaction_checker",
model="gemini-2.5-flash",
instruction=(
"You perform comprehensive medication safety analysis.\n"
"For each request: check_interactions, check_allergies, "
"validate_dose for each drug, find_alternatives for severe cases."
),
tools=get_mcp_tools(), # returns [check_interactions, check_allergies, ...]
)
Each agent is then wrapped with to_a2a() from the ADK and served via uvicorn:
# shared/app_factory.py
from google.adk.artifacts import InMemoryArtifactService
from google.adk.runners import Runner
from a2a.server.apps import A2AStarlette
def build_a2a_app(agent, agent_card) -> A2AStarlette:
runner = Runner(agent=agent, artifact_service=InMemoryArtifactService())
return A2AStarlette(agent_card=agent_card, runner=runner)
The Frontend
The Next.js frontend has two modes:
Full Pipeline routes the request through the Orchestrator at port 8003. You see each step as it completes: Source Collector pulls medications, Interaction Checker runs safety analysis, Orchestrator assembles the report. A pipeline visualizer component shows which agent is active.
Quick Scan calls the MCP server directly for a fast interaction check - no agent coordination, just raw tool calls from the frontend. Useful for single-drug lookups.
The report panel renders the Orchestrator's markdown output with severity highlighting. SEVERE interactions render with a red badge, MODERATE with yellow. The frontend is deployed to Vercel; the agents and MCP server run on GCP Cloud Run.
Live demo: https://frontend-eta-flax-63.vercel.app
What It Finds
Patient Margaret Chen, ID 131494564 on HAPI FHIR, has 11 active medications including a complex cardiovascular/anticoagulation regimen. Running her through the full pipeline takes about 35 seconds. The report catches:
SEVERE - Metoprolol + Verapamil
Combined use causes additive negative chronotropic and inotropic effects. Can result in severe bradycardia or heart block. Requires cardiology review before continuing.
SEVERE - Warfarin + Amiodarone
Amiodarone inhibits CYP2C9, significantly increasing warfarin's anticoagulant effect. INR monitoring required; typical warfarin dose reduction of 30-50%.
The Interaction Checker also validates doses (flagging if a value is outside reference range), cross-references against the patient's FHIR AllergyIntolerance resources, and suggests therapeutic alternatives for problematic drugs.
This is the same analysis a clinical pharmacist would do - but automatically, every time, across three source systems, with full audit trail.
Stack
- Agents: Google ADK, Gemini 2.5 Flash
- Agent coordination: A2A protocol (JSON-RPC 2.0)
-
Clinical tools: TypeScript MCP server,
@modelcontextprotocol/sdk - FHIR: HAPI FHIR R4 public server, FHIR R4 MedicationRequest
- Drug data: RxNorm (NLM), OpenFDA adverse events API, curated interaction DB
- Frontend: Next.js, Vercel
- Infra: GCP Cloud Run (agents + MCP server)
What's Next
A few things on the roadmap:
Synthea patient generation - We have 5 hand-crafted demo patients in HAPI FHIR now. We want to generate 200+ using Synthea with realistic polypharmacy profiles so the system is stress-tested across more clinical scenarios.
FHIR MedicationStatement output - Right now the system produces a markdown report. In a real clinical workflow, the reconciled list should be written back as FHIR MedicationStatement resources so it can be consumed by downstream EHR systems.
SMART on FHIR / SHARP compliance - The current auth is a simple API key. Real EHR integration requires SMART on FHIR OAuth flows, which is its own project.
Prompt Opinion marketplace - We're looking at publishing the MCP server as a Prompt Opinion integration so other agent builders can add clinical drug-checking to their workflows without building the tooling from scratch.
Code
GitHub: https://github.com/astraedus/medrecon
The repo includes the full MCP server, all three agents, the shared FHIR utilities, scripts to generate demo patients, and deployment configs for Cloud Run.
If you're building healthcare AI, MCP tooling, or multi-agent systems with ADK - happy to answer questions in the comments.
Top comments (0)