MCP Clinical Research Server
by Harshahi
README.md
# MCP Clinical Research Server
This project exposes an MCP server that can:
- search ClinicalTrials.gov for studies by condition or keyword
- fetch the latest stock price for a company ticker from Yahoo Finance
## Local setup
### Run with Docker Compose
1. Copy the example env file:
cp .env.example .env
2. Update the values in `.env` with a real PAT if you want to use one:
MCP_PAT=your_personal_access_token
MCP_ACCESS_URL=http://localhost:8000/mcp
MCP_PUBLIC_URL=http://localhost
MCP_HOST=0.0.0.0
MCP_PORT=8000
3. Start the app with Docker Compose:
docker compose up --build
4. The server will be available at:
http://localhost:8000/mcp
### Run directly with Python
1. Create a virtual environment if you want one.
2. Install dependencies:
python -m pip install -e .
3. Set your PAT and the server endpoint:
export MCP_PAT="your_personal_access_token"
export MCP_ACCESS_URL="http://localhost:8000/mcp"
export MCP_HOST="0.0.0.0"
export MCP_PORT="8000"
4. Start the server:
python -m mcp_clinical.server
or:
mcp-clinical
## Access pattern
Your MCP client should connect to:
- URL: http://localhost:8000/mcp (or your EC2 HTTPS URL)
- Auth header: Authorization: Bearer <your_pat>
The server expects the same token in the `MCP_PAT` environment variable when it starts. When `MCP_PAT` is set, requests without the bearer token are rejected with a 401 response.
### VS Code MCP config
Create a `.vscode/mcp.json` file with a PAT-based config like this:
```json
{
"servers": {
"clinicalmcp-local": {
"type": "http",
"url": "http://localhost:8000/mcp",
"headers": {
"Authorization": "Bearer ${input:clinicalmcp_pat}"
}
},
"clinicalmcp-ec2": {
"type": "http",
"url": "https://your-ec2-host.example.com/mcp",
"headers": {
"Authorization": "Bearer ${input:clinicalmcp_pat}"
}
}
},
"inputs": [
{
"id": "clinicalmcp_pat",
"type": "promptString",
"description": "ClinicalMCP PAT"
}
]
}
```
Use the EC2 entry when the app is deployed on your VM.
## Available tools
- `server_access_info()`
- `clinical_trials_search(condition, max_results=5)`
- `get_company_price(ticker, range_name="1d")`
## Example calls
### ClinicalTrials.gov
- condition: "breast cancer"
- max_results: 5
### Market ticker
- ticker: "AAPL"
- range_name: "1d"
## Public EC2 deployment
The app is deployed to EC2 as a Docker container fronted by NGINX:
- EC2 instance with a public IP or Elastic IP
- security group allowing `22`, `80`, and `443` inbound (port `8000` stays closed)
- the container publishes only to `127.0.0.1:8000`, so it is not reachable directly
- NGINX listens on `80` and proxies to `127.0.0.1:8000`
- `MCP_PAT` is supplied through a `.env` file written by the deploy workflow
- GitHub Actions runs the tests, then SSHes into the instance and redeploys on pushes to `main`
Deployment is handled entirely by [.github/workflows/deploy-ec2.yml](.github/workflows/deploy-ec2.yml).
It installs Docker and NGINX if they are missing, so a bare Ubuntu instance needs no manual
preparation beyond SSH access and the security group rules.
### One-time setup
1. Launch an Ubuntu EC2 instance.
2. Open inbound ports `22`, `80`, and `443`. Do **not** open `8000`.
3. Add the repository secrets listed below.
4. Push to `main` (or run the workflow manually via **Actions -> Run workflow**).
### Required repository secrets
| Secret | Required | Purpose |
| --- | --- | --- |
| `EC2_HOST` | yes | Public DNS name or IP of the instance |
| `EC2_USER` | yes | SSH user, `ubuntu` on Ubuntu AMIs |
| `EC2_SSH_KEY` | yes | Full contents of the private key, including the BEGIN/END lines |
| `MCP_PAT` | yes | Bearer token clients must send; the deploy fails if this is empty |
| `MCP_PUBLIC_URL` | yes | Public base URL, e.g. `http://your-host` with no trailing slash |
| `FINNHUB_API_KEY` | no | Preferred market price source |
| `TWELVEDATA_API_KEY` | no | Fallback price source, defaults to `demo` |
| `MCP_ALLOWED_ORIGINS` | no | Comma-separated browser origins; empty refuses all of them |
| `CLOUDFRONT_ORIGIN_SECRET` | no | Shared header value that locks the origin to CloudFront (see below) |
`MCP_ACCESS_URL` is derived automatically as `${MCP_PUBLIC_URL}/mcp` and should not be set separately.
### What the workflow does
1. **test** job: installs the package and runs `pytest`. A failing test blocks the deploy.
2. **deploy** job:
- installs `docker.io`, `docker-compose-v2`, `git`, and `nginx` if absent
- clones or fast-forwards the repo at `/home/ubuntu/ClinicalMCP` over HTTPS
- writes `.env` (mode `600`) from the repository secrets
- runs `docker compose up -d --build` and prunes dangling images
- installs the NGINX proxy config as the `default_server` on port `80`
- smoke tests the app directly, then through NGINX, and asserts that an
unauthenticated request is rejected with `401`
The deploy is idempotent: it runs `git reset --hard origin/main`, so the instance always
matches `main`. `.env` is gitignored and survives the reset.
### Operating the deployed instance
```bash
ssh -i /path/to/key.pem ubuntu@<EC2_HOST>
cd /home/ubuntu/ClinicalMCP
sudo docker compose ps # container status and health
sudo docker compose logs -f # follow application logs
sudo docker compose restart # restart without rebuilding
sudo docker compose up -d --build # rebuild after a code change
```
## HTTPS via CloudFront
CloudFront gives you TLS on its own `*.cloudfront.net` certificate, so no custom domain
is required. Let's Encrypt cannot issue a certificate for an `*.amazonaws.com` hostname,
which is why certbot on the instance is not an option here.
**Understand the trade-off first.** CloudFront encrypts the client-to-CloudFront leg, but
the CloudFront-to-EC2 leg stays plain HTTP, because CloudFront validates origin
certificates against a public CA and an EC2 public hostname cannot have one. So the PAT is
protected across the client's own network — coffee shop Wi-Fi, ISP, corporate proxy — but
is still cleartext on the hop inside AWS. Steps 4 and 5 below reduce that to a
CloudFront-only path rather than the open internet. If you need end-to-end TLS, use a real
domain and certbot instead.
Do these in order, so you never lock yourself out of a working endpoint:
1. **Pick a secret** for the origin guard: `openssl rand -hex 32`.
2. **Create the distribution** (CloudFront console -> Create distribution):
- Origin domain: your `EC2_HOST`
- Protocol: **HTTP only**, port 80
- Add a custom header: `X-Origin-Secret` = the secret from step 1
- Viewer protocol policy: **Redirect HTTP to HTTPS**
- Allowed methods: **GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE**
- Cache policy: **CachingDisabled**
- Origin request policy: **AllViewer**
`AllViewer` matters: without it CloudFront strips the `Authorization` header and every
request returns 401. `CachingDisabled` matters because MCP responses are per-session
and must never be served from cache.
3. **Add the GitHub secrets** and redeploy: set `CLOUDFRONT_ORIGIN_SECRET` to the value
from step 1, and change `MCP_PUBLIC_URL` to `https://<id>.cloudfront.net`. Pushing to
`main` turns on the nginx origin guard and regenerates `MCP_ACCESS_URL`.
4. **Verify** `https://<id>.cloudfront.net/mcp` answers, and that a direct request to
`http://<EC2_HOST>/mcp` now returns **403** because it lacks the secret header.
5. **Restrict the security group**: change the port 80 rule's source from `0.0.0.0/0` to
the AWS-managed prefix list `com.amazonaws.global.cloudfront.origin-facing`. After this
the origin is unreachable except through CloudFront.
The deploy already trusts `X-Forwarded-For` only from AWS's published CloudFront
origin-facing ranges, refreshed from `ip-ranges.amazonaws.com` on every run. Without that,
the rate limit would count every request as coming from a handful of edge IPs and throttle
all users collectively instead of per client.
If long tool calls ever cut off, raise the distribution's **origin response timeout**
(default 30s). CloudFront streams responses fine, but will drop a stream that sits idle
past that window.
## Security model
- **Every request needs the PAT.** The check is fail-closed and applies to all paths, so
a route added later is protected by default rather than exposed by omission.
- **The server refuses to start when `MCP_PAT` is empty**, instead of silently accepting
anonymous callers. Set `MCP_ALLOW_UNAUTHENTICATED=true` to override this locally.
- **Tokens are compared with `hmac.compare_digest`**, so the PAT cannot be recovered a
byte at a time by timing the responses.
- **Requests carrying an unrecognised `Origin` header are refused with 403.** This blocks
DNS-rebinding attacks from a web page. Non-browser MCP clients send no `Origin` and are
unaffected; list trusted origins in `MCP_ALLOWED_ORIGINS` if you need browser access.
- **NGINX rate limits each client IP to 10 req/s** (`burst=20`, returning `429`), so the
PAT cannot be brute forced.
- **The container listens only on `127.0.0.1:8000`** and runs as an unprivileged user.
Port `8000` is not open in the security group; all traffic goes through NGINX.
- **SSH is key-only** (`PasswordAuthentication no`, `PermitRootLogin prohibit-password`),
and `fail2ban` bans an IP for an hour after 5 failed attempts in 10 minutes. Port `22`
stays open to `0.0.0.0/0` because GitHub-hosted runners deploy over SSH from rotating
IP ranges that cannot be expressed in a security group.
- **Origin guard**: when `CLOUDFRONT_ORIGIN_SECRET` is set, NGINX returns 403 to any
request lacking the matching `X-Origin-Secret` header, so CloudFront cannot be bypassed.
## Notes
- The NGINX config disables `proxy_buffering` and uses a long `proxy_read_timeout`. MCP
streamable HTTP holds SSE connections open, and the NGINX defaults would truncate
tool responses mid-stream.
- Requests are served over plain HTTP, so the PAT crosses the network in cleartext. For
anything beyond testing, put TLS in front: point a DNS name at the instance and run
`sudo certbot --nginx`, then update `MCP_PUBLIC_URL` to the `https://` URL.
- Keep the PAT in GitHub secrets and never commit it to source control.
- `infra/clinicalmcp.service` is left over from the earlier systemd-based deployment and is
no longer used by the workflow.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues