# UpToDate for Agents — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Expand OpenMedicine from 4 MCP tools to 10, adding differential diagnosis, treatment pathways, semantic search, and clinical routing — transforming any connected agent into a medical super-agent.

**Architecture:** Three phases shipped independently. Phase 1 adds new content types (differentials, pathways) as JSON data files + Pydantic engines + MCP tools, following the existing renal-dose-adjustment pattern. Phase 2 adds pre-computed embedding-based semantic search. Phase 3 adds a rule-based clinical routing engine. All new content requires DOI-backed evidence.

**Tech Stack:** Python 3.10+, Pydantic v2, MCP SDK, numpy (Phase 2 only for cosine similarity), OpenAI embeddings API (build-time only in Phase 2).

---

## Phase 1: Clinical Workflow Tools

### Task 1: Differential Diagnosis Data Schema and Engine

**Files:**
- Create: `src/open_medicine/mcp/differentials/__init__.py`
- Create: `src/open_medicine/mcp/differentials/engine.py`
- Create: `src/open_medicine/mcp/differentials/data/` (directory)
- Test: `tests/test_differentials.py`

**Step 1: Write the failing test for the differential engine**

Create `tests/test_differentials.py`:

```python
"""Tests for the differential diagnosis engine."""
import pytest
from open_medicine.mcp.differentials.engine import (
    search_differentials,
    get_differential,
    DifferentialParams,
)


def test_search_differentials_returns_matches():
    """Searching for 'chest pain' should return at least one differential."""
    results = search_differentials("chest pain")
    assert len(results) > 0
    assert "differential_id" in results[0]
    assert "title" in results[0]
    assert "description" in results[0]


def test_search_differentials_no_match():
    """Searching for nonsense returns empty list."""
    results = search_differentials("xyznonexistent123")
    assert results == []


def test_get_differential_returns_clinical_result():
    """Retrieving a known differential returns a ClinicalResult."""
    from open_medicine.foundation.base import ClinicalResult
    params = DifferentialParams(
        differential_id="chest_pain",
        age=55,
        sex="male",
    )
    result = get_differential(params)
    assert isinstance(result, ClinicalResult)
    assert result.evidence.source_doi != ""
    # Value should contain diagnoses list
    assert "diagnoses" in result.value


def test_get_differential_unknown_id():
    """Unknown differential_id returns an error result."""
    params = DifferentialParams(
        differential_id="nonexistent_thing",
        age=30,
        sex="female",
    )
    result = get_differential(params)
    assert "not_found" in str(result.value)


def test_differential_diagnoses_have_required_fields():
    """Each diagnosis in the differential must have required fields."""
    params = DifferentialParams(
        differential_id="chest_pain",
        age=55,
        sex="male",
    )
    result = get_differential(params)
    for dx in result.value["diagnoses"]:
        assert "name" in dx
        assert "likelihood" in dx
        assert dx["likelihood"] in ("common", "less_common", "must_not_miss")
        assert "key_features" in dx
        assert "recommended_tests" in dx
```

**Step 2: Run test to verify it fails**

Run: `uv run python -m pytest tests/test_differentials.py -v`
Expected: FAIL — module not found

**Step 3: Create the data schema and seed data file**

Create `src/open_medicine/mcp/differentials/__init__.py` (empty).

Create `src/open_medicine/mcp/differentials/data/chest_pain.json`:

```json
{
  "differential_id": "chest_pain",
  "title": "Chest Pain Differential Diagnosis",
  "description": "Differential diagnosis for acute chest pain in adults, covering cardiac, pulmonary, GI, and musculoskeletal etiologies.",
  "keywords": ["chest pain", "angina", "ACS", "PE", "pneumothorax", "GERD", "costochondritis"],
  "source_doi": "10.1016/j.jacc.2021.09.024",
  "evidence_level": "Clinical Practice Guideline",
  "source_description": "2021 AHA/ACC Chest Pain Guideline — Evaluation and Diagnosis of Chest Pain.",
  "diagnoses": [
    {
      "name": "Acute Coronary Syndrome (ACS)",
      "likelihood": "must_not_miss",
      "key_features": [
        "Substernal pressure/squeezing",
        "Radiation to left arm, jaw, or back",
        "Diaphoresis, dyspnea, nausea",
        "Worse with exertion"
      ],
      "red_flags": [
        "ST changes on ECG",
        "Elevated troponin",
        "Hemodynamic instability"
      ],
      "recommended_tests": [
        "ECG (12-lead)",
        "Serial troponin",
        "calculate_heart_score",
        "calculate_timi_ua_nstemi"
      ],
      "related_guidelines": ["acc_aha_stemi_2013"],
      "age_modifier": "risk_increases_over_40",
      "sex_modifier": "atypical_presentation_more_common_in_female"
    },
    {
      "name": "Pulmonary Embolism",
      "likelihood": "must_not_miss",
      "key_features": [
        "Pleuritic chest pain",
        "Acute dyspnea",
        "Tachycardia",
        "Unilateral leg swelling"
      ],
      "red_flags": [
        "Hypotension / shock",
        "Severe hypoxia",
        "Right heart strain on echo"
      ],
      "recommended_tests": [
        "D-dimer",
        "CT pulmonary angiography",
        "calculate_wells_pe",
        "calculate_perc"
      ],
      "related_guidelines": ["wells_pe_2000", "ash_vte_2020"],
      "age_modifier": null,
      "sex_modifier": "increased_risk_with_oral_contraceptives_in_female"
    },
    {
      "name": "Aortic Dissection",
      "likelihood": "must_not_miss",
      "key_features": [
        "Sudden severe tearing/ripping pain",
        "Radiation to back",
        "Blood pressure differential between arms",
        "New aortic regurgitation murmur"
      ],
      "red_flags": [
        "Pulse deficits",
        "Widened mediastinum on CXR",
        "Neurological deficits"
      ],
      "recommended_tests": [
        "CT angiography of aorta",
        "D-dimer",
        "Transthoracic echocardiography"
      ],
      "related_guidelines": [],
      "age_modifier": "risk_increases_over_60",
      "sex_modifier": null
    },
    {
      "name": "Pneumothorax",
      "likelihood": "less_common",
      "key_features": [
        "Sudden pleuritic chest pain",
        "Acute dyspnea",
        "Decreased breath sounds unilaterally",
        "Hyperresonance to percussion"
      ],
      "red_flags": [
        "Tracheal deviation (tension pneumothorax)",
        "Hemodynamic instability"
      ],
      "recommended_tests": [
        "Chest X-ray",
        "Point-of-care ultrasound"
      ],
      "related_guidelines": [],
      "age_modifier": "primary_spontaneous_more_common_in_young_tall_males",
      "sex_modifier": null
    },
    {
      "name": "Gastroesophageal Reflux (GERD)",
      "likelihood": "common",
      "key_features": [
        "Burning substernal pain",
        "Worse after meals or lying down",
        "Relieved by antacids",
        "Sour taste / regurgitation"
      ],
      "red_flags": [
        "Dysphagia",
        "Unintentional weight loss",
        "GI bleeding"
      ],
      "recommended_tests": [
        "Trial of PPI therapy",
        "ECG to rule out cardiac cause first"
      ],
      "related_guidelines": [],
      "age_modifier": null,
      "sex_modifier": null
    },
    {
      "name": "Musculoskeletal / Costochondritis",
      "likelihood": "common",
      "key_features": [
        "Localized tenderness on palpation",
        "Reproducible with movement or palpation",
        "Sharp, positional pain",
        "No associated dyspnea or diaphoresis"
      ],
      "red_flags": [],
      "recommended_tests": [
        "Clinical examination",
        "ECG to rule out cardiac cause first"
      ],
      "related_guidelines": [],
      "age_modifier": null,
      "sex_modifier": null
    }
  ]
}
```

**Step 4: Implement the differential engine**

Create `src/open_medicine/mcp/differentials/engine.py`:

```python
"""Differential diagnosis engine — search and retrieve clinical differentials."""
import json
from pathlib import Path
from typing import Any, Optional

from pydantic import BaseModel, Field

from open_medicine.foundation.base import ClinicalResult, Evidence


class DifferentialParams(BaseModel):
    """Parameters for retrieving a differential diagnosis."""
    differential_id: str = Field(..., description="The differential ID (e.g., 'chest_pain')")
    age: Optional[int] = Field(None, description="Patient age in years")
    sex: Optional[str] = Field(None, description="Patient sex ('male' or 'female')")


_DATA_DIR = Path(__file__).parent / "data"


def _load_all_differentials() -> dict[str, dict[str, Any]]:
    """Load all differential JSON files from the data directory."""
    differentials = {}
    for fp in _DATA_DIR.glob("*.json"):
        with open(fp, "r", encoding="utf-8") as f:
            data = json.load(f)
        differentials[data["differential_id"]] = data
    return differentials


_DIFFERENTIAL_DB = _load_all_differentials()


def search_differentials(query: str) -> list[dict[str, Any]]:
    """Search differentials by keyword matching against titles, descriptions, and keywords."""
    query_lower = query.lower()
    results = []
    for diff_id, diff in _DIFFERENTIAL_DB.items():
        searchable = " ".join([
            diff["title"].lower(),
            diff["description"].lower(),
            diff_id.lower(),
            " ".join(k.lower() for k in diff.get("keywords", [])),
        ])
        if query_lower in searchable:
            results.append({
                "differential_id": diff_id,
                "title": diff["title"],
                "description": diff["description"],
                "doi": diff["source_doi"],
            })
    return results


def get_differential(params: DifferentialParams) -> ClinicalResult:
    """Retrieve a full differential diagnosis with ranked diagnoses."""
    if params.differential_id not in _DIFFERENTIAL_DB:
        available = sorted(_DIFFERENTIAL_DB.keys())
        return ClinicalResult(
            value={"status": "not_found", "available_differentials": available},
            interpretation=f"Differential '{params.differential_id}' not found. Available: {', '.join(available)}",
            evidence=Evidence(
                source_doi="N/A",
                level="N/A",
                description="Differential not in database.",
            ),
        )

    diff = _DIFFERENTIAL_DB[params.differential_id]

    # Build the diagnoses list with optional age/sex annotations
    diagnoses = []
    for dx in diff["diagnoses"]:
        entry = {
            "name": dx["name"],
            "likelihood": dx["likelihood"],
            "key_features": dx["key_features"],
            "red_flags": dx.get("red_flags", []),
            "recommended_tests": dx["recommended_tests"],
            "related_guidelines": dx.get("related_guidelines", []),
        }

        # Add contextual notes based on age/sex if modifiers exist
        notes = []
        if params.age and dx.get("age_modifier"):
            notes.append(f"Age context: {dx['age_modifier']}")
        if params.sex and dx.get("sex_modifier"):
            notes.append(f"Sex context: {dx['sex_modifier']}")
        if notes:
            entry["contextual_notes"] = notes

        diagnoses.append(entry)

    # Sort: must_not_miss first, then less_common, then common
    priority = {"must_not_miss": 0, "less_common": 1, "common": 2}
    diagnoses.sort(key=lambda d: priority.get(d["likelihood"], 99))

    value = {
        "differential_id": diff["differential_id"],
        "title": diff["title"],
        "diagnoses": diagnoses,
    }

    interpretation = (
        f"{diff['title']}: {len(diagnoses)} diagnoses ranked by clinical priority. "
        f"Must-not-miss: {', '.join(d['name'] for d in diagnoses if d['likelihood'] == 'must_not_miss')}."
    )

    evidence = Evidence(
        source_doi=diff["source_doi"],
        level=diff["evidence_level"],
        description=diff["source_description"],
    )

    return ClinicalResult(
        value=value,
        interpretation=interpretation,
        evidence=evidence,
    )
```

**Step 5: Run tests to verify they pass**

Run: `uv run python -m pytest tests/test_differentials.py -v`
Expected: All 5 tests PASS

**Step 6: Commit**

```bash
git add src/open_medicine/mcp/differentials/ tests/test_differentials.py
git commit -m "feat: add differential diagnosis engine with chest pain seed data"
```

---

### Task 2: Treatment Pathway Data Schema and Engine

**Files:**
- Create: `src/open_medicine/mcp/pathways/__init__.py`
- Create: `src/open_medicine/mcp/pathways/engine.py`
- Create: `src/open_medicine/mcp/pathways/data/` (directory)
- Test: `tests/test_pathways.py`

**Step 1: Write the failing test for the pathway engine**

Create `tests/test_pathways.py`:

```python
"""Tests for the treatment pathway engine."""
import pytest
from open_medicine.mcp.pathways.engine import (
    search_pathways,
    get_pathway,
    PathwayParams,
)


def test_search_pathways_returns_matches():
    """Searching for 'atrial fibrillation' should return at least one pathway."""
    results = search_pathways("atrial fibrillation")
    assert len(results) > 0
    assert "pathway_id" in results[0]
    assert "title" in results[0]


def test_search_pathways_no_match():
    """Searching for nonsense returns empty list."""
    results = search_pathways("xyznonexistent123")
    assert results == []


def test_get_pathway_returns_clinical_result():
    """Retrieving a known pathway returns a ClinicalResult."""
    from open_medicine.foundation.base import ClinicalResult
    params = PathwayParams(
        pathway_id="afib_anticoagulation",
    )
    result = get_pathway(params)
    assert isinstance(result, ClinicalResult)
    assert result.evidence.source_doi != ""
    assert "steps" in result.value


def test_get_pathway_unknown_id():
    """Unknown pathway_id returns an error result."""
    params = PathwayParams(
        pathway_id="nonexistent_pathway",
    )
    result = get_pathway(params)
    assert "not_found" in str(result.value)


def test_pathway_steps_have_required_fields():
    """Each step in the pathway must have required fields."""
    params = PathwayParams(
        pathway_id="afib_anticoagulation",
    )
    result = get_pathway(params)
    for step in result.value["steps"]:
        assert "order" in step
        assert "name" in step
        assert "description" in step
        assert "evidence_doi" in step


def test_pathway_with_contraindications():
    """Pathway with contraindications should filter steps."""
    params = PathwayParams(
        pathway_id="afib_anticoagulation",
        contraindications=["active_major_bleeding"],
    )
    result = get_pathway(params)
    # Should still return a result, but with warnings
    assert isinstance(result.value, dict)
```

**Step 2: Run test to verify it fails**

Run: `uv run python -m pytest tests/test_pathways.py -v`
Expected: FAIL — module not found

**Step 3: Create seed data file**

Create `src/open_medicine/mcp/pathways/__init__.py` (empty).

Create `src/open_medicine/mcp/pathways/data/afib_anticoagulation.json`:

```json
{
  "pathway_id": "afib_anticoagulation",
  "title": "Atrial Fibrillation Anticoagulation Pathway",
  "description": "Evidence-based treatment pathway for anticoagulation in non-valvular atrial fibrillation.",
  "keywords": ["atrial fibrillation", "AFib", "anticoagulation", "stroke prevention", "DOAC", "warfarin"],
  "source_doi": "10.1161/CIR.0000000000001193",
  "evidence_level": "Clinical Practice Guideline",
  "source_description": "2023 ACC/AHA/ACCP/HRS Guideline for Diagnosis and Management of Atrial Fibrillation.",
  "contraindication_warnings": {
    "active_major_bleeding": "Active major bleeding is an absolute contraindication to anticoagulation. Address bleeding source first.",
    "severe_thrombocytopenia": "Platelet count <50,000/uL — anticoagulation risk may outweigh benefit. Hematology consult recommended."
  },
  "steps": [
    {
      "order": 1,
      "name": "Assess stroke risk",
      "description": "Calculate CHA2DS2-VASc score to determine stroke risk and need for anticoagulation.",
      "action_type": "calculate",
      "tool_reference": "calculate_chadsvasc",
      "evidence_doi": "10.1161/CIR.0000000000001193",
      "decision_points": [
        {"condition": "CHA2DS2-VASc >= 2 (male) or >= 3 (female)", "next_step": 2},
        {"condition": "CHA2DS2-VASc 1 (male) or 2 (female)", "next_step": 2, "note": "Anticoagulation may be considered"},
        {"condition": "CHA2DS2-VASc 0 (male) or 1 (female)", "next_step": null, "note": "Anticoagulation not recommended"}
      ]
    },
    {
      "order": 2,
      "name": "Assess bleeding risk",
      "description": "Calculate HAS-BLED score to evaluate bleeding risk. High score does not contraindicate anticoagulation but identifies modifiable risk factors.",
      "action_type": "calculate",
      "tool_reference": "calculate_hasbled",
      "evidence_doi": "10.1161/CIR.0000000000001193",
      "decision_points": [
        {"condition": "HAS-BLED >= 3", "next_step": 3, "note": "High bleeding risk — address modifiable factors, closer monitoring"},
        {"condition": "HAS-BLED < 3", "next_step": 3}
      ]
    },
    {
      "order": 3,
      "name": "Assess renal function",
      "description": "Calculate CrCl (Cockcroft-Gault) for DOAC dose adjustment. eGFR (CKD-EPI) is acceptable but CrCl preferred for DOAC dosing.",
      "action_type": "calculate",
      "tool_reference": "calculate_cockcroft_gault",
      "evidence_doi": "10.1161/CIR.0000000000001193",
      "decision_points": [
        {"condition": "CrCl >= 30 mL/min", "next_step": 4},
        {"condition": "CrCl 15-29 mL/min", "next_step": 5, "note": "Limited DOAC options; consider dose adjustment"},
        {"condition": "CrCl < 15 mL/min or dialysis", "next_step": 6}
      ]
    },
    {
      "order": 4,
      "name": "First-line: DOAC therapy",
      "description": "DOACs are preferred over warfarin for most patients with non-valvular AF. Apixaban or rivaroxaban are most commonly used.",
      "action_type": "prescribe",
      "tool_reference": "calculate_apixaban_dosing",
      "evidence_doi": "10.1161/CIR.0000000000001193",
      "options": [
        {
          "medication": "Apixaban",
          "standard_dose": "5 mg PO BID",
          "reduced_dose_criteria": "Age >= 80, weight <= 60 kg, or creatinine >= 1.5 mg/dL (need 2 of 3)",
          "reduced_dose": "2.5 mg PO BID",
          "calculator": "calculate_apixaban_dosing"
        },
        {
          "medication": "Rivaroxaban",
          "standard_dose": "20 mg PO daily with evening meal",
          "reduced_dose_criteria": "CrCl 15-50 mL/min",
          "reduced_dose": "15 mg PO daily with evening meal",
          "calculator": "calculate_rivaroxaban_dosing"
        },
        {
          "medication": "Dabigatran",
          "standard_dose": "150 mg PO BID",
          "reduced_dose_criteria": "CrCl 30-50 mL/min with P-gp inhibitor",
          "reduced_dose": "75 mg PO BID",
          "calculator": "calculate_dabigatran_dosing"
        },
        {
          "medication": "Edoxaban",
          "standard_dose": "60 mg PO daily",
          "reduced_dose_criteria": "CrCl 15-50 mL/min, weight <= 60 kg, or P-gp inhibitor",
          "reduced_dose": "30 mg PO daily",
          "calculator": "calculate_edoxaban_dosing"
        }
      ],
      "decision_points": []
    },
    {
      "order": 5,
      "name": "Reduced renal function: Limited DOAC options",
      "description": "CrCl 15-29 mL/min. Apixaban and rivaroxaban have data in this range. Dabigatran contraindicated below CrCl 30. Warfarin is an alternative.",
      "action_type": "prescribe",
      "tool_reference": "calculate_renal_dose_adjustment",
      "evidence_doi": "10.1161/CIR.0000000000001193",
      "options": [
        {"medication": "Apixaban 5 mg BID (or 2.5 mg if dose-reduction criteria met)", "calculator": "calculate_apixaban_dosing"},
        {"medication": "Rivaroxaban 15 mg daily", "calculator": "calculate_rivaroxaban_dosing"},
        {"medication": "Warfarin (INR target 2.0-3.0)", "calculator": "calculate_warfarin_initiation"}
      ],
      "decision_points": []
    },
    {
      "order": 6,
      "name": "Severe CKD / Dialysis: Warfarin or apixaban",
      "description": "CrCl < 15 mL/min or on dialysis. Most DOACs lack evidence. Apixaban 5 mg BID has limited data in ESKD. Warfarin is traditional choice. Shared decision-making essential.",
      "action_type": "prescribe",
      "tool_reference": "calculate_warfarin_initiation",
      "evidence_doi": "10.1161/CIR.0000000000001193",
      "options": [
        {"medication": "Warfarin (INR target 2.0-3.0)", "calculator": "calculate_warfarin_initiation"},
        {"medication": "Apixaban 5 mg BID (off-label in ESKD, limited data)", "calculator": "calculate_apixaban_dosing"}
      ],
      "decision_points": []
    }
  ]
}
```

**Step 4: Implement the pathway engine**

Create `src/open_medicine/mcp/pathways/engine.py`:

```python
"""Treatment pathway engine — search and retrieve evidence-based treatment pathways."""
import json
from pathlib import Path
from typing import Any, Optional

from pydantic import BaseModel, Field

from open_medicine.foundation.base import ClinicalResult, Evidence


class PathwayParams(BaseModel):
    """Parameters for retrieving a treatment pathway."""
    pathway_id: str = Field(..., description="The pathway ID (e.g., 'afib_anticoagulation')")
    contraindications: Optional[list[str]] = Field(None, description="List of contraindication keys (e.g., ['active_major_bleeding'])")


_DATA_DIR = Path(__file__).parent / "data"


def _load_all_pathways() -> dict[str, dict[str, Any]]:
    """Load all pathway JSON files from the data directory."""
    pathways = {}
    for fp in _DATA_DIR.glob("*.json"):
        with open(fp, "r", encoding="utf-8") as f:
            data = json.load(f)
        pathways[data["pathway_id"]] = data
    return pathways


_PATHWAY_DB = _load_all_pathways()


def search_pathways(query: str) -> list[dict[str, Any]]:
    """Search pathways by keyword matching against titles, descriptions, and keywords."""
    query_lower = query.lower()
    results = []
    for pw_id, pw in _PATHWAY_DB.items():
        searchable = " ".join([
            pw["title"].lower(),
            pw["description"].lower(),
            pw_id.lower(),
            " ".join(k.lower() for k in pw.get("keywords", [])),
        ])
        if query_lower in searchable:
            results.append({
                "pathway_id": pw_id,
                "title": pw["title"],
                "description": pw["description"],
                "doi": pw["source_doi"],
            })
    return results


def get_pathway(params: PathwayParams) -> ClinicalResult:
    """Retrieve a full treatment pathway with decision tree."""
    if params.pathway_id not in _PATHWAY_DB:
        available = sorted(_PATHWAY_DB.keys())
        return ClinicalResult(
            value={"status": "not_found", "available_pathways": available},
            interpretation=f"Pathway '{params.pathway_id}' not found. Available: {', '.join(available)}",
            evidence=Evidence(
                source_doi="N/A",
                level="N/A",
                description="Pathway not in database.",
            ),
        )

    pw = _PATHWAY_DB[params.pathway_id]

    # Check contraindications
    warnings = []
    if params.contraindications:
        contra_map = pw.get("contraindication_warnings", {})
        for c in params.contraindications:
            if c in contra_map:
                warnings.append(contra_map[c])

    value = {
        "pathway_id": pw["pathway_id"],
        "title": pw["title"],
        "steps": pw["steps"],
        "contraindication_warnings": warnings,
    }

    interpretation = (
        f"{pw['title']}: {len(pw['steps'])} steps from assessment to treatment. "
        f"Follow decision points at each step to navigate the pathway."
    )
    if warnings:
        interpretation += f" WARNINGS: {'; '.join(warnings)}"

    evidence = Evidence(
        source_doi=pw["source_doi"],
        level=pw["evidence_level"],
        description=pw["source_description"],
    )

    return ClinicalResult(
        value=value,
        interpretation=interpretation,
        evidence=evidence,
    )
```

**Step 5: Run tests to verify they pass**

Run: `uv run python -m pytest tests/test_pathways.py -v`
Expected: All 6 tests PASS

**Step 6: Commit**

```bash
git add src/open_medicine/mcp/pathways/ tests/test_pathways.py
git commit -m "feat: add treatment pathway engine with AFib anticoagulation seed data"
```

---

### Task 3: Register New Tools in MCP Server

**Files:**
- Modify: `src/open_medicine/mcp/server.py`
- Test: `tests/test_mcp_tools.py`

**Step 1: Write the failing test for the new MCP tools**

Create `tests/test_mcp_tools.py`:

```python
"""Tests for MCP tool registration — verify all 10 tools are listed."""
import pytest
import asyncio
from open_medicine.mcp.server import handle_list_tools


@pytest.fixture
def tools():
    return asyncio.get_event_loop().run_until_complete(handle_list_tools())


def test_tool_count(tools):
    """Should have 8 tools (original 4 + 4 new)."""
    assert len(tools) == 8


def test_new_differential_tools_exist(tools):
    """New differential tools should be registered."""
    names = [t.name for t in tools]
    assert "search_differential_diagnosis" in names
    assert "get_differential_diagnosis" in names


def test_new_pathway_tools_exist(tools):
    """New pathway tools should be registered."""
    names = [t.name for t in tools]
    assert "search_treatment_pathways" in names
    assert "get_treatment_pathway" in names
```

**Step 2: Run test to verify it fails**

Run: `uv run python -m pytest tests/test_mcp_tools.py -v`
Expected: FAIL — only 4 tools found

**Step 3: Add the 4 new tools to server.py**

Add to `handle_list_tools()` in `src/open_medicine/mcp/server.py` (after the existing 4 tools):

```python
        types.Tool(
            name="search_differential_diagnosis",
            description="Searches available differential diagnoses by symptoms, presentations, or keywords. Returns matching differential IDs and descriptions.",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Keywords to match against differential diagnoses (e.g. 'chest pain', 'dyspnea', 'headache')."
                    }
                },
                "required": ["query"]
            }
        ),
        types.Tool(
            name="get_differential_diagnosis",
            description="Retrieves a full ranked differential diagnosis with must-not-miss conditions, key features, red flags, and recommended tests/calculators. Use search_differential_diagnosis first to find the ID.",
            inputSchema={
                "type": "object",
                "properties": {
                    "differential_id": {
                        "type": "string",
                        "description": "The exact differential ID returned from search_differential_diagnosis."
                    },
                    "age": {
                        "type": "integer",
                        "description": "Patient age in years (optional, for age-specific annotations)."
                    },
                    "sex": {
                        "type": "string",
                        "description": "Patient sex — 'male' or 'female' (optional, for sex-specific annotations)."
                    }
                },
                "required": ["differential_id"]
            }
        ),
        types.Tool(
            name="search_treatment_pathways",
            description="Searches available evidence-based treatment pathways by diagnosis or keywords. Returns matching pathway IDs and descriptions.",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Keywords to match against treatment pathways (e.g. 'atrial fibrillation anticoagulation', 'DVT treatment')."
                    }
                },
                "required": ["query"]
            }
        ),
        types.Tool(
            name="get_treatment_pathway",
            description="Retrieves a full evidence-based treatment pathway with step-by-step decision tree, medication options, dose calculators, and guideline citations. Use search_treatment_pathways first to find the ID.",
            inputSchema={
                "type": "object",
                "properties": {
                    "pathway_id": {
                        "type": "string",
                        "description": "The exact pathway ID returned from search_treatment_pathways."
                    },
                    "contraindications": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "List of contraindication keys (e.g. ['active_major_bleeding']). Pathway will include warnings for matched contraindications."
                    }
                },
                "required": ["pathway_id"]
            }
        ),
```

Add to `handle_call_tool()` in the same file — add import at top and elif branches:

```python
# Add at top of file:
from open_medicine.mcp.differentials.engine import search_differentials, get_differential, DifferentialParams
from open_medicine.mcp.pathways.engine import search_pathways, get_pathway, PathwayParams
```

Add these elif branches before the final `else`:

```python
    elif name == "search_differential_diagnosis":
        query = (arguments or {}).get("query", "")
        results = search_differentials(query)
        import json
        return [
            types.TextContent(
                type="text",
                text=json.dumps({"matches": results}, indent=2)
            )
        ]

    elif name == "get_differential_diagnosis":
        diff_id = (arguments or {}).get("differential_id", "")
        age = (arguments or {}).get("age")
        sex = (arguments or {}).get("sex")
        try:
            params = DifferentialParams(
                differential_id=diff_id,
                age=age,
                sex=sex,
            )
            result = get_differential(params)
            return [
                types.TextContent(
                    type="text",
                    text=result.model_dump_json(indent=2)
                )
            ]
        except Exception as e:
            return [
                types.TextContent(
                    type="text",
                    text=f"Error: {e}"
                )
            ]

    elif name == "search_treatment_pathways":
        query = (arguments or {}).get("query", "")
        results = search_pathways(query)
        import json
        return [
            types.TextContent(
                type="text",
                text=json.dumps({"matches": results}, indent=2)
            )
        ]

    elif name == "get_treatment_pathway":
        pw_id = (arguments or {}).get("pathway_id", "")
        contras = (arguments or {}).get("contraindications")
        try:
            params = PathwayParams(
                pathway_id=pw_id,
                contraindications=contras,
            )
            result = get_pathway(params)
            return [
                types.TextContent(
                    type="text",
                    text=result.model_dump_json(indent=2)
                )
            ]
        except Exception as e:
            return [
                types.TextContent(
                    type="text",
                    text=f"Error: {e}"
                )
            ]
```

**Step 4: Run tests to verify they pass**

Run: `uv run python -m pytest tests/test_mcp_tools.py tests/test_differentials.py tests/test_pathways.py -v`
Expected: All tests PASS

**Step 5: Run full test suite**

Run: `uv run python -m pytest -v`
Expected: All existing tests still PASS

**Step 6: Commit**

```bash
git add src/open_medicine/mcp/server.py tests/test_mcp_tools.py
git commit -m "feat: register differential diagnosis and treatment pathway MCP tools"
```

---

## Phase 2: Semantic Search

### Task 4: Embedding Generation Script

**Files:**
- Create: `src/open_medicine/embeddings/__init__.py`
- Create: `src/open_medicine/embeddings/generate.py`
- Create: `src/open_medicine/embeddings/search.py`
- Test: `tests/test_embeddings.py`

**Step 1: Write the failing test**

Create `tests/test_embeddings.py`:

```python
"""Tests for embedding-based semantic search."""
import pytest
import json
from pathlib import Path
from unittest.mock import patch, MagicMock
from open_medicine.embeddings.search import semantic_search, load_embeddings


EMBEDDINGS_PATH = Path(__file__).parent.parent / "src" / "open_medicine" / "embeddings" / "data" / "embeddings.json"


def test_load_embeddings_returns_dict():
    """load_embeddings returns a dict even if no file exists (empty fallback)."""
    result = load_embeddings()
    assert isinstance(result, dict)


def test_semantic_search_fallback_without_embeddings():
    """Without embeddings file, semantic_search returns None (signal to fall back)."""
    result = semantic_search("chest pain", domain="all")
    # If no embeddings file and no API key, should return None
    if result is None:
        assert True  # graceful fallback
    else:
        assert isinstance(result, list)


def test_semantic_search_with_mock_embeddings():
    """With mock embeddings and mock API, search returns ranked results."""
    mock_embeddings = {
        "calculate_chadsvasc": {
            "text": "CHA2DS2-VASc score for atrial fibrillation stroke risk",
            "domain": "calculator",
            "embedding": [0.1] * 10,
        },
        "chest_pain": {
            "text": "Differential diagnosis for acute chest pain in adults",
            "domain": "differential",
            "embedding": [0.9] * 10,
        },
    }
    mock_query_embedding = [0.85] * 10

    with patch("open_medicine.embeddings.search.load_embeddings", return_value=mock_embeddings):
        with patch("open_medicine.embeddings.search._get_query_embedding", return_value=mock_query_embedding):
            results = semantic_search("chest pain", domain="all")
            assert results is not None
            assert len(results) > 0
            # chest_pain should rank higher (closer cosine similarity)
            assert results[0]["id"] == "chest_pain"
```

**Step 2: Run test to verify it fails**

Run: `uv run python -m pytest tests/test_embeddings.py -v`
Expected: FAIL — module not found

**Step 3: Implement the embedding search module**

Create `src/open_medicine/embeddings/__init__.py` (empty).

Create `src/open_medicine/embeddings/data/` directory (empty, will hold `embeddings.json` after generation).

Create `src/open_medicine/embeddings/search.py`:

```python
"""Semantic search over pre-computed embeddings."""
import json
import math
import os
from pathlib import Path
from typing import Any, Optional


_DATA_PATH = Path(__file__).parent / "data" / "embeddings.json"


def load_embeddings() -> dict[str, Any]:
    """Load pre-computed embeddings from the shipped JSON file."""
    if not _DATA_PATH.exists():
        return {}
    with open(_DATA_PATH, "r", encoding="utf-8") as f:
        return json.load(f)


def _cosine_similarity(a: list[float], b: list[float]) -> float:
    """Compute cosine similarity between two vectors."""
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(x * x for x in b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


def _get_query_embedding(query: str) -> Optional[list[float]]:
    """Get embedding for a query string via API. Returns None if no API key."""
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        return None

    import httpx
    response = httpx.post(
        "https://api.openai.com/v1/embeddings",
        headers={"Authorization": f"Bearer {api_key}"},
        json={"input": query, "model": "text-embedding-3-small"},
        timeout=10.0,
    )
    response.raise_for_status()
    return response.json()["data"][0]["embedding"]


def semantic_search(
    query: str,
    domain: str = "all",
    top_k: int = 10,
) -> Optional[list[dict[str, Any]]]:
    """
    Search pre-computed embeddings for items matching the query.

    Args:
        query: Search query string.
        domain: Filter by domain — 'calculator', 'guideline', 'differential',
                'pathway', or 'all'.
        top_k: Number of top results to return.

    Returns:
        List of ranked results with id, domain, text, and score.
        Returns None if embeddings or API are unavailable (signals fallback).
    """
    embeddings = load_embeddings()
    if not embeddings:
        return None

    query_embedding = _get_query_embedding(query)
    if query_embedding is None:
        return None

    scored = []
    for item_id, item in embeddings.items():
        if domain != "all" and item.get("domain") != domain:
            continue
        score = _cosine_similarity(query_embedding, item["embedding"])
        scored.append({
            "id": item_id,
            "domain": item.get("domain", "unknown"),
            "text": item.get("text", ""),
            "score": round(score, 4),
        })

    scored.sort(key=lambda x: x["score"], reverse=True)
    return scored[:top_k]
```

Create `src/open_medicine/embeddings/generate.py`:

```python
"""Generate embeddings for all searchable content in OpenMedicine.

Run: uv run python -m open_medicine.embeddings.generate
Requires: OPENAI_API_KEY environment variable.
"""
import json
import os
import sys
from pathlib import Path
from typing import Any

import httpx

from open_medicine.mcp.registry import CALCULATOR_REGISTRY
from open_medicine.mcp.differentials.engine import _DIFFERENTIAL_DB
from open_medicine.mcp.pathways.engine import _PATHWAY_DB


_OUTPUT_PATH = Path(__file__).parent / "data" / "embeddings.json"


def _get_embeddings_batch(texts: list[str], api_key: str) -> list[list[float]]:
    """Get embeddings for a batch of texts from OpenAI API."""
    response = httpx.post(
        "https://api.openai.com/v1/embeddings",
        headers={"Authorization": f"Bearer {api_key}"},
        json={"input": texts, "model": "text-embedding-3-small"},
        timeout=60.0,
    )
    response.raise_for_status()
    data = response.json()["data"]
    # Sort by index to maintain order
    data.sort(key=lambda x: x["index"])
    return [d["embedding"] for d in data]


def _collect_items() -> list[dict[str, Any]]:
    """Collect all searchable items with their text."""
    items = []

    # Calculators
    for calc_id, tool in CALCULATOR_REGISTRY.items():
        items.append({
            "id": calc_id,
            "domain": "calculator",
            "text": f"{calc_id}: {tool.description}",
        })

    # Guidelines
    from open_medicine.mcp.guideline_engine import _load_registry
    for g in _load_registry():
        topics = ", ".join(g.get("topics", []))
        items.append({
            "id": g["id"],
            "domain": "guideline",
            "text": f"{g['id']}: {g['title']}. Topics: {topics}",
        })

    # Differentials
    for diff_id, diff in _DIFFERENTIAL_DB.items():
        keywords = ", ".join(diff.get("keywords", []))
        items.append({
            "id": diff_id,
            "domain": "differential",
            "text": f"{diff_id}: {diff['title']}. {diff['description']}. Keywords: {keywords}",
        })

    # Pathways
    for pw_id, pw in _PATHWAY_DB.items():
        keywords = ", ".join(pw.get("keywords", []))
        items.append({
            "id": pw_id,
            "domain": "pathway",
            "text": f"{pw_id}: {pw['title']}. {pw['description']}. Keywords: {keywords}",
        })

    return items


def main() -> None:
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        print("Error: OPENAI_API_KEY environment variable required.", file=sys.stderr)
        sys.exit(1)

    items = _collect_items()
    print(f"Generating embeddings for {len(items)} items...")

    texts = [item["text"] for item in items]

    # Batch in groups of 100 (API limit is 2048)
    all_embeddings = []
    batch_size = 100
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        print(f"  Batch {i // batch_size + 1}: {len(batch)} items...")
        embeddings = _get_embeddings_batch(batch, api_key)
        all_embeddings.extend(embeddings)

    # Build output
    output = {}
    for item, embedding in zip(items, all_embeddings):
        output[item["id"]] = {
            "text": item["text"],
            "domain": item["domain"],
            "embedding": embedding,
        }

    # Write to file
    _OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
    with open(_OUTPUT_PATH, "w", encoding="utf-8") as f:
        json.dump(output, f)

    print(f"Wrote {len(output)} embeddings to {_OUTPUT_PATH}")
    print(f"File size: {_OUTPUT_PATH.stat().st_size / 1024:.1f} KB")


if __name__ == "__main__":
    main()
```

**Step 4: Run tests to verify they pass**

Run: `uv run python -m pytest tests/test_embeddings.py -v`
Expected: All 3 tests PASS

**Step 5: Commit**

```bash
git add src/open_medicine/embeddings/ tests/test_embeddings.py
git commit -m "feat: add embedding-based semantic search with generation script"
```

---

### Task 5: Unified search_medical_knowledge MCP Tool

**Files:**
- Modify: `src/open_medicine/mcp/server.py`
- Modify: `tests/test_mcp_tools.py`

**Step 1: Write the failing test**

Add to `tests/test_mcp_tools.py`:

```python
def test_search_medical_knowledge_tool_exists(tools):
    """Unified search tool should be registered."""
    names = [t.name for t in tools]
    assert "search_medical_knowledge" in names


def test_tool_count_with_unified_search(tools):
    """Should now have 9 tools (original 4 + 4 workflow + 1 unified search)."""
    assert len(tools) == 9
```

**Step 2: Run test to verify it fails**

Run: `uv run python -m pytest tests/test_mcp_tools.py::test_search_medical_knowledge_tool_exists -v`
Expected: FAIL

**Step 3: Add search_medical_knowledge tool to server.py**

Add to `handle_list_tools()`:

```python
        types.Tool(
            name="search_medical_knowledge",
            description="Unified semantic search across ALL OpenMedicine content — calculators, guidelines, differential diagnoses, and treatment pathways. Returns categorized ranked results. This is the primary entry point for discovering relevant clinical tools. Falls back to keyword search if semantic search is unavailable.",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Natural language clinical query (e.g. 'patient with chest pain and shortness of breath', 'anticoagulation in CKD')."
                    },
                    "domain": {
                        "type": "string",
                        "enum": ["all", "calculator", "guideline", "differential", "pathway"],
                        "description": "Optional filter by content type. Default: 'all'."
                    }
                },
                "required": ["query"]
            }
        ),
```

Add to `handle_call_tool()`:

```python
    elif name == "search_medical_knowledge":
        query = (arguments or {}).get("query", "")
        domain = (arguments or {}).get("domain", "all")

        # Try semantic search first
        from open_medicine.embeddings.search import semantic_search
        results = semantic_search(query, domain=domain)

        if results is not None:
            import json
            return [
                types.TextContent(
                    type="text",
                    text=json.dumps({"search_type": "semantic", "matches": results}, indent=2)
                )
            ]

        # Fallback: aggregate keyword search across all domains
        query_lower = query.lower()
        matches = []

        if domain in ("all", "calculator"):
            for calc_id, tool_def in CALCULATOR_REGISTRY.items():
                if query_lower in calc_id.lower() or query_lower in tool_def.description.lower():
                    matches.append({"id": calc_id, "domain": "calculator", "text": tool_def.description})

        if domain in ("all", "guideline"):
            from open_medicine.mcp.guideline_engine import search_guidelines as _sg
            for g in _sg(query):
                matches.append({"id": g["guideline_id"], "domain": "guideline", "text": g["title"]})

        if domain in ("all", "differential"):
            for d in search_differentials(query):
                matches.append({"id": d["differential_id"], "domain": "differential", "text": d["title"]})

        if domain in ("all", "pathway"):
            for p in search_pathways(query):
                matches.append({"id": p["pathway_id"], "domain": "pathway", "text": p["title"]})

        import json
        return [
            types.TextContent(
                type="text",
                text=json.dumps({"search_type": "keyword", "matches": matches}, indent=2)
            )
        ]
```

**Step 4: Update test_tool_count assertion from 8 to 9**

**Step 5: Run tests**

Run: `uv run python -m pytest tests/test_mcp_tools.py -v`
Expected: All PASS

**Step 6: Run full suite**

Run: `uv run python -m pytest -v`
Expected: All PASS

**Step 7: Commit**

```bash
git add src/open_medicine/mcp/server.py tests/test_mcp_tools.py
git commit -m "feat: add unified search_medical_knowledge MCP tool with semantic + keyword fallback"
```

---

### Task 6: Add httpx Dependency

**Files:**
- Modify: `pyproject.toml`

**Step 1: Add httpx as optional dependency for embeddings**

In `pyproject.toml`, add to `[project.optional-dependencies]`:

```toml
[project.optional-dependencies]
test = [
    "pytest>=7.0.0",
    "hypothesis>=6.0.0",
]
embeddings = [
    "httpx>=0.27.0",
]
```

**Step 2: Run uv sync**

```bash
uv sync --extra test --extra embeddings
```

**Step 3: Commit**

```bash
git add pyproject.toml uv.lock
git commit -m "chore: add httpx as optional dependency for embedding generation"
```

---

## Phase 3: Clinical Routing Engine

### Task 7: Routing Rules Data and Engine

**Files:**
- Create: `src/open_medicine/mcp/routing/__init__.py`
- Create: `src/open_medicine/mcp/routing/engine.py`
- Create: `src/open_medicine/mcp/routing/data/rules.json`
- Test: `tests/test_routing.py`

**Step 1: Write the failing test**

Create `tests/test_routing.py`:

```python
"""Tests for the clinical routing engine."""
import pytest
from open_medicine.mcp.routing.engine import (
    assess_clinical_scenario,
    ScenarioParams,
)
from open_medicine.foundation.base import ClinicalResult


def test_assess_afib_scenario():
    """AFib scenario should recommend CHA2DS2-VASc, HAS-BLED, and AF guideline."""
    params = ScenarioParams(
        conditions=["atrial_fibrillation"],
        age=65,
        sex="male",
    )
    result = assess_clinical_scenario(params)
    assert isinstance(result, ClinicalResult)
    actions = result.value["recommended_actions"]
    action_ids = [a["tool_id"] for a in actions]
    assert "calculate_chadsvasc" in action_ids
    assert "calculate_hasbled" in action_ids


def test_assess_unknown_condition():
    """Unknown condition returns empty recommendations (no crash)."""
    params = ScenarioParams(
        conditions=["totally_made_up_condition"],
        age=40,
        sex="female",
    )
    result = assess_clinical_scenario(params)
    assert isinstance(result, ClinicalResult)
    assert result.value["recommended_actions"] == []


def test_assess_multiple_conditions():
    """Multiple conditions should aggregate recommendations."""
    params = ScenarioParams(
        conditions=["atrial_fibrillation", "ckd"],
        age=70,
        sex="male",
    )
    result = assess_clinical_scenario(params)
    actions = result.value["recommended_actions"]
    action_ids = [a["tool_id"] for a in actions]
    # Should include both AF and CKD tools
    assert "calculate_chadsvasc" in action_ids
    assert "calculate_ckd_epi" in action_ids


def test_recommendations_are_prioritized():
    """Recommendations should have priority ordering."""
    params = ScenarioParams(
        conditions=["atrial_fibrillation"],
        age=65,
        sex="male",
    )
    result = assess_clinical_scenario(params)
    actions = result.value["recommended_actions"]
    priorities = [a["priority"] for a in actions]
    assert priorities == sorted(priorities)


def test_recommendations_have_required_fields():
    """Each recommendation should have required fields."""
    params = ScenarioParams(
        conditions=["atrial_fibrillation"],
        age=65,
        sex="male",
    )
    result = assess_clinical_scenario(params)
    for action in result.value["recommended_actions"]:
        assert "priority" in action
        assert "action" in action
        assert "tool_id" in action
        assert "reason" in action
```

**Step 2: Run test to verify it fails**

Run: `uv run python -m pytest tests/test_routing.py -v`
Expected: FAIL — module not found

**Step 3: Create routing rules data**

Create `src/open_medicine/mcp/routing/__init__.py` (empty).

Create `src/open_medicine/mcp/routing/data/rules.json`:

```json
{
  "condition_rules": {
    "atrial_fibrillation": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_chadsvasc", "reason": "Assess stroke risk in atrial fibrillation"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_hasbled", "reason": "Assess bleeding risk before anticoagulation"},
        {"priority": 3, "action": "retrieve_guideline", "tool_id": "acc_aha_af_2023", "section": "anticoagulation", "reason": "Review AF anticoagulation guideline"},
        {"priority": 4, "action": "get_treatment_pathway", "tool_id": "afib_anticoagulation", "reason": "Navigate anticoagulation treatment pathway"}
      ],
      "warnings": []
    },
    "ckd": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_ckd_epi", "reason": "Estimate GFR for CKD staging"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_cockcroft_gault", "reason": "Calculate CrCl for drug dosing"},
        {"priority": 3, "action": "retrieve_guideline", "tool_id": "kdigo_ckd_2024", "section": "staging", "reason": "Review KDIGO CKD staging criteria"}
      ],
      "warnings": ["Renal dose adjustments required for many medications"]
    },
    "chest_pain": {
      "actions": [
        {"priority": 1, "action": "get_differential_diagnosis", "tool_id": "chest_pain", "reason": "Review differential diagnosis for chest pain"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_heart_score", "reason": "Risk-stratify for ACS with HEART score"},
        {"priority": 3, "action": "execute_calculator", "tool_id": "calculate_wells_pe", "reason": "Assess PE probability"},
        {"priority": 4, "action": "retrieve_guideline", "tool_id": "acc_aha_stemi_2013", "section": "initial_assessment", "reason": "Review STEMI assessment guideline"}
      ],
      "warnings": ["Must rule out life-threatening causes: ACS, PE, aortic dissection, tension pneumothorax"]
    },
    "heart_failure": {
      "actions": [
        {"priority": 1, "action": "retrieve_guideline", "tool_id": "aha_acc_hf_2022", "section": "classification", "reason": "Classify heart failure stage and EF"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_ckd_epi", "reason": "Assess renal function (common comorbidity)"}
      ],
      "warnings": []
    },
    "sepsis": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_qsofa", "reason": "Bedside sepsis screening"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_sofa", "reason": "Assess organ dysfunction severity"},
        {"priority": 3, "action": "execute_calculator", "tool_id": "calculate_news2", "reason": "Calculate early warning score"},
        {"priority": 4, "action": "retrieve_guideline", "tool_id": "ssc_sepsis_2021", "section": "initial_resuscitation", "reason": "Review Surviving Sepsis Campaign resuscitation bundle"}
      ],
      "warnings": ["Time-critical condition — initiate 1-hour bundle"]
    },
    "vte": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_wells_dvt", "reason": "Assess DVT probability"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_wells_pe", "reason": "Assess PE probability"},
        {"priority": 3, "action": "retrieve_guideline", "tool_id": "ash_vte_2020", "section": "diagnosis", "reason": "Review VTE diagnostic pathway"}
      ],
      "warnings": []
    },
    "gi_bleeding": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_glasgow_blatchford", "reason": "Risk-stratify upper GI bleeding"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_aims65", "reason": "Predict in-hospital mortality in UGIB"},
        {"priority": 3, "action": "retrieve_guideline", "tool_id": "nice_ugib_2012", "section": "initial_assessment", "reason": "Review UGIB management guideline"}
      ],
      "warnings": []
    },
    "pneumonia": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_curb65", "reason": "Assess pneumonia severity"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_psi_port", "reason": "PSI/PORT score for site-of-care decision"},
        {"priority": 3, "action": "execute_calculator", "tool_id": "calculate_qsofa", "reason": "Screen for sepsis in pneumonia"}
      ],
      "warnings": []
    },
    "stroke": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_nihss", "reason": "Quantify neurological deficit"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_chadsvasc", "reason": "Stroke risk if AF-related"},
        {"priority": 3, "action": "execute_calculator", "tool_id": "calculate_abcd2", "reason": "Assess stroke risk after TIA"}
      ],
      "warnings": ["Time-critical — consider thrombolysis window"]
    },
    "pancreatitis": {
      "actions": [
        {"priority": 1, "action": "execute_calculator", "tool_id": "calculate_bisap", "reason": "Predict severity of acute pancreatitis"},
        {"priority": 2, "action": "execute_calculator", "tool_id": "calculate_ransons", "reason": "Ranson's criteria for pancreatitis severity"}
      ],
      "warnings": []
    }
  }
}
```

**Step 4: Implement the routing engine**

Create `src/open_medicine/mcp/routing/engine.py`:

```python
"""Clinical routing engine — assess a clinical scenario and recommend tools to run."""
import json
from pathlib import Path
from typing import Any, Optional

from pydantic import BaseModel, Field

from open_medicine.foundation.base import ClinicalResult, Evidence


class ScenarioParams(BaseModel):
    """Parameters for clinical scenario assessment."""
    conditions: list[str] = Field(..., description="List of clinical conditions/presentations (e.g., ['atrial_fibrillation', 'ckd'])")
    age: Optional[int] = Field(None, description="Patient age in years")
    sex: Optional[str] = Field(None, description="Patient sex ('male' or 'female')")
    medications: Optional[list[str]] = Field(None, description="Current medications")


_RULES_PATH = Path(__file__).parent / "data" / "rules.json"
with open(_RULES_PATH, "r", encoding="utf-8") as f:
    _RULES: dict = json.load(f)


def assess_clinical_scenario(params: ScenarioParams) -> ClinicalResult:
    """Assess a clinical scenario and return prioritized tool recommendations."""
    condition_rules = _RULES.get("condition_rules", {})

    all_actions = []
    all_warnings = []
    matched_conditions = []

    for condition in params.conditions:
        condition_lower = condition.strip().lower()
        if condition_lower in condition_rules:
            rule = condition_rules[condition_lower]
            matched_conditions.append(condition_lower)
            all_actions.extend(rule["actions"])
            all_warnings.extend(rule.get("warnings", []))

    # Deduplicate actions by tool_id, keeping lowest priority
    seen_tools = {}
    for action in all_actions:
        tid = action["tool_id"]
        if tid not in seen_tools or action["priority"] < seen_tools[tid]["priority"]:
            seen_tools[tid] = action

    # Re-sort and re-number priorities
    deduped = sorted(seen_tools.values(), key=lambda a: a["priority"])
    for i, action in enumerate(deduped, 1):
        action["priority"] = i

    # Deduplicate warnings
    unique_warnings = list(dict.fromkeys(all_warnings))

    value = {
        "matched_conditions": matched_conditions,
        "recommended_actions": deduped,
        "warnings": unique_warnings,
    }

    if matched_conditions:
        interpretation = (
            f"Clinical scenario assessment for: {', '.join(matched_conditions)}. "
            f"{len(deduped)} recommended actions identified. "
            f"Execute in priority order for systematic evaluation."
        )
    else:
        interpretation = (
            f"No matching routing rules for conditions: {', '.join(params.conditions)}. "
            f"Consider using search_medical_knowledge to discover relevant tools."
        )

    evidence = Evidence(
        source_doi="10.1161/CIR.0000000000001193",
        level="Clinical Routing Engine",
        description="OpenMedicine deterministic routing rules based on clinical practice guidelines.",
    )

    return ClinicalResult(
        value=value,
        interpretation=interpretation,
        evidence=evidence,
    )
```

**Step 5: Run tests to verify they pass**

Run: `uv run python -m pytest tests/test_routing.py -v`
Expected: All 5 tests PASS

**Step 6: Commit**

```bash
git add src/open_medicine/mcp/routing/ tests/test_routing.py
git commit -m "feat: add clinical routing engine with rules for 10 conditions"
```

---

### Task 8: Register assess_clinical_scenario MCP Tool

**Files:**
- Modify: `src/open_medicine/mcp/server.py`
- Modify: `tests/test_mcp_tools.py`

**Step 1: Update test for 10 tools**

Update `test_tool_count_with_unified_search` to expect 10 tools. Add:

```python
def test_assess_clinical_scenario_tool_exists(tools):
    """Routing engine tool should be registered."""
    names = [t.name for t in tools]
    assert "assess_clinical_scenario" in names
```

**Step 2: Run test to verify it fails**

Run: `uv run python -m pytest tests/test_mcp_tools.py::test_assess_clinical_scenario_tool_exists -v`
Expected: FAIL

**Step 3: Add the tool to server.py**

Add to `handle_list_tools()`:

```python
        types.Tool(
            name="assess_clinical_scenario",
            description="Clinical routing engine — given a patient's conditions, returns a prioritized list of recommended tool calls (calculators, guidelines, differentials, pathways) to systematically evaluate the scenario. OpenMedicine acts as a clinical librarian, recommending what to look up. The agent decides what to actually execute.",
            inputSchema={
                "type": "object",
                "properties": {
                    "conditions": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "List of clinical conditions or presentations (e.g. ['atrial_fibrillation', 'ckd', 'chest_pain'])."
                    },
                    "age": {
                        "type": "integer",
                        "description": "Patient age in years (optional)."
                    },
                    "sex": {
                        "type": "string",
                        "description": "Patient sex — 'male' or 'female' (optional)."
                    },
                    "medications": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Current medications (optional)."
                    }
                },
                "required": ["conditions"]
            }
        ),
```

Add import and handler:

```python
from open_medicine.mcp.routing.engine import assess_clinical_scenario, ScenarioParams
```

```python
    elif name == "assess_clinical_scenario":
        conditions = (arguments or {}).get("conditions", [])
        age = (arguments or {}).get("age")
        sex = (arguments or {}).get("sex")
        medications = (arguments or {}).get("medications")
        try:
            params = ScenarioParams(
                conditions=conditions,
                age=age,
                sex=sex,
                medications=medications,
            )
            result = assess_clinical_scenario(params)
            return [
                types.TextContent(
                    type="text",
                    text=result.model_dump_json(indent=2)
                )
            ]
        except Exception as e:
            return [
                types.TextContent(
                    type="text",
                    text=f"Error: {e}"
                )
            ]
```

**Step 4: Run tests**

Run: `uv run python -m pytest tests/test_mcp_tools.py -v`
Expected: All PASS

**Step 5: Run full suite**

Run: `uv run python -m pytest -v`
Expected: All PASS

**Step 6: Commit**

```bash
git add src/open_medicine/mcp/server.py tests/test_mcp_tools.py
git commit -m "feat: register assess_clinical_scenario MCP tool — 10 tools complete"
```

---

### Task 9: Version Bump and Final Validation

**Files:**
- Modify: `pyproject.toml` (version -> 0.7.0)

**Step 1: Update version**

In `pyproject.toml`, change `version = "0.6.0"` to `version = "0.7.0"`.

**Step 2: Run full test suite**

Run: `uv run python -m pytest -v`
Expected: All tests PASS

**Step 3: Commit**

```bash
git add pyproject.toml
git commit -m "chore: bump version to 0.7.0 — UpToDate for Agents (Phase 1-3)"
```
