Skip to content
Closed
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
22 changes: 11 additions & 11 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,38 +40,38 @@ jobs:
sudo apt-get clean
df -h /

- name: Build and tag Docker image
- name: Build and tag Docker images
run: |
docker compose build api
docker compose build api biomcp
docker tag pillchecker-api-api pillchecker-api:ci

- name: Start API
- name: Start API and BioMCP
run: >
docker compose
-f docker-compose.yml
-f docker-compose.ci.yml
up -d api
up -d api biomcp

- name: Wait for startup and hydration
- name: Wait for API startup
run: |
# Wait for the entrypoint to finish syncing data
for i in {1..30}; do
if curl -s http://localhost:8000/health/data | grep -q '"status":"ready"'; then
echo "API and Data ready!"
if curl -s http://localhost:8000/health/data | grep -q '"biomcp":"connected"'; then
echo "API and BioMCP ready!"
exit 0
fi
echo "Waiting for API/Data hydration..."
echo "Waiting for API and BioMCP... ($i/30)"
sleep 5
done
echo "Timeout waiting for API"
echo "Timeout waiting for API/BioMCP"
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs api biomcp
exit 1

- name: Run smoke tests
run: ./scripts/smoke-test.sh http://localhost:8000

- name: Dump logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml logs api
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml logs api biomcp

- name: Stop containers
if: always()
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ ENV TRANSFORMERS_CACHE=/app/models
# Pre-download NER model so the image is self-contained.
# Layer is cached until venv or model ID changes.
# In local dev, docker-compose mounts a volume over /app/models.
RUN python -c "from transformers import pipeline; pipeline('ner', model='OpenMed/OpenMed-NER-PharmaDetect-ModernClinical-149M', aggregation_strategy='none')"
RUN python -c "from transformers import pipeline; \
pipeline('ner', model='OpenMed/OpenMed-NER-PharmaDetect-ModernClinical-149M', aggregation_strategy='none'); \
pipeline('zero-shot-classification', model='MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli')"

# App code comes last — most frequently changing layer
COPY --from=builder /app/app /app/app
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile.biomcp
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir biomcp-cli==0.8.15
EXPOSE 8080
CMD ["biomcp", "serve-http", "--host", "0.0.0.0", "--port", "8080"]
16 changes: 8 additions & 8 deletions app/api/health.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Health check endpoints."""

from fastapi import APIRouter
from app.data import fda_store
from app.clients import biomcp_client
from app.nlp import ner_model

router = APIRouter()
Expand All @@ -11,17 +11,17 @@
async def health_check():
"""Basic health check to verify the API is running."""
return {
"status": "ok",
"status": "ok",
"version": "0.1.0",
"ner_model_loaded": ner_model.is_loaded()
"ner_model_loaded": ner_model.is_loaded(),
}


@router.get("/health/data")
async def data_health_check():
"""Check the status of the medication interaction database."""
count = fda_store.interaction_count()
"""Check the status of the drug interaction data source."""
connected = await biomcp_client.health_check()
return {
"status": "ready" if count > 0 else "empty",
"record_count": count,
"database": str(fda_store.DB_PATH)
"status": "ready" if connected else "degraded",
"biomcp": "connected" if connected else "unreachable",
}
2 changes: 1 addition & 1 deletion app/api/interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@

@router.post("/interactions", response_model=InteractionsResponse)
async def check_interactions(request: InteractionsRequest):
result = interaction_checker.check(request.drugs)
result = await interaction_checker.check(request.drugs)
return InteractionsResponse(**result)
3 changes: 2 additions & 1 deletion app/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ class InteractionResult(BaseModel):

class InteractionsResponse(BaseModel):
interactions: list[InteractionResult]
safe: bool
safe: bool | None
error: str | None = None
153 changes: 153 additions & 0 deletions app/clients/biomcp_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Async client for BioMCP MCP server.

Connects to a BioMCP HTTP sidecar and queries drug interaction data
from DrugBank via MyChem.info.
"""

import json
import logging
import os
import shlex
import time
from contextlib import AbstractAsyncContextManager

import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

logger = logging.getLogger(__name__)

BIOMCP_BASE_URL = os.environ.get("BIOMCP_URL", "http://biomcp:8080/mcp").rsplit("/mcp", 1)[0]
BIOMCP_URL = f"{BIOMCP_BASE_URL}/mcp"

_session: ClientSession | None = None
_streams: AbstractAsyncContextManager | None = None
_tool_name: str = "biomcp" # discovered at connect() via list_tools()

# Simple TTL cache: {key: (value, expiry_timestamp)}
_cache: dict[str, tuple[object, float]] = {}
_CACHE_TTL = 86400 # 24 hours


class BioMCPUnavailableError(Exception):
"""Raised when BioMCP sidecar is unreachable or returns an error."""


def _cache_get(key: str) -> object | None:
if key in _cache:
value, expiry = _cache[key]
if time.time() < expiry:
return value
del _cache[key]
return None


def _cache_set(key: str, value: object) -> None:
_cache[key] = (value, time.time() + _CACHE_TTL)


async def connect() -> None:
"""Establish MCP session with the BioMCP sidecar.

Silently degrades to _session=None on failure (graceful degradation).
Callers should handle BioMCPUnavailableError raised by get_interactions().
"""
global _session, _streams, _tool_name
try:
_streams = streamable_http_client(BIOMCP_URL)
read_stream, write_stream, _ = await _streams.__aenter__()
try:
_session = ClientSession(read_stream, write_stream)
await _session.__aenter__()
await _session.initialize()
# Discover the actual tool name — versions ≤0.8.14 use "shell",
# ≥0.8.15 use "biomcp". Fall back to default if neither is found.
tools = await _session.list_tools()
names = {t.name for t in tools.tools}
if "biomcp" in names:
_tool_name = "biomcp"
elif "shell" in names:
_tool_name = "shell"
logger.warning("BioMCP tool named 'shell' (pre-0.8.15); upgrade for 'biomcp'")
else:
logger.warning("Unexpected BioMCP tool names: %s; defaulting to 'biomcp'", names)
logger.info("Connected to BioMCP at %s (tool=%s)", BIOMCP_URL, _tool_name)
except Exception:
# Clean up transport if session init fails
await _streams.__aexit__(None, None, None)
raise
except Exception:
logger.warning("Failed to connect to BioMCP at %s", BIOMCP_URL, exc_info=True)
_session = None
_streams = None


async def close() -> None:
"""Close the MCP session."""
global _session, _streams
try:
if _session is not None:
try:
await _session.__aexit__(None, None, None)
except Exception:
pass
_session = None
finally:
if _streams is not None:
try:
await _streams.__aexit__(None, None, None)
except Exception:
pass
_streams = None


async def health_check() -> bool:
"""Check if BioMCP sidecar is reachable and MCP session is active."""
if _session is None:
return False
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{BIOMCP_BASE_URL}/health")
return resp.status_code == 200
except Exception:
return False


async def get_interactions(drug_name: str) -> list[dict]:
"""Get drug-drug interactions for a given drug name.

Returns list of {"drug": str, "description": str | None}.
Raises BioMCPUnavailableError if BioMCP is unreachable.
"""
cache_key = f"interactions:{drug_name.lower()}"
cached = _cache_get(cache_key)
if cached is not None:
return cached

if _session is None:
raise BioMCPUnavailableError("BioMCP session not established")

try:
result = await _session.call_tool(
_tool_name,
{"command": f"get drug {shlex.quote(drug_name)} interactions --json"},
)
except Exception as exc:
raise BioMCPUnavailableError(f"BioMCP call failed: {exc}") from exc
if result.isError:
raise BioMCPUnavailableError(f"BioMCP returned error for {drug_name}")

# Parse the response — BioMCP returns JSON in content[0].text
try:
content_block = result.content[0]
if not hasattr(content_block, "text"):
logger.warning("BioMCP returned unexpected content type for %s", drug_name)
interactions = []
else:
data = json.loads(content_block.text)
interactions = data.get("interactions", [])
except (json.JSONDecodeError, IndexError):
interactions = []

_cache_set(cache_key, interactions)
return interactions
Empty file removed app/data/__init__.py
Empty file.
Loading
Loading