Skip to main content
Glama
Harshahi

MCP Clinical Research Server

by Harshahi

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

Related MCP server: Clinical Trials MCP Server

Access pattern

Your MCP client should connect to:

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:

{
  "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. 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

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.

Related MCP Connectors

Related MCP Servers