Skip to main content
Glama
README.md
# oracle-fhir-mcp

A read-only [Model Context Protocol](https://modelcontextprotocol.io) server for the Oracle Health (Cerner)
FHIR R4 **public sandbox**. It lets an MCP client find synthetic test patients, see which parts of their record
hold data, and search or read individual FHIR resources. Responses are trimmed and size-capped so they fit
in a model's context window.

It talks to one host and one tenant:

```
https://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d/
```

Every request is a `GET` that goes through one allowlisted client (`src/fhir/client.ts`). Nothing is written
back to the EHR, and the server cannot be pointed at another host.

## Tools

| Tool | What it does |
|---|---|
| `list_resources` | Lists the 42 resource types the sandbox declares, one line each (group, read/search, patient-scoped, short purpose), plus a few verified sandbox patient ids. Optional `group` filter. No network call. |
| `describe_resource` | A short recipe for one resource type: search params, required-param rules, known sandbox quirks with examples, an example query and a plain-English description. No network call. |
| `probe_patient_coverage` | For one patient id, runs a small query against every patient-scoped resource and returns a table of status, count, sampled date range, top codes and a suggested next step. Optional `resources` list to narrow or re-probe. |
| `search` | Searches one resource type with validated params. Returns trimmed rows, warnings and a `next_cursor`, or a count when `count_only` is set. Options: `max_results` (1-50, default 10), `max_pages` (1-10, default 3), `fields`, `cursor`. |
| `read` | Reads one resource by id and returns a trimmed record. For `Binary` it returns metadata plus a short text preview. |

All tools carry the MCP annotations `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`,
`openWorldHint: true`. The server also sends a short `instructions` string telling the client the usual order:
`list_resources`, then `probe_patient_coverage`, then `describe_resource` and `search`.

A typical flow: `search Patient {"name":"smart"}` to get an id, `probe_patient_coverage {"patient":"12724066"}`
to see what is there, then `search Observation {"patient":"12724066","category":"vital-signs"}`.

## Quick start

Requires Node.js 22.12 or newer.

```sh
npm ci
npm run build
```

### stdio

```sh
node dist/stdio.js      # or: npm run stdio
```

Example client config (most desktop MCP clients use this shape):

```json
{
  "mcpServers": {
    "oracle-fhir": {
      "command": "node",
      "args": ["/absolute/path/to/oracle-fhir-mcp/dist/stdio.js"]
    }
  }
}
```

The server reads `data/` and `fixtures/r4-capabilitystatement.json` at startup, relative to `dist/`, so run it
from a full checkout rather than copying `dist/` somewhere else on its own.

### HTTP (local)

```sh
MCP_PATH_TOKEN=some-long-random-string npm run dev:http
```

This serves stateless Streamable HTTP (JSON responses, `POST` only) on `http://127.0.0.1:8787/mcp/<token>`.
It binds to loopback only. A wrong or missing token gets a plain `404`.

### HTTP (Vercel)

`api/mcp/[token].ts` is a thin shim over the same handler, with `maxDuration` set to 60 seconds in
`vercel.json`. Set `MCP_PATH_TOKEN` in the project's environment variables and deploy; the endpoint is
`https://<your-deployment>/api/mcp/<token>`.

Treat the token like a password. It is the only thing protecting the endpoint, and it ends up in the URL, so
it can show up in proxy or access logs.

## Configuration

| Variable | Used by | Default | Notes |
|---|---|---|---|
| `MCP_PATH_TOKEN` | HTTP handler (dev server and Vercel) | unset | Required for HTTP. When unset, every request gets `404`. Compared in constant time. |
| `PORT` | `npm run dev:http` | `8787` | The dev server always binds `127.0.0.1`. |
| `LIVE_SANDBOX` | test runner | unset | Set to `1` to run the live tests in `test/live/` (see Testing). |

The FHIR base URL, tenant, timeouts and caps are constants in the code, not configuration. See
`src/fhir/allowlist.ts`, `src/fhir/client.ts`, `src/search/run.ts` and `src/probe/run.ts`.

## Project layout

```
api/mcp/[token].ts       Vercel function shim
src/
  server.ts              createServer(): registers the five tools and the instructions
  stdio.ts               stdio entry point
  deps.ts                wires the client, CapabilityStatement, overlay and patient list
  fhir/                  allowlist, GET-only client with retries, CapabilityStatement loader
  search/                param validation, URL building, cursors, the search/read services
  overlay/               resource overlay schema, loader, required-param rules, preflight
  probe/                 probe_patient_coverage: per-resource probe rows, bounded concurrency
  trim/                  resource/bundle trimming, flattening, caps, date and code summaries
  discovery/             list_resources and describe_resource rendering
  http/                  Streamable HTTP handler, path-token gate, rate limiter, dev server
  tools/                 MCP tool registrations and error-to-text mapping
data/
  resource-overlay.json  hand-written notes per resource: plain-English text, required params, quirks
  sandbox-patients.json  patient ids confirmed with a live read
fixtures/                recorded sandbox responses used by the offline tests
scripts/                 maintenance scripts (re-record fixtures, refresh the CapabilityStatement, etc.)
test/                    Vitest suite; test/live/ is opt-in
```

## Testing

```sh
npm run typecheck
npm test              # offline: replays recorded responses, no network
npm run test:live     # LIVE_SANDBOX=1, hits the real sandbox
```

The offline suite stubs `fetch` and replays the JSON under `fixtures/`. The live suite is excluded from
`npm test` and is slow; the sandbox sometimes times out, and those cases are recorded and skipped rather
than failed.

Maintenance scripts (all `GET` only, through the same allowlisted client):

| Script | Purpose |
|---|---|
| `npm run record-fixtures` | Re-record the response fixtures in `fixtures/responses/` |
| `npm run refresh-capability` | Re-download the R4 CapabilityStatement |
| `npm run discover-patients` | Rebuild `data/sandbox-patients.json` |
| `npm run probe-quirks` | Re-record the overlay's quirk and probe cases in `fixtures/probes/` |
| `npm run measure-context` | Check that the tool list plus instructions stay within the token budget |
| `npm run discover-vitals` | Run the three-call "what vitals exist?" flow through the MCP tools |

## Limitations

- **Read-only.** Only `GET` requests exist in the code. There is no create, update, delete or `$operation`
  support.
- **Public sandbox only.** The host and tenant are hard-coded and enforced by an allowlist. It does not work
  against a production Oracle Health / Cerner environment, and was never meant to.
- **Synthetic data.** Every patient in the sandbox is made up. Some records are odd: dates in 2099, duplicate
  or test-looking names, sparse charts. Rows dated in the future get a warning but are still returned.
- **No SMART on FHIR or user auth.** The open sandbox endpoint needs no OAuth, so there is no login, no
  patient context and no scopes. The optional HTTP token only gates who can reach this server; it is not a
  user identity.
- **Trimmed responses.** Results are cut down to fit a model's context: `text`, `meta`, `extension`,
  `contained` and `identifier` are dropped, strings are capped at 200 characters, arrays at 5 items, and
  each tool response at 6,000 characters. `Binary` content comes back as a 1,500-character text preview at most.
  If you need the full resource, this is the wrong tool.
- **Paging and counts are capped.** `search` returns at most 50 rows and follows at most 10 pages per call.
  `_count` is capped at 200, and Oracle does not honour it exactly. Counts are worked out on the client side from
  the pages actually fetched, and are marked inexact when more pages remain.
- **Probe results are samples.** `probe_patient_coverage` looks at the first page per resource only (up to 50
  rows), with 4 requests in flight, 15 seconds per resource and 45 seconds overall. Slow resources come back
  as `timeout` with a suggested narrower search.
- **Timeouts and retries.** Each upstream request times out after 25 seconds. Network errors and 429, 502,
  503 and 504 responses are retried at most twice, and a `Retry-After` wait is capped at 30 seconds. The sandbox is often slow, and some
  queries (an unfiltered `Condition` search, for example) can take longer than any of these budgets.
- **Rate limiting is basic.** The HTTP handler allows 60 requests per minute per client IP, counted in memory
  per instance. Counters reset on a cold start and are not shared between serverless instances. The client
  IP is taken from `x-real-ip` / `x-forwarded-for`, which can only be trusted behind a proxy that sets them.
- **Stateless HTTP.** No MCP sessions, no server-sent notifications; each `POST` builds a fresh server.
- **Frozen sandbox knowledge.** The CapabilityStatement, the resource overlay (required params, quirks) and the
  patient list are snapshots recorded in September 2026. If Oracle changes the sandbox, they can go stale
  until the scripts above are re-run.
- **Validation is strict.** Params must be declared in the CapabilityStatement, paging and format params
  (`_count`, `_sort`, `_elements`, and so on) are reserved for the server, and some resources need specific
  param combinations before a request is sent. A query that is valid FHIR can still be rejected here.
- **Untrusted record text.** Free text from records is tagged as `freeText` and the client is told it is data,
  not instructions. This reduces prompt-injection risk; it does not remove it.
- **Not a medical device.** This is a developer tool for exploring a test sandbox. Do not use it for
  diagnosis, treatment or any other clinical decision.

## License

MIT. See [LICENSE](LICENSE).