ClinicalTrials.gov MCP Server — OncoHub
by baris-varis
README.md
# ClinicalTrials.gov MCP Server — OncoHub
A production-quality MCP (Model Context Protocol) server that wraps the [ClinicalTrials.gov v2 API](https://clinicaltrials.gov/data-api/api). Designed for the **OncoHub Tumor Council** as the data source for `clinical_trial_matching_module v2 / A2.3`.
**What this server does:** structured, TTL-cached access to CT.gov trial data (worldwide + Turkey) via 4 MCP tools.
**What it does not do:** clinical eligibility assessment, scoring, or any form of clinical judgment — that is the consuming system's responsibility.
---
## Why Python + FastMCP?
- CT.gov v2 is a plain JSON REST API — no special SDK needed
- FastMCP's HTTP transport satisfies claude.ai's remote connector requirement (STDIO won't work there)
- `httpx` + `asyncio` give clean async retry/backoff
- SQLite cache requires zero infrastructure beyond the process itself
---
## Project Structure
```
CT-MSP/
├── server.py # FastMCP server + 4 MCP tools (entry point)
├── ctgov_client.py # Async httpx client, rate limiting, retry
├── normalize.py # Raw CT.gov JSON → flat OncoHub schema
├── cache.py # SQLite TTL cache (no external deps)
├── models.py # Pydantic models (schema documentation)
├── tests/
│ ├── conftest.py # Shared fixtures + sample API responses
│ ├── test_normalize.py
│ ├── test_client.py
│ ├── test_cache.py
│ ├── test_tools.py # MCP tool integration tests (mocked)
│ └── test_smoke.py # Live API smoke tests (opt-in)
├── Dockerfile
├── render.yaml # One-click Render deployment
├── pyproject.toml
└── .env.example
```
---
## Local Setup
**Requirements:** Python 3.11+, pip
```bash
# 1. Clone / navigate to project directory
cd CT-MSP
# 2. Create virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. Install dependencies
pip install -e ".[dev]"
# 4. Configure environment
cp .env.example .env
# Edit .env if needed (defaults work for local development)
# 5. Run the server
python server.py
# Server starts on http://localhost:8000
# MCP endpoint: http://localhost:8000/mcp
```
### Test with MCP Inspector
```bash
npx @modelcontextprotocol/inspector http://localhost:8000/mcp
```
This opens a browser UI where you can call tools interactively.
### Run tests
```bash
pytest # all unit tests (no network)
pytest -m smoke # live CT.gov smoke tests (requires internet)
pytest tests/test_normalize.py # specific module
```
---
## Public Deployment
> **Required** for claude.ai connector — claude.ai only connects to public HTTPS endpoints.
### Option A: Render (recommended — free tier, auto-HTTPS)
1. Push this repo to GitHub.
2. Go to [render.com](https://render.com) → **New** → **Web Service** → connect your repo.
3. Render auto-detects `render.yaml` — click **Apply**.
4. Wait ~3 min for the first build.
5. Your URL: `https://ctgov-mcp-oncohub.onrender.com` (or your custom name).
6. **Free tier caveat:** the service sleeps after 15 min of inactivity (first request takes ~30s to wake). The SQLite cache resets on restart since `/tmp` is ephemeral. Upgrade to Starter ($7/mo) for a persistent disk (`/data`).
**Persistent disk (Starter plan):** uncomment the `disk:` section in `render.yaml` and set `CACHE_PATH=/data/ctgov.db`.
### Option B: Railway
```bash
# Install Railway CLI
npm i -g @railway/cli
railway login
railway init
railway up
```
Set environment variables via `railway variables set STATUS_TTL_DAYS=7 ...`
### Option C: Fly.io
```bash
fly launch # detects Dockerfile automatically
fly deploy
fly secrets set STATUS_TTL_DAYS=7 META_TTL_DAYS=30
```
Add a volume for persistent cache:
```bash
fly volumes create ctgov_cache --size 1
```
Then set `CACHE_PATH=/data/ctgov.db` in `fly.toml`.
### Option D: Any Docker host
```bash
docker build -t ctgov-mcp .
docker run -p 8000:8000 \
-e STATUS_TTL_DAYS=7 \
-v ctgov_cache:/data \
-e CACHE_PATH=/data/ctgov.db \
ctgov-mcp
```
---
## Adding to claude.ai as a Custom Connector
> **Prerequisites:**
> - Server running at a public HTTPS URL (e.g. `https://ctgov-mcp-oncohub.onrender.com`)
> - claude.ai **Pro, Team, or Enterprise** plan
> - OncoHub Project must be **private**
### Steps (Individual / Pro)
1. Go to **claude.ai → [Your name] → Settings → Connectors**.
2. Click **"+"** → **"Add custom connector"**.
3. **Name:** `ClinicalTrials.gov (OncoHub)`
4. **URL:** `https://<your-host>/mcp`
5. **Auth:** leave OAuth fields blank (this server has no auth).
6. Click **Add**.
### Steps (Team / Enterprise)
1. An **Owner** goes to **Organization Settings → Connectors → Add connector** (same fields as above).
2. Members connect it via **[+] → Connectors** in any conversation.
### Activating in the Tumor Council Project
1. Open the **OncoHub** project.
2. In a conversation, click **"+" (bottom-left) → Connectors**.
3. Toggle **ClinicalTrials.gov (OncoHub)** ON.
4. The system prompt should reference tool names (e.g. `dual_source_search`) for the module to call them automatically during report generation.
> **Note:** `claude_desktop_config.json` (STDIO transport) does NOT work with claude.ai Projects — only remote HTTP/SSE connectors work there.
---
## MCP Tools Reference
### `dual_source_search` ← Primary council entry point
Searches worldwide AND Turkey in two sequential calls, merges by NCT ID, marks `has_turkey_site`.
| Parameter | Type | Default | Description |
|---|---|---|---|
| `condition` | str | required | Disease/condition (maps from A1 tumor field) |
| `term` | str? | null | Biomarker / keyword (maps from A1 biomarker field) |
| `intervention` | str? | null | Drug or target (maps from A1 treatment field) |
| `statuses` | list[str]? | RECRUITING, NOT_YET_RECRUITING | Overall status filter |
| `phases` | list[str]? | null | Phase filter (maps from A1 treatment line) |
| `page_size` | int | 20 | Results per sub-query (max 50) |
| `force_refresh` | bool | false | Skip cache |
**Returns:** `{studies: [...], total_count, turkey_count, next_page_token, note}`
---
### `search_trials`
Parameterized worldwide search.
| Parameter | Type | Default | Description |
|---|---|---|---|
| `condition` | str | required | Disease/condition |
| `term` | str? | null | Free-text biomarker or keyword |
| `intervention` | str? | null | Drug or target |
| `statuses` | list[str]? | RECRUITING, NOT_YET_RECRUITING | See valid values below |
| `phases` | list[str]? | null | See valid values below |
| `country` | str? | null | Country name for location filter |
| `page_size` | int | 20 | Max 50 recommended |
| `page_token` | str? | null | Pagination cursor |
| `force_refresh` | bool | false | Skip cache |
**Valid `statuses`:** `RECRUITING`, `NOT_YET_RECRUITING`, `ACTIVE_NOT_RECRUITING`, `COMPLETED`, `TERMINATED`, `WITHDRAWN`, `SUSPENDED`, `ENROLLING_BY_INVITATION`, `UNKNOWN`
**Valid `phases`:** `EARLY_PHASE1`, `PHASE1`, `PHASE2`, `PHASE3`, `PHASE4`, `NA`
---
### `search_turkey_trials`
Convenience wrapper: `search_trials` with `country="Turkey"`. Same parameters (minus `country`).
---
### `get_trial`
Fetch full details for a single study.
| Parameter | Type | Description |
|---|---|---|
| `nct_id` | str | NCT number, e.g. `"NCT05678901"` |
| `force_refresh` | bool | Skip cache |
**Returns:** single normalized study with full `eligibility.criteria_text` and all `locations`.
---
## Normalized Study Schema
Every tool returns studies in this flat schema:
```json
{
"nct_id": "NCT05678901",
"title": "A Study of Sotorasib...",
"status": "RECRUITING",
"status_unknown_flag": false,
"phases": ["PHASE2"],
"study_type": "INTERVENTIONAL",
"conditions": ["Non-Small Cell Lung Cancer"],
"interventions": [{"type": "DRUG", "name": "Sotorasib"}],
"eligibility": {
"criteria_text": "Inclusion Criteria:\n- KRAS G12C...",
"sex": "ALL",
"min_age": "18 Years",
"max_age": "N/A",
"healthy_volunteers": false
},
"locations": [
{"country": "Turkey", "city": "Istanbul", "facility": "IUH", "status": "RECRUITING"}
],
"has_turkey_site": true,
"turkey_sites": [{"city": "Istanbul", "facility": "IUH", "status": "RECRUITING"}],
"lead_sponsor": "Amgen",
"last_update_post_date": "2024-05-01",
"url": "https://clinicaltrials.gov/study/NCT05678901",
"retrieved_at": "2026-06-24T10:30:00+00:00",
"freshness": "live",
"cached_at": null
}
```
**Key fields for clinicians:**
- `status_unknown_flag: true` → status unverified for 2+ years, verify before acting
- `retrieved_at` → when data was fetched from CT.gov (always surface this)
- `freshness: "cached"` → data served from local cache; `cached_at` shows when it was stored
---
## OncoHub Integration: A1 → A2.3 Field Mapping
| A1 Patient Profile Field | `dual_source_search` Parameter | Notes |
|---|---|---|
| Tumor type / primary diagnosis | `condition` | Required |
| Biomarker (e.g. KRAS G12C, PD-L1) | `term` | Free text, forwarded to `query.term` |
| Current/target drug | `intervention` | Forwarded to `query.intr` |
| Treatment line / phase preference | `phases` | e.g. `["PHASE2","PHASE3"]` |
| Eligibility status filter | `statuses` | Default: RECRUITING + NOT_YET_RECRUITING |
**Module flow:**
```
A1 Patient Card
↓
A2.3 dual_source_search(condition, term, intervention, phases)
↓
Normalized studies[] with has_turkey_site, freshness, retrieved_at
↓
A3 Eligibility Matrix
↓
A5 Council Report Output
```
The server provides data only. A3 performs eligibility matching; A5 formats the output.
---
## Cache & Freshness Configuration
| Variable | Default | Description |
|---|---|---|
| `STATUS_TTL_DAYS` | 7 | Max age for cached recruiting/status data |
| `META_TTL_DAYS` | 30 | Reserved for future eligibility-only caching |
| `CACHE_PATH` | `.cache/ctgov.db` | SQLite file path |
**Why TTL matters for patient safety:** Trial status changes frequently. A "RECRUITING" status cached 30 days ago may no longer be accurate. STATUS_TTL_DAYS=7 ensures clinicians see data verified within one week, or the server fetches fresh data.
**Cache behavior:**
- Hit (fresh): returns stored data with `freshness: "cached"` + `cached_at` timestamp
- Miss or expired: fetches from CT.gov, updates cache, returns with `freshness: "live"`
- `force_refresh: true`: always fetches live, updates cache
**Cloud deployments:** On free-tier hosts with ephemeral storage, the cache resets on each restart — all requests go live until the cache warms up again. Use a persistent volume for production.
---
## Rate Limiting & CT.gov ToU Compliance
- Minimum 1 second between API requests (`CTGOV_RATE_INTERVAL=1.0`)
- Automatic exponential-backoff retry on network/5xx errors (max 3 attempts)
- No contact-harvesting: email, phone, and investigator PII are never included in output
- No bulk corpus download: `pageSize` capped at 50 per call
- `User-Agent` header identifies this client to CT.gov
---
## No Authentication Required
CT.gov v2 is a public API — no API key, no OAuth. This server also has no auth on its `/mcp` endpoint (it serves read-only public data). For production deployments where you want to restrict access, place a reverse proxy with IP allowlisting or a simple bearer token in front.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues