Oscar Health Provider MCP
by labatt
README.md
# Oscar Health Provider MCP
An [MCP](https://modelcontextprotocol.io) server for searching an Oscar Health
in-network provider directory — doctors and facilities — from an AI assistant.
Ask *"find me a male primary care physician within 10 miles who's accepting new
patients and has good reviews"* and get an answer grounded in your own plan's
network.
> **Not affiliated with, endorsed by, or supported by Oscar Health.** This is an
> unofficial client for a public provider directory. See
> [Fair use](#fair-use) before deploying it.
## Tools
| Tool | Purpose |
| --- | --- |
| `find_specialty` | Resolve free text ("cardiologist") to a specialty ID. Call this first — IDs mix Oscar Health internal codes with NUCC taxonomy codes and cannot be guessed. |
| `search_doctors` | Search physicians: specialty, distance, language, group, hospital, plus local gender and review filtering. |
| `search_facilities` | Hospitals, pharmacies, labs, urgent care. |
| `get_provider_details` | Full record for one provider — every office, education, certifications. Always fetched live. |
| `describe_plan` | Your configured plan, plus the exact values the filters accept. |
## Two things it does that a thin API wrapper would not
**Gender and review quality are filtered locally.** Oscar Health's API accepts a
`gender` parameter and silently ignores it — every value returns the same
unfiltered count — and offers no way to sort by patient reviews. Both are
therefore applied in this server, over a bounded sample of fetched pages, and
the tool descriptions say so rather than implying a server-side filter that does
not exist.
**Reviews are ranked by Wilson score, not raw percentage.** In a 150-provider
sample, 62 of 106 rated providers scored exactly 100% — so sorting on that field
ranks one review identically to two hundred. The
[Wilson lower bound](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval)
asks what the true rate plausibly is *given the sample size*:
| Reviews | 100% recommend | Score |
| --- | --- | --- |
| 1 | ✔ | 0.21 |
| 8 | ✔ | 0.68 |
| 43 | ✔ | 0.92 |
| 209 | ✔ | 0.98 |
Providers with no reviews sort **last** under `rating` and are labelled
`unrated` rather than scored zero. No reviews is not a bad review.
## How the upstream API behaves
Oscar Health's provider search is undocumented, and it **fails quietly** — an
unrecognised parameter is ignored rather than rejected, and a wrong field name
yields `undefined` rather than an error. Both produce plausible-looking output.
[`docs/upstream-api.md`](docs/upstream-api.md) records the contract as observed,
with the result counts behind each claim. The traps worth knowing before you
touch this code:
- **`gender` is accepted and silently ignored** — every value returns the same
unfiltered count.
- **NPI and `provider_id` are not searchable** — only `name_query`, and only on
a name.
- **Facet keys differ from their labels** — `language_code=ES` matches 2449 of
4772; `language_code=Spanish` matches **0**.
- **Some facet keys carry meaningful leading spaces**, and both variants exist
as distinct facets.
- **`years_experience: 0` means "not recorded"**, not zero years.
- **`sort` outside `0|1|2` returns HTTP 400**, with the reason nested in an
object rather than a string.
- **A free-text `address` is silently ignored** — searches fall back to the
network's default anchor while returning a plausible count. Only a ZIP or a
coordinate pair actually moves the search.
## Design notes
**Responses are shaped down hard.** One upstream page of 30 providers is ~225 KB
of JSON. The compact row is 14 fields plus a nested review summary — roughly
2–3k tokens for 30 results. `get_provider_details` returns the full record when
you are vetting a shortlist.
**Providers with no gender on file are returned separately, not dropped.** About
9% of records have a blank gender field. When you filter by gender they come back
in `unknownGender` with their names, so you can judge, instead of silently
disappearing.
**Caching is aggressive and persistent** (SQLite, survives restarts): 30 days for
the specialty taxonomy, 7 for search results, and never for
`get_provider_details` — that one is the verification step before you act on a
choice, so it must not serve a cached record.
**Page fetching is capped at 5** (150 providers). Narrowing a search beats
paginating it: a 10-mile PCP search returns ~631 results against ~4,772 at 50
miles.
## Setup
```bash
git clone https://github.com/labatt/oscarhealth-provider-mcp.git
cd oscarhealth-provider-mcp
npm install
npm run setup
```
`npm run setup` is interactive and does the awkward parts for you. It reads
Oscar Health's own catalogue and has you pick your **state**, then your **network**,
then your **plan by the name printed on your insurance card** — the `policyId`,
formulary tier and a sensible default ZIP all fall out of those choices, so you
never type an opaque identifier. It then runs a real search to prove the
configuration works before writing anything:
```
State
1. AL 3. FL 5. IA …
Network in FL for 2026
1. Florida EPO Off Exchange (019, EPO)
2. Florida HMO Broad (070, HMO_NAME_ONLY)
3. Florida HMO Standard (066, HMO_NAME_ONLY)
Your plan (as printed on your insurance card)
…
34. Gold Classic Standard INDIVIDUAL_4_TIER
Verifying with a real search …
✓ 4,123 primary care providers found near 33186.
✓ wrote config/plans.json (2026 Florida HMO Standard)
```
It also generates the operator credential — prompting for a password or
inventing a strong one, hashing it with argon2id — plus a session secret, and
writes `.env` with mode `600`. **The password is printed once and stored
nowhere**; save it then.
Both `config/plans.json` and `.env` are gitignored.
<details>
<summary>Configuring by hand instead</summary>
Copy `config/plans.example.json` to `config/plans.json` and fill it in. All five
values are visible in the URL of Oscar Health's own search page:
```
https://www.hioscar.com/search/?networkId=066&state=FL&year=2026&policyId=b5c9…&formularyPlanType=INDIVIDUAL_4_TIER
^^^ ^^ ^^^^ ^^^^ ^^^^^^^^^^^^^^^^
```
Then `cp .env.example .env` and generate a credential:
```bash
node -e "
const argon2 = require('argon2');
const pw = require('crypto').randomBytes(18).toString('base64url');
argon2.hash(pw, { type: argon2.argon2id }).then(h => {
console.log('PASSWORD (save this):', pw);
console.log('HASH (put in .env): ', h);
});"
```
Set `MCP_PUBLIC_URL` to your public origin — **no trailing slash**, since it is
the OAuth issuer and must match exactly.
You can define several plans and pass `plan: "<id>"` to any tool to switch.
</details>
## Running it
```bash
npm run build
node dist/server.js
```
It listens on `127.0.0.1:3070` and expects a TLS-terminating reverse proxy in
front. `MCP_PUBLIC_URL` must be the public HTTPS origin.
### Deploying with Claude Code
TLS certificates, reverse proxy config and a process manager are exactly the
kind of fiddly, host-specific work an agent is good at. If you have
[Claude Code](https://claude.com/claude-code) on the server, this prompt gets
you a working deployment:
```
Deploy the MCP server in this directory behind nginx with a Let's Encrypt
certificate, at https://mcp.example.com. Specifically:
1. Confirm DNS for mcp.example.com already points at this host, and stop if
it does not — certbot's HTTP-01 challenge will fail otherwise.
2. Run `npm run build`, then start dist/server.js under a process manager
(pm2 or systemd) bound to 127.0.0.1:3070. Verify it actually bound with
`ss -ltnp | grep 3070` — under pm2 fork mode a process can report
"online" while never binding, so do not trust the status column.
3. Add an nginx site proxying to 127.0.0.1:3070. Streamable HTTP holds the
response open, so it needs: proxy_buffering off, proxy_cache off,
proxy_read_timeout 3600s, proxy_send_timeout 3600s and
chunked_transfer_encoding on. Without these the connector is
intermittently flaky rather than obviously broken.
4. Obtain a certificate with certbot --nginx and reload.
5. Verify from outside: /healthz returns {"ok":true}; /mcp returns 401 with
a WWW-Authenticate header naming resource_metadata; and confirm that
/.env, /config/plans.json and /.git/config are NOT reachable.
Do not edit .env or anything under data/. Show me the nginx config before
reloading, and check `nginx -t` first.
```
Replace `mcp.example.com` with your host. Step 5 matters: a **401** on `/mcp` is
success — it means auth is wired and no token was supplied. A 403 means host
validation rejected the request; a 502 means the app is not listening.
<details>
<summary>Deploying by hand</summary>
```nginx
server {
server_name mcp.example.com;
location / {
proxy_pass http://127.0.0.1:3070;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Streamable HTTP holds the response open. Default buffering plus a 60s
# read timeout severs the session mid-stream, which shows up as an
# intermittently "flaky" connector rather than an obvious timeout.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding on;
}
listen 443 ssl; # certbot manages the certificate lines
}
```
The app expects exactly one hop of `X-Forwarded-For` (`trust proxy` is
`loopback`). Add another proxy in front and every request looks like it came
from `127.0.0.1`, sharing one rate-limit bucket.
Under **pm2 fork mode**, `process.argv[1]` is pm2's wrapper rather than your
app, so `isMainModule` consults `process.env.pm_exec_path` first. Without that
pm2 reports "online" with a live PID while nothing binds the port — verify with
`ss -ltnp | grep 3070`.
</details>
## Connecting a client
Add `https://your-host/mcp` as a custom MCP connector. The OAuth flow opens a
browser; sign in with your operator credentials.
**`MCP_ALLOWED_REDIRECT_HOSTS` fails closed**: until you set it, no client can
be authorized at all. Your first connection attempt is therefore *supposed* to
be refused — the refusal page names the hostname to add and shows the exact line
to paste:
```
MCP_ALLOWED_REDIRECT_HOSTS=claude.ai
```
Add it, restart, and connect again.
This is deliberate. Dynamic client registration is open, so with an empty
allowlist meaning "allow anything", anyone who found your URL could register a
client named after something you trust, point it at their own callback, and send
you an `/authorize` link. You would see a genuine consent page **on your own
domain** and type your real password, and the code would land on their host. The
consent screen names the redirect host precisely so that is noticeable — but
noticing is a judgement call made in a hurry, and a closed default is not.
Set it to `*` to allow any host, only if you have understood the above.
## Example queries
Once connected, ask in plain language. Claude resolves the specialty first, then
searches — you never handle IDs.
**Finding a doctor**
> Find me a primary care physician within 10 miles who's accepting new patients.
> Find a primary care physician within 20 miles of Tampa, FL — near the office on Boy Scout Blvd.
> I want a male cardiologist with good reviews, sorted by rating.
> Are there any female dermatologists near me who speak Spanish?
> Find pediatricians affiliated with a hospital I'd actually want to be admitted to — show me which hospitals they're affiliated with.
> Who are the ten best-reviewed PCPs in my network, and how many reviews does each have?
**Narrowing a shortlist**
> Of those five, which have been practising longest and are board certified?
> Show me everything you have on the second one — all their office locations, education and certifications.
> Is Dr. Alvarez still in my network? I want to check before I book.
**Facilities**
> Find in-network pharmacies within 5 miles.
> Which hospitals are in my network, and which are accredited?
> Is there an in-network urgent care near ZIP 33139?
**Searching somewhere else**
Pass a `zipCode` to search anywhere, or `latitude`/`longitude` for a city or
landmark — Claude geocodes it and the result echoes `searchedNear`, so if it
guessed the wrong place you can see that rather than quietly getting providers
from the wrong city.
**Understanding your plan**
> What plan am I configured for, and what network is it?
> What languages can I filter doctors by?
> List the medical groups in my network so I can filter by one.
### What the answers will and won't tell you
Claude will say when a filter ran locally rather than upstream, because it
matters: **gender and review filters operate on a fetched sample**, not the
whole directory. A search that reports "applied locally over 90 fetched
providers" out of 4,772 has genuinely looked at 90. Narrow with a distance or a
more specific specialty and the sample covers proportionally more of what
matches.
You will also see providers with **no gender on file returned separately** rather
than dropped, with their names — about 9% of records — so you can judge for
yourself instead of losing them silently.
Things it deliberately cannot do: book appointments, quote your out-of-pocket
cost, or read anything from your member account. It only searches the public
provider directory.
## Fair use
Oscar Health's `robots.txt` disallows `/search/*` and `/member/*`. That directive
addresses search-engine crawling; this server makes low-volume, user-initiated
lookups against a directory that CMS transparency rules require insurers to
publish. It is built to stay on that side of the line:
- an honest User-Agent identifying the software — no browser impersonation
- a serialised outbound throttle, and in-flight de-duplication of identical requests
- a hard 5-page cap, so bulk enumeration is not reachable through any tool
- aggressive persistent caching, which is the main politeness mechanism
**Please keep those.** They are the difference between a personal lookup tool and
a scraper. Deploy it for your own plan; do not point it at a directory you have
no relationship with, and do not mirror the data.
### Known limitations
Verified during a security review and accepted rather than fixed, so you can
judge them yourself:
- **Tool calls are not audited.** `logs/audit.jsonl` records every OAuth grant —
client name, id, redirect URI, timestamp — but nothing about what an
authorized client subsequently searched.
- **No refresh-token reuse detection.** Rotation spends the presented token, so
a stolen one stops working once the legitimate client rotates; but reuse
breaks that client rather than revoking the whole token family.
- **No audience binding.** The `resource` parameter is stored and not enforced
(RFC 8707). Harmless with a single resource server, which is the only
supported deployment.
- **Client secrets are stored in plaintext** in `data/oauth.db`. The database
and its WAL sidecars are force-chmodded to `0600` on every start.
## Development
```bash
npm test # 161 offline tests, no network
npm run typecheck # type-checks src/ AND test/
npm run test:contract # 17 assertions against the live API — slow, real requests
npm run build
```
Test fixtures are **synthetic**. They mirror the real response shape exactly,
including its quirks, but contain no real clinicians.
### When the contract tests fail
`npm run test:contract` pins every upstream fact this server depends on. A
failure means Oscar Health changed something and the *documentation* needs updating,
not the test. The ones most worth watching:
- **`gender` is still ignored server-side.** If it starts working, delete the
local filter and send it upstream.
- **NPI is still unsearchable.** `get_provider_details` carries a `name`
parameter purely to work around this; if Oscar Health adds NPI search, simplify it.
- **Facet keys still differ from labels.** `language_code=ES` matches;
`language_code=Spanish` returns zero.
## Licence
MIT. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues