Skip to main content
Glama
README.md
# healthcare-mock-mcp

A **Model Context Protocol (MCP)** server that exposes 18 synthetic
healthcare-administration tools over **Streamable HTTP**, built with
Next.js App Router and [`mcp-handler`](https://www.npmjs.com/package/mcp-handler).
It also exposes a small set of plain **REST `GET` endpoints** over the same
underlying data, for callers that need clean JSON rather than MCP's
JSON-RPC/SSE envelope — e.g. a [data-validation
config](#rest-api-endpoints) that checks values an agent stated during a
conversation against this system of record.

It exists to let a voice/chat agent (e.g. [Vapi](https://vapi.ai)) rehearse
member-service conversations — eligibility checks, benefits lookups, claims
status, pharmacy pricing, case creation — against realistic-shaped data,
**without touching any real PHI**. Every record returned by every tool or
endpoint is fabricated by this repo at startup; nothing is fetched from, or
written to, a real payer, PBM, or EHR system.

> ⚠️ **Not a real healthcare system.** No diagnosis, treatment, dosage
> guidance, medication substitution, emergency dispatch, or real coverage
> determination is performed anywhere in this codebase. See
> [Safety model](#safety-model) below.

---

## Contents

- [Architecture](#architecture)
- [Folder structure](#folder-structure)
- [File-by-file description](#file-by-file-description)
- [How the mock data is built](#how-the-mock-data-is-built)
- [The 18 tools](#the-18-tools)
- [Response envelope & error codes](#response-envelope--error-codes)
- [Running it locally](#running-it-locally)
- [Calling the server manually](#calling-the-server-manually)
- [REST API endpoints](#rest-api-endpoints)
- [Deploying to Vercel](#deploying-to-vercel)
- [Wiring it into Vapi](#wiring-it-into-vapi)
- [Safety model](#safety-model)

---

## Architecture

```mermaid
flowchart TB
    subgraph Clients["Clients"]
        Vapi["Vapi voice agent\n(or any MCP client)"]
        REST["REST caller\n(e.g. a data-validation config)"]
    end

    subgraph Server["Next.js app (Vercel)"]
        Route["app/api/mcp/route.ts\nStreamable HTTP endpoint\ncreateMcpHandler(...)"]
        RestRoutes["app/api/members|eligibility|dependents|claims|pharmacy|providers/[memberId]/route.ts\nPlain GET endpoints"]
        Tools["lib/healthcare-tools.ts\n18 MCP tool handlers +\n3 REST-only profile aggregators\n(validation + envelope)"]
        Data["lib/demo-data.ts\nSynthetic dataset\n(seeded PRNG, generated once\nat module load / cold start)"]
    end

    Vapi -- "POST /api/mcp\nJSON-RPC: tools/list, tools/call" --> Route
    Route -- "registers 18 Zod-validated\ntool schemas" --> Route
    Route -- "delegates each tools/call\nto the matching handler" --> Tools
    REST -- "GET /api/{domain}/{memberId}" --> RestRoutes
    RestRoutes -- "delegates to the matching\ntool/aggregator function" --> Tools
    Tools -- "reads/looks up\n(never mutates)" --> Data
    Tools -- "ToolSuccess | ToolError\nenvelope" --> Route
    Tools -- "ToolSuccess | ToolError\nenvelope" --> RestRoutes
    Route -- "JSON-RPC result\n(SSE-framed)" --> Vapi
    RestRoutes -- "plain JSON\n(200 or 404/400)" --> REST
```

**Request flow for one MCP tool call:**

1. The MCP client sends `POST /api/mcp` with a JSON-RPC `tools/call` message
   (`{"method":"tools/call","params":{"name":"get_demo_eligibility","arguments":{"memberId":"M1000"}}}`).
2. `mcp-handler` (inside `route.ts`) parses the request, validates the
   arguments against the tool's Zod schema, and invokes the matching
   function in `healthcare-tools.ts`.
3. That function validates required fields, looks up synthetic records in
   `demo-data.ts`, and returns either a `success` envelope with `data`, or
   an `error` envelope with a structured `code`/`message` — it **never**
   fabricates data ad hoc or falls back to a default member.
4. `route.ts` wraps the result as MCP tool-call content and streams it back
   as a JSON-RPC response.

**Request flow for one REST call** (e.g. `GET /api/claims/M1000`): the
dynamic route resolves `memberId` from the URL, calls the matching function
in `healthcare-tools.ts` directly (no JSON-RPC framing), and returns the same
`ToolSuccess`/`ToolError` envelope as plain JSON — `200` on success, `404`
for `member_not_found`, `400` for any other tool error. See
[REST API endpoints](#rest-api-endpoints) for the full list.

There is **no database and no persistence**. The dataset is generated once
per server process (per Vercel cold start) from a fixed seed, so it's
stable within a session but does not survive a redeploy — see
[How the mock data is built](#how-the-mock-data-is-built).

---

## Folder structure

```
healthcare-mock-mcp/
├── app/
│   └── api/
│       ├── mcp/
│       │   └── route.ts                  # MCP Streamable HTTP endpoint (GET/POST/DELETE)
│       ├── members/[memberId]/route.ts   # REST: GET member profile
│       ├── eligibility/[memberId]/route.ts # REST: GET eligibility
│       ├── dependents/[memberId]/route.ts # REST: GET dependents
│       ├── claims/[memberId]/route.ts    # REST: GET claims + accumulators + benefits catalog
│       ├── pharmacy/[memberId]/route.ts  # REST: GET formulary + pharmacies + rx rejections
│       └── providers/[memberId]/route.ts # REST: GET PCP + provider directory
├── lib/
│   ├── demo-data.ts             # Synthetic dataset + lookup helpers
│   └── healthcare-tools.ts      # 18 MCP tool implementations + 3 REST-only
│                                 # profile aggregators + response envelope
├── member-validation-config.json # Example data-validation config for the REST endpoints
├── .gitignore
├── next-env.d.ts                 # Auto-generated by Next.js (gitignored)
├── package.json
├── package-lock.json
└── tsconfig.json
```

## File-by-file description

### `app/api/mcp/route.ts`
The MCP server's entry point.

- Calls `createMcpHandler(registerFn, serverOptions, config)` from
  `mcp-handler`, which builds a single Fetch-API-compatible handler
  supporting **Streamable HTTP** (`POST` for JSON-RPC calls; `GET`/`DELETE`
  to that same path are explicitly rejected — see
  [Calling the server manually](#calling-the-server-manually) for why a
  browser visit to the URL returns a `405`).
- Registers all 18 tools with `server.tool(name, description, zodSchema, handler)`.
  Every argument is declared `.optional()` in the Zod schema — required-ness
  is enforced *inside* each tool function (via `missing_required_parameter`),
  not at the schema layer, so a missing field always comes back as a
  structured tool error rather than an MCP-level schema-validation error.
- Each handler is a one-line delegation: it calls the matching function in
  `healthcare-tools.ts` and wraps the JS object it returns as
  `{ content: [{ type: "text", text: JSON.stringify(result) }] }`, which is
  the MCP content-block shape clients expect.
- Exports `{ handler as GET, handler as POST, handler as DELETE }` — Next.js
  App Router route handlers are one exported function per HTTP verb; all
  three point at the same `mcp-handler`-produced function, which internally
  branches on `request.method`.
- Carries a `TODO` comment marking where an `Authorization` header check
  should be added before this is exposed beyond local/mock evaluation (see
  [Safety model](#safety-model)).

### `app/api/{members,eligibility,dependents,claims,pharmacy,providers}/[memberId]/route.ts`
Six plain REST `GET` endpoints, added alongside the MCP endpoint for
callers that need clean JSON rather than MCP's JSON-RPC/SSE envelope — see
[REST API endpoints](#rest-api-endpoints) for full examples. Each is a
thin, near-identical wrapper:

- Reads `memberId` out of the dynamic route segment (`params` is a
  `Promise` under Next.js 15's App Router), and — for the four "bundle"
  routes (dependents, claims, pharmacy, providers) — spreads every query
  param (`req.nextUrl.searchParams`, via `Object.fromEntries`) into the
  same call. Any new optional filter a tool function starts reading just
  works from the URL automatically; no route file changes needed.
- Calls exactly one function in `healthcare-tools.ts` (`getDemoMember`,
  `getDemoEligibility`, `getDemoDependents`, `getDemoClaimsBenefitsProfile`,
  `getDemoPharmacyProfile`, or `getDemoProviderProfile`) — no business
  logic lives in the route file itself. `getDemoDependents` is the one
  case reusing an existing MCP tool function directly rather than a
  REST-only aggregator, since it already returns exactly the shape needed.
- Returns the function's `ToolSuccess`/`ToolError` envelope as-is via
  `NextResponse.json(result, { status: restErrorStatus(result.error.code) })`
  on failure (every `*_not_found` code → `404`, everything else → `400`) or
  `200` on success. `restErrorStatus` is a small shared helper exported
  from `healthcare-tools.ts` so this mapping stays consistent as new error
  codes get added, instead of being re-implemented per route.
- Same synthetic-only data as the MCP tools (they share the same lookup
  helpers in `demo-data.ts`) — nothing here is a separate data source.

### `lib/healthcare-tools.ts`
The business logic for all 18 MCP tools, plus 3 REST-only aggregator
functions — pure functions, no HTTP concerns.

- **Envelope helpers** (`ok`, `err`) build the two response shapes every
  tool returns: `ToolSuccess<T>` (`success: true`, `data`, `asOf`,
  `warnings`) and `ToolError` (`success: false`, `error: { code, message }`).
  `synthetic: true` and the `tool` name are stamped on every response.
- **`requireString`** — a small validator every tool calls first for each
  required string field. Missing or blank → `missing_required_parameter`.
  This is also the reason **a missing `memberId` never silently defaults to
  a fallback member**: if the field isn't a non-empty string, the function
  returns before any lookup happens.
- **`requireMember`** — wraps `findMember` from `demo-data.ts`; converts a
  miss into `member_not_found`. Used only by the two tools where `memberId`
  is a secondary, optional cross-check (`search_demo_providers`,
  `escalate_demo_conversation`) rather than the primary lookup key.
- **`resolveMember`** — the primary member-identification path, used by
  every tool that needs to find *a* member before doing anything else (14
  of the 18 tools). A caller may identify the member by `memberId`
  (authoritative — looked up alone if present), by full name (`firstName`
  **and** `lastName` together), by `dob`, or any combination of the three.
  At least one complete identifier is required
  (`missing_required_parameter` if none is given); zero matches returns
  `member_not_found`; more than one match (possible only via name/dob,
  since `memberId` is unique) returns `ambiguous_member_match` asking for
  an additional identifier. See [`findMembersByIdentifiers`](#libdemo-datats)
  below for the matching logic.
- **`seededFraction(seed)`** — an FNV-1a-style string hash used wherever a
  tool needs a "random-looking" but **stable** derived number (a cost
  estimate, a pharmacy distance, a confirmation number). Hashing the input
  arguments means calling `estimate_demo_cost` twice with identical
  arguments returns the identical estimate, without needing to persist
  anything.
- **18 exported functions**, one per tool (see [The 18 tools](#the-18-tools)),
  grouped by domain with comment banners: member/eligibility, benefits,
  claims/EOB, pharmacy/PBM, case/escalation.
- The two simulated *write* tools — `simulateDemoAppeal` and
  `createDemoCase` — both check `input.confirmed !== true` and return
  `confirmation_required` if the caller didn't explicitly confirm. Neither
  one persists anything; "creating" a case just means returning a
  deterministically-derived case number.
- **3 REST-only profile aggregators** (`getDemoClaimsBenefitsProfile`,
  `getDemoPharmacyProfile`, `getDemoProviderProfile`) — not registered as
  MCP tools, only consumed by the REST routes. Each bundles several related
  lookups behind one `memberId`-keyed call: the claims/benefits one returns
  a member's full claims history plus their accumulators plus the entire
  benefits catalog; the pharmacy one returns the full formulary, pharmacy
  directory, and all 4 canned prescription-rejection scenarios; the
  provider one resolves the member's `pcpProviderId` to the full provider
  record (name, specialty, location, network status) instead of just the
  ID, plus the full 16-provider directory. All three require an explicit
  return-type annotation (`ToolResult<...>`) rather than relying on
  inference — see the code comment on `requireMember` for why (a real
  `undefined`-narrowing bug was caught here once these results were first
  checked with `.success`/`.error`, which the MCP path never did).
- **Optional narrowing filters** on the four "bundle" functions
  (`getDemoDependents`, `getDemoClaimsBenefitsProfile`,
  `getDemoPharmacyProfile`, `getDemoProviderProfile`) — each accepts extra
  optional `input` fields (`dependentId`, `claimId`/`serviceType`/
  `networkLevel`, `drugName`/`pharmacyId`/`prescriptionReference`,
  `providerId`) that narrow the matching array/list field down to 0-or-1
  entries, mirroring how the single-record MCP tools (`get_demo_claim_details`,
  `get_demo_formulary`, etc.) take an ID as an argument and filter
  server-side rather than making the caller filter a full response
  client-side. `getDemoDependents` is shared with the `get_demo_dependents`
  MCP tool, so that tool gained `dependentId` too; the other three are
  REST-only, so their filters are REST-only. Each filter independently
  returns a domain-specific `*_not_found` error (via `err(...)`) when the
  ID doesn't match, except `pharmacyId`, which returns an empty list — no
  error — mirroring `search_demo_pharmacies`'s existing zero-match
  behavior.
- **`restErrorStatus(code)`** — maps a `ToolError.error.code` to an HTTP
  status for the REST routes: any code ending in `_not_found` → `404`,
  everything else → `400`. Centralizing this means a new `_not_found` code
  (like the `dependent_not_found`/`provider_not_found` added alongside the
  filters above) automatically gets the right status everywhere, without
  touching route files.
- **`prescriptionRejections` is exposed as an array**, not the underlying
  `PRESCRIPTION_REJECTIONS` `Record<string, ...>` — `getDemoPharmacyProfile`
  maps it to `{ reference, code, explanation, nextAction }[]`. This keeps
  it consistent with every other catalog here (array, filterable, always
  `[0]`-addressable after filtering) instead of being the one field whose
  filtered result would need a *dynamic* object key.

### `lib/demo-data.ts`
The synthetic dataset and the only place randomness is generated. See
[How the mock data is built](#how-the-mock-data-is-built) for the full
mechanics.

- A **seeded PRNG** (`mulberry32`) plus two helpers (`pick`, `int`) used to
  generate names, locations, and numeric ranges deterministically.
- Reference lists: `FIRST_NAMES`, `LAST_NAMES`, `CITIES`, `PLANS`,
  `SPECIALTIES` — the raw pools the generators sample from.
- Generated collections, each a `const` array built once at module load:
  `PROVIDERS` (16), `MEMBERS` (20), `DEPENDENTS` (derived from members),
  `ACCUMULATORS` (one entry per member, keyed by `memberId`), `BENEFITS`
  (9 service types × 2 network levels = 18 entries), `CLAIMS` (1–3 per
  member for the first 15 members).
- Two **hand-authored** (non-generated) tables: `FORMULARY` (10 drugs across
  tiers 1–4, including one deliberately `covered: false` entry for testing
  `drug_not_found`-adjacent flows) and `PHARMACIES` (5 pharmacies covering
  retail/mail-order/specialty and all three network categories).
- `PRESCRIPTION_REJECTIONS` — a fixed lookup of 4 canned rejection
  scenarios (`RX-DEMO-0001`..`0004`) covering PA-required, refill-too-soon,
  not-covered, and quantity-limit-exceeded.
- **Lookup helpers** at the bottom (`findMember`, `findMembersByIdentifiers`,
  `findDependents`, `findAccumulators`, `findClaim`, `findClaimsByMember`,
  `findProvider`, `findFormularyEntry`) — the only functions
  `healthcare-tools.ts` imports from this file. Tool code never reaches into
  the raw arrays directly. `findMembersByIdentifiers({ memberId, firstName,
  lastName, dob })` is what backs `resolveMember` (see above): if
  `memberId` is given it's looked up alone (authoritative, unique); otherwise
  it filters `MEMBERS` by whichever of `firstName`/`lastName`/`dob` were
  supplied, case-insensitively for names, and can return 0, 1, or (rarely,
  since first/last names are assigned 1:1 per member in this fixed dataset —
  a DOB collision is the only realistic way to get more than one match)
  multiple matches.

### `package.json`
Standard Next.js scripts (`dev`, `build`, `start`) plus the four runtime
dependencies this project actually needs: `next`, `react`, `react-dom`,
`mcp-handler`, `@modelcontextprotocol/sdk`, `zod`. No test framework, ORM,
or database client — there's nothing to test against except the
deterministic data generator, and nothing to persist.

### `tsconfig.json`
Standard Next.js App Router TypeScript config: `moduleResolution: "bundler"`,
`jsx: "preserve"`, strict mode on, and the `@/*` path alias used by
`route.ts`'s `import * as tools from "@/lib/healthcare-tools"`.

### `next-env.d.ts`
Auto-generated by `next dev`/`next build` on first run. It's listed in
`.gitignore` — don't hand-edit it; if it's ever missing, running the dev
server regenerates it.

### `.gitignore`
Excludes `node_modules/`, `.next/` (build output), `*.tsbuildinfo`,
`next-env.d.ts`, `.env*` (secrets — see [Safety model](#safety-model)), and
`.vercel/` (Vercel CLI's local project link).

---

## How the mock data is built

All synthetic data lives in `lib/demo-data.ts` and is built **once, at
module import time** (i.e., once per server process / Vercel cold start) —
there's no build step, seed script, or database migration to run.

1. **Deterministic randomness.** A [mulberry32](https://github.com/bryc/code/blob/master/jshash/PRNGs.md)
   PRNG is seeded with the fixed literal `20260101`. Two thin wrappers,
   `pick(array)` and `int(min, max)`, are the only way the generators touch
   randomness. Because the seed is a hardcoded constant, **the same 20
   members, 16 providers, and claims come out every time the process
   starts** — useful for demos and for writing tests/scripts against
   specific IDs (`M1000`, `PRV2000`, `CLM5000`, ...) that will always exist.
2. **Providers first** (`PROVIDERS`, 16 entries) — each gets a synthetic
   name (`Dr. {first} {last}`), a specialty, a city, and randomized
   `networkStatus` (80% in-network) / `acceptingNewPatients` (60% true).
   Providers are generated before members because members reference them.
3. **Members** (`MEMBERS`, 20 entries, IDs `M1000`–`M1019`) — name, DOB,
   gender, location, and a random plan (PPO/HMO/EPO) are assigned per
   member. `M1019` (index 19) is **deliberately hardcoded inactive** with a
   termination date, so `get_demo_eligibility`/`coverageStatus` has a
   guaranteed non-active case to demo. Every 4th member (`i % 4 === 0`) gets
   `pcpProviderId: null` to exercise the "no PCP assigned" branch of
   `get_demo_pcp`.
4. **Dependents** (`DEPENDENTS`) — derived from `MEMBERS` via `flatMap`:
   members at every 5th index get 2 dependents (one spouse + one child),
   members at every 3rd index get 1 (a child), everyone else gets 0. This
   guarantees both "member with a spouse" and "member with no dependents"
   cases exist for `get_demo_dependents`.
5. **Accumulators** (`ACCUMULATORS`, keyed by `memberId`) — every member
   gets individual deductible/out-of-pocket progress (`$1,500`/`$6,000`
   limits) with a random amount already "met". Members who have at least
   one dependent also get a family accumulator (`$3,000`/`$12,000`
   limits); members with no dependents get `family: null`.
6. **Benefits** (`BENEFITS`, 18 entries) — built by mapping 9 service types
   (`primary_care_visit`, `emergency_room`, `imaging`, ...) across both
   network levels. In-network entries get a flat copay (except ER, which
   uses 20% coinsurance instead); out-of-network entries always use 40%
   coinsurance, always apply the deductible, and always require prior
   authorization — modeling the usual real-world asymmetry without
   claiming to be a real plan document.
7. **Claims** (`CLAIMS`) — generated only for the **first 15** of the 20
   members (so 5 members have zero claim history, for testing empty-result
   search behavior). Each of those members gets 1–3 claims with a random
   billed amount, an allowed amount computed as 50–80% of billed, a status
   cycled through `submitted → processing → paid → denied`, and a
   `processingHistory` timeline whose last entry only appears once the
   claim reaches `paid` or `denied`.
8. **Formulary and pharmacies** are **not** procedurally generated — they're
   short, hand-written tables (10 drugs, 5 pharmacies) chosen to cover every
   tier (1–4), every requirement flag (PA, step therapy, quantity limit,
   specialty), and one intentionally non-covered drug
   (`experimental-compound-x`), so every branch of `get_demo_formulary` /
   `price_demo_medication` has a matching fixture.
9. **Prescription rejections** are a fixed 4-entry map, since they represent
   canned scenarios (`get_demo_prescription_rejection`) rather than
   naturally-occurring data tied to a member's claim history.

At request time, tool functions never touch these arrays directly — they go
through the **lookup helpers** at the bottom of the file (`findMember`,
`findClaim`, etc.), which is what keeps `healthcare-tools.ts` free of any
array-scanning logic and easy to unit test in isolation if you add tests
later.

**Regenerating the dataset:** there's no separate "build the mock data"
command — it happens automatically every time the Node process starts
(`npm run dev`, `npm run build && npm start`, or a fresh Vercel cold start).
To get a *different* dataset, change the seed literal on the `mulberry32(...)`
call at the top of `demo-data.ts`; to get a *larger* one, change the
`Array.from({ length: N }, ...)` counts for `PROVIDERS`/`MEMBERS`.

---

## The 18 tools

Tools marked **member-identified** accept `memberId` **or** full name
(`firstName` + `lastName`) **or** `dob`, in any combination — see
[Member identification](#member-identification) below. Tools marked
`memberId` (secondary) only use it as an optional cross-check, not a
lookup key.

| Tool | Required inputs | Purpose |
|---|---|---|
| `get_demo_member` | member-identified | Synthetic member's basic profile and plan info |
| `get_demo_eligibility` | member-identified | Coverage status, plan, effective/termination dates |
| `get_demo_dependents` | member-identified | Dependents and their coverage status |
| `get_demo_pcp` | member-identified | PCP assignment, or "no PCP assigned" |
| `search_demo_providers` | `specialty`, `location` | Provider directory search + network status |
| `get_demo_benefits` | member-identified, `serviceType` | Copay, coinsurance, deductible, limits, exclusions, PA requirement |
| `get_demo_accumulators` | member-identified | Individual/family deductible & OOP totals |
| `estimate_demo_cost` | member-identified, `serviceType` | Simulated cost range with assumptions (estimate-only) |
| `search_demo_claims` | member-identified | Claims matching optional filters |
| `get_demo_claim_details` | `claimId` | Detailed claim status, amounts, codes, history |
| `get_demo_eob` | `claimId` | Billed/allowed/plan-paid/disallowed/member-responsibility amounts |
| `simulate_demo_appeal` | member-identified, `claimId`, `reason`, `confirmed` | Simulated appeal submission (requires `confirmed=true`) |
| `get_demo_formulary` | member-identified, `drugName` | Coverage tier, PA, step therapy, quantity limits |
| `price_demo_medication` | member-identified, `drugName`, `daysSupply` | Simulated pricing by pharmacy channel |
| `search_demo_pharmacies` | member-identified, `location` | Pharmacy search with network category & distance |
| `get_demo_prescription_rejection` | member-identified, `prescriptionReference` | Simulated rejection code + explanation + next action |
| `create_demo_case` | member-identified, `category`, `summary`, `confirmed` | Simulated case creation (requires `confirmed=true`) |
| `escalate_demo_conversation` | `reason`, `urgency` | Simulated escalation routing (`memberId` optional cross-check) |

Full argument lists (including optional fields) are declared as Zod schemas
in `app/api/mcp/route.ts`.

### Member identification

The 14 member-identified tools resolve the member from whichever of these
arguments are supplied — **any one is enough**, and supplying more than
one narrows a potential multi-match:

- `memberId` — authoritative; if present, it's looked up alone (`M1000`–`M1019` in the seeded dataset).
- `firstName` **and** `lastName` together (a partial name alone isn't treated as a complete identifier).
- `dob` — `YYYY-MM-DD`, matched exactly against the synthetic member's date of birth.

Providing none of the three returns `missing_required_parameter`; matching
zero members returns `member_not_found`; matching more than one (only
realistically possible via `dob` collision, since first/last names are
assigned 1:1 per member in the seeded dataset) returns
`ambiguous_member_match`.

---

## Response envelope & error codes

Every tool returns one of two shapes:

```jsonc
// success
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_eligibility",
  "data": { /* tool-specific */ },
  "asOf": "2026-09-09T17:30:00.000Z",
  "warnings": []
}
```

```jsonc
// error
{
  "success": false,
  "synthetic": true,
  "tool": "get_demo_eligibility",
  "error": {
    "code": "member_not_found",
    "message": "No synthetic member matched memberId M9999."
  }
}
```

Error codes in use: `missing_required_parameter`, `member_not_found`,
`ambiguous_member_match`, `claim_not_found`, `claim_member_mismatch`,
`drug_not_found`, `prescription_not_found`, `confirmation_required`,
`conflicting_demo_data`, `dependent_not_found` (REST-only, from the
`dependentId`/`?dependentId=` filter — see [REST API
endpoints](#rest-api-endpoints)), `provider_not_found` (REST-only, from
the `?providerId=` filter).

---

## Running it locally

```bash
npm install
npm run dev
```

The server listens at `http://localhost:3000` — the MCP endpoint is at
`/api/mcp`; the REST endpoints (see [REST API
endpoints](#rest-api-endpoints)) are at `/api/members/{memberId}`,
`/api/eligibility/{memberId}`, `/api/dependents/{memberId}`,
`/api/claims/{memberId}`, `/api/pharmacy/{memberId}`, and
`/api/providers/{memberId}`. You can check it's up with:

```bash
netstat -ano | grep ":3000" | grep LISTENING   # Git Bash
```

> Visiting that URL in a **browser** will show a `405 Method not allowed`
> error — that's expected. Streamable HTTP only accepts `POST` for JSON-RPC
> calls; a browser tab only ever sends `GET`.

---

## Calling the server manually

List all tools:

```bash
curl -s -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Call one tool:

```bash
curl -s -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_demo_member","arguments":{"memberId":"M1000"}}}'
```

Pretty-print the result (the response is SSE-framed, so strip the `data: `
prefix before parsing JSON):

```bash
curl -s -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_demo_member","arguments":{"memberId":"M1000"}}}' \
  | sed -n 's/^data: //p' \
  | node -e "const r=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log(JSON.stringify(JSON.parse(r.result.content[0].text), null, 2))"
```

PowerShell equivalent (list call):

```powershell
Invoke-RestMethod -Uri "http://localhost:3000/api/mcp" -Method Post `
  -ContentType "application/json" `
  -Headers @{ Accept = "application/json, text/event-stream" } `
  -Body '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

**Known-good IDs to try:** members `M1000`–`M1019` (`M1019` is inactive;
`M1000` = Jordan Alvarez, dob `1980-05-24`, so `{"firstName":"Jordan","lastName":"Alvarez"}` or `{"dob":"1980-05-24"}` alone also resolves it),
providers `PRV2000`–`PRV2015`, claims `CLM5000`+ (only members `M1000`–`M1014`
have claims), prescription references `RX-DEMO-0001`–`0004`, formulary drugs
`metformin`, `semaglutide`, `adalimumab`, `experimental-compound-x` (not
covered).

---

## REST API endpoints

Unlike `/api/mcp` (JSON-RPC over Streamable HTTP, `POST` only), these are
plain `GET` routes on the **same** running server — open one directly in a
browser, `curl`, or Postman, no JSON-RPC envelope or SSE framing involved.
They read the same synthetic dataset as the MCP tools (same `demo-data.ts`,
same seed), just returned as flat JSON.

**Start the server first** (see [Running it locally](#running-it-locally)):

```bash
npm run dev
```

Every endpoint takes a `memberId` path segment and returns the tool's
standard envelope:

- Match → `HTTP 200`, `{ "success": true, "synthetic": true, "tool": "...", "data": { ... }, "asOf": "...", "warnings": [] }`
- Unknown `memberId` → `HTTP 404`, `{ "success": false, "synthetic": true, "tool": "...", "error": { "code": "member_not_found", "message": "..." } }`

**Optional server-side filtering.** The four "bundle" endpoints (dependents,
claims, pharmacy, providers) return every record for the member by default,
but each also accepts optional query params that narrow one field down to a
single matching record — mirroring how the corresponding MCP tool takes
that same ID as an argument and does the lookup server-side, rather than
returning everything and expecting the caller to filter it out
client-side. A filtered response keeps the same shape (still an array/list
field), just with 0-or-1 items, so `[0]` always addresses "the requested
record, or the first one if no filter was given." An unmatched ID on most
filters returns `404` with a dedicated `_not_found` error code (exception:
`?pharmacyId=` mirrors `search_demo_pharmacies` and returns an empty list
instead of erroring). See each domain section below for its specific
params.

**Known-good IDs:** members `M1000`–`M1019` (`M1019` is inactive); claims
`CLM5000`+ (only `M1000`–`M1014` have claims — see the claims/benefits
response for exact IDs per member).

**Live deployment:** `https://healthcare-mock-mcp.vercel.app` — every
example below works against it as-is, or swap in `http://localhost:3000`
for a local dev server.

| # | Domain | Method & path | Underlying tool function | Bundles | Optional filter query params |
|---|---|---|---|---|---|
| 1 | Member | `GET /api/members/{memberId}` | `getDemoMember` | Profile + plan | — (already a single record) |
| 2 | Eligibility | `GET /api/eligibility/{memberId}` | `getDemoEligibility` | Coverage status + plan dates | — (already a single record) |
| 3 | Dependents | `GET /api/dependents/{memberId}` | `getDemoDependents` | Spouse/child records + their coverage status | `dependentId` |
| 4 | Claims & Benefits | `GET /api/claims/{memberId}` | `getDemoClaimsBenefitsProfile` | Claims history + accumulators + benefits catalog | `claimId`; `serviceType` (+ optional `networkLevel`, defaults to `in_network`) |
| 5 | Pharmacy/PBM | `GET /api/pharmacy/{memberId}` | `getDemoPharmacyProfile` | Formulary + pharmacy directory + prescription rejections | `drugName`; `pharmacyId`; `prescriptionReference` |
| 6 | Providers | `GET /api/providers/{memberId}` | `getDemoProviderProfile` | PCP assignment (resolved to full provider record) + provider directory | `providerId` |

### 1. Member domain — `GET /api/members/{memberId}`

Basic profile and plan info: `memberId`, `firstName`, `lastName`, `dob`,
`gender`, `location`, `plan`, `coverageStatus`, `pcpProviderId`.

```bash
curl -s https://healthcare-mock-mcp.vercel.app/api/members/M1000
```

```powershell
Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/members/M1000" -Method Get
```

Example response (`M1000`):

```jsonc
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_member",
  "data": {
    "memberId": "M1000",
    "firstName": "Jordan",
    "lastName": "Alvarez",
    "dob": "1980-05-24",
    "gender": "F",
    "location": { "city": "Cedar Hollow", "state": "NC", "zip": "27601" },
    "plan": {
      "name": "Synthetic PPO Gold",
      "type": "PPO",
      "effectiveDate": "2026-01-01",
      "terminationDate": null
    },
    "coverageStatus": "active",
    "pcpProviderId": null
  },
  "asOf": "2026-09-14T00:42:46.743Z",
  "warnings": []
}
```

### 2. Eligibility domain — `GET /api/eligibility/{memberId}`

Coverage status, plan, effective/termination dates, and data timestamp:
`memberId`, `coverageStatus`, `plan`, `effectiveDate`, `terminationDate`,
`dataTimestamp`.

```bash
curl -s https://healthcare-mock-mcp.vercel.app/api/eligibility/M1000
```

```powershell
Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/eligibility/M1000" -Method Get
```

Example response (`M1000`):

```jsonc
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_eligibility",
  "data": {
    "memberId": "M1000",
    "coverageStatus": "active",
    "plan": {
      "name": "Synthetic PPO Gold",
      "type": "PPO",
      "effectiveDate": "2026-01-01",
      "terminationDate": null
    },
    "effectiveDate": "2026-01-01",
    "terminationDate": null,
    "dataTimestamp": "2026-09-09T00:00:00.000Z"
  },
  "asOf": "2026-09-14T00:42:51.422Z",
  "warnings": []
}
```

Try `M1019` to see the inactive-coverage case (`coverageStatus: "inactive"`,
a non-null `terminationDate`).

### 3. Dependents domain — `GET /api/dependents/{memberId}`

A member's spouse/child records and their coverage status: `memberId`,
`dependents[]` (each with `dependentId`, `memberId`, `name`,
`relationship`, `dob`, `coverageStatus`).

**Optional query param:** `dependentId` — narrows `dependents[]` to the one
matching entry. `404 dependent_not_found` if it doesn't belong to this
member. E.g. `?dependentId=M1000-D2`.

```bash
curl -s https://healthcare-mock-mcp.vercel.app/api/dependents/M1000
```

```powershell
Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/dependents/M1000" -Method Get
```

Example response (`M1000`, who has 2 dependents — every 5th member does):

```jsonc
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_dependents",
  "data": {
    "memberId": "M1000",
    "dependents": [
      {
        "dependentId": "M1000-D1",
        "memberId": "M1000",
        "name": "Emerson Alvarez",
        "relationship": "spouse",
        "dob": "2019-03-28",
        "coverageStatus": "active"
      },
      {
        "dependentId": "M1000-D2",
        "memberId": "M1000",
        "name": "Morgan Alvarez",
        "relationship": "child",
        "dob": "202-08-14",
        "coverageStatus": "active"
      }
    ]
  },
  "asOf": "2026-09-14T14:56:35.256Z",
  "warnings": []
}
```

> **Known data-generator bug:** some dependent `dob` values come out
> un-padded (`"202-08-14"` above should be `"2002-08-14"`) — the generator
> at [demo-data.ts:133](lib/demo-data.ts#L133) builds the year as
> `` `20${int(2, 20)}` `` without zero-padding `int(2, 20)` to 2 digits, so
> single-digit results (2-9) drop a character. Left as-is for now since
> fixing it would shift the seeded PRNG sequence and change every
> downstream "random" value in the dataset.

Members at every 3rd index get 1 dependent (a child, no spouse); everyone
else gets `dependents: []` — useful for testing the empty-result case.

### 4. Claims & Benefits domain — `GET /api/claims/{memberId}`

Bundles three related lookups for one member: their full claims history,
their individual/family accumulators, and the entire 18-entry benefits
catalog (9 service types × 2 network levels — not member-specific, but
included for reference): `memberId`, `claims[]`, `accumulators`,
`benefitsCatalog[]`.

**Optional query params** (independent — combine freely in one request):
- `claimId` — narrows `claims[]` to the one matching entry. `404 claim_not_found` if it doesn't belong to this member.
- `serviceType` (+ optional `networkLevel`, defaults to `in_network`) — narrows `benefitsCatalog[]` to the one matching entry. `400 conflicting_demo_data` if no such entry exists.

E.g. `?claimId=CLM5031&serviceType=imaging&networkLevel=out_of_network`.

```bash
curl -s https://healthcare-mock-mcp.vercel.app/api/claims/M1000
```

```powershell
Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/claims/M1000" -Method Get
```

Example response (`M1000`, truncated — `claims` has 3 entries and
`benefitsCatalog` has 18; one of each is shown):

```jsonc
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_claims_benefits_profile",
  "data": {
    "memberId": "M1000",
    "claims": [
      {
        "claimId": "CLM5000",
        "memberId": "M1000",
        "serviceDate": "2026-05-07",
        "providerName": "Dr. Riley Quintero",
        "status": "submitted",
        "codes": { "cpt": "99214", "diagnosis": "J06.9" },
        "billedAmount": 3992,
        "allowedAmount": 3090,
        "planPaidAmount": 2472,
        "memberResponsibility": 618,
        "processingHistory": [
          { "date": "2026-08-01", "status": "received", "note": "Claim received from provider." },
          { "date": "2026-08-05", "status": "processing", "note": "Under adjudication review." }
        ]
      }
      // ...2 more claims (CLM5001, CLM5002)
    ],
    "accumulators": {
      "memberId": "M1000",
      "individual": {
        "deductible": { "limit": 1500, "met": 763, "remaining": 737 },
        "outOfPocket": { "limit": 6000, "met": 1832, "remaining": 4168 }
      },
      "family": {
        "deductible": { "limit": 3000, "met": 2590, "remaining": 410 },
        "outOfPocket": { "limit": 12000, "met": 5450, "remaining": 6550 }
      }
    },
    "benefitsCatalog": [
      {
        "serviceType": "primary_care_visit",
        "networkLevel": "in_network",
        "copay": 41,
        "coinsurance": null,
        "deductibleApplies": false,
        "limits": null,
        "exclusions": [],
        "priorAuthorizationRequired": false
      }
      // ...17 more entries (9 service types x 2 network levels)
    ]
  },
  "asOf": "2026-09-14T00:42:51.615Z",
  "warnings": []
}
```

Members `M1015`–`M1019` have zero claims (`claims: []`) — useful for
testing empty-result handling. Members with no dependents get
`accumulators.family: null`.

### 5. Pharmacy/PBM domain — `GET /api/pharmacy/{memberId}`

Bundles the full 10-drug formulary, the full 5-pharmacy directory, and all
4 canned prescription-rejection scenarios. `memberId` only gates access
(must be a valid member) — the catalogs themselves aren't member-specific:
`memberId`, `formulary[]`, `pharmacies[]`, `prescriptionRejections[]`.

**Optional query params** (independent — combine freely in one request):
- `drugName` — narrows `formulary[]` to the one matching entry. `404 drug_not_found` if it doesn't exist.
- `pharmacyId` — narrows `pharmacies[]` to the one matching entry. No error on zero matches (returns `pharmacies: []`), mirroring `search_demo_pharmacies`.
- `prescriptionReference` — narrows `prescriptionRejections[]` to the one matching entry. `404 prescription_not_found` if it isn't one of the canned references (`RX-DEMO-0001`–`0004`).

E.g. `?drugName=lisinopril&pharmacyId=PHM03`.

```bash
curl -s https://healthcare-mock-mcp.vercel.app/api/pharmacy/M1000
```

```powershell
Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/pharmacy/M1000" -Method Get
```

Example response (`M1000`, truncated — `formulary` has 10 entries,
`pharmacies` has 5, and `prescriptionRejections` has 4; one of each is
shown):

```jsonc
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_pharmacy_profile",
  "data": {
    "memberId": "M1000",
    "formulary": [
      {
        "drugName": "metformin",
        "strength": "500mg",
        "form": "tablet",
        "tier": 1,
        "priorAuthorizationRequired": false,
        "stepTherapyRequired": false,
        "quantityLimit": null,
        "specialtyRequired": false,
        "covered": true
      }
      // ...9 more drugs, including "experimental-compound-x" (covered: false)
    ],
    "pharmacies": [
      {
        "pharmacyId": "PHM01",
        "name": "Corner Health Pharmacy",
        "location": { "city": "Springvale", "state": "OH", "zip": "44001" },
        "type": "retail",
        "networkCategory": "preferred",
        "mailOrderAvailable": false
      }
      // ...4 more pharmacies (PHM02-PHM05)
    ],
    "prescriptionRejections": [
      {
        "reference": "RX-DEMO-0001",
        "code": "PA_REQUIRED",
        "explanation": "This medication requires prior authorization before it can be filled.",
        "nextAction": "Ask the prescriber to submit a prior authorization request."
      }
      // ...3 more (RX-DEMO-0002, -0003, -0004)
    ]
  },
  "asOf": "2026-09-14T00:42:52.370Z",
  "warnings": []
}
```

`formulary` includes `experimental-compound-x` (`covered: false`) for
testing not-covered flows. `prescriptionRejections` is an **array** (each
entry carries its own `reference` field), not an object keyed by
reference — this keeps it consistent with every other catalog here and
lets a filtered result always be addressed at `[0]` regardless of which
reference was requested.

### 6. Providers domain — `GET /api/providers/{memberId}`

Resolves the member's `pcpProviderId` to the actual provider record — the
member and eligibility responses only expose the bare ID — plus the full
16-provider directory: `memberId`, `pcpAssigned`, `pcp` (`null` if
unassigned), `providerDirectory[]`.

**Optional query param:** `providerId` — narrows `providerDirectory[]` to
the one matching entry (independent of the member's own `pcp`, which is
never filtered). `404 provider_not_found` if it doesn't exist. E.g.
`?providerId=PRV2005`.

```bash
curl -s https://healthcare-mock-mcp.vercel.app/api/providers/M1001
```

```powershell
Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/providers/M1001" -Method Get
```

Example response (`M1001`, truncated — `providerDirectory` has 16 entries;
one is shown):

```jsonc
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_provider_profile",
  "data": {
    "memberId": "M1001",
    "pcpAssigned": true,
    "pcp": {
      "providerId": "PRV2011",
      "name": "Dr. Sawyer Lindqvist",
      "specialty": "Physical Therapy",
      "location": { "city": "Lakeport", "state": "MN", "zip": "55401" },
      "networkStatus": "in_network",
      "acceptingNewPatients": true
    },
    "providerDirectory": [
      {
        "providerId": "PRV2000",
        "name": "Dr. Reese Quintero",
        "specialty": "Physical Therapy",
        "location": { "city": "Rivermont", "state": "TX", "zip": "75201" },
        "networkStatus": "in_network",
        "acceptingNewPatients": true
      }
      // ...15 more providers (PRV2001-PRV2015)
    ]
  },
  "asOf": "2026-09-14T01:34:54.652Z",
  "warnings": []
}
```

Every 4th member (`M1000`, `M1004`, `M1008`, ...) has no PCP assigned —
try `M1000` to see `pcpAssigned: false, pcp: null` while
`providerDirectory` is still returned in full.

### Error example — unknown memberId

Every endpoint returns the same shape for a `memberId` that doesn't exist
in the synthetic dataset:

```bash
curl -s -w "\nHTTP %{http_code}\n" https://healthcare-mock-mcp.vercel.app/api/members/M9999
```

```jsonc
{
  "success": false,
  "synthetic": true,
  "tool": "get_demo_member",
  "error": {
    "code": "member_not_found",
    "message": "No synthetic member matched the supplied identifiers."
  }
}
// HTTP 404
```

### Using these with a data-validation config

[`member-validation-config.json`](./member-validation-config.json) in the
repo root is a worked example. It declares all six endpoints above,
sourcing `member_id` plus one optional lookup key per filterable field
(`dependent_id`, `claim_id`, `service_type`/`network_level`, `drug_name`,
`pharmacy_id`, `prescription_reference`) from a test profile, and passes
each of those straight through as a **query param** on the matching
endpoint's `url` (e.g.
`.../api/claims/{{member_id}}?claimId={{claim_id}}&serviceType={{service_type}}&networkLevel={{network_level}}`).
Every `json_path` then reads a fixed index (`[0]`, plus `[1]`/`[2]` for
dependents/claims, since a member can have more than one) rather than a
JSONPath filter predicate — the server does the narrowing, not the
validator.

This design exists because JSONPath `?()` filter expressions
(`$.data.claims[?(@.claimId=='...')]`) turned out not to work against this
particular validation tool — its own documented examples were all plain
paths/indices, never filters, and testing confirmed filter predicates
silently match nothing. Query-param filtering sidesteps that entirely: it
mirrors how the MCP tools already take an ID as an *argument* and do the
lookup server-side, so the REST layer now does the same, and the validator
only ever needs the simple path forms it's confirmed to support. Leaving a
lookup key unset in the test profile (e.g. no `claim_id`) makes the
endpoint fall back to returning every record unfiltered, so `[0]`/`[1]`
still resolve to *something* meaningful — the first claim, first
dependent, the default drug (metformin), etc. — each documented in that
variable's `description` in the config. Its URLs point at the live
deployment, `https://healthcare-mock-mcp.vercel.app` — swap that for
`localhost:3000` to run it against a local dev server instead.

---

## Deploying to Vercel

This repo has no Vercel-specific config beyond being a standard Next.js
app — `vercel.json` isn't required.

1. Push to GitHub (already done — see repo history).
2. In the [Vercel dashboard](https://vercel.com/new), import the
   `healthcare-mock-mcp` GitHub repo, or run `npx vercel` from this folder
   to deploy via CLI.
3. Once deployed, your MCP endpoint is
   `https://<your-project>.vercel.app/api/mcp`, and the REST endpoints are
   at `https://<your-project>.vercel.app/api/{members,eligibility,dependents,claims,pharmacy,providers}/{memberId}`.
4. Before sharing those URLs beyond a local/mock evaluation, add the
   authorization check noted in the `TODO` at the top of
   `app/api/mcp/route.ts` — and equivalently to the REST routes, which
   currently carry no auth check either (see [Safety model](#safety-model)).

---

## Wiring it into Vapi

```json
{
  "type": "mcp",
  "server": {
    "url": "https://YOUR-PROJECT.vercel.app/api/mcp"
  },
  "metadata": {
    "protocol": "shttp"
  }
}
```

Use Streamable HTTP (`shttp`) as shown — not `stdio` (local-process only)
or legacy SSE (only needed for clients that can't do Streamable HTTP).

---

## Safety model

- **All data is synthetic.** Names, dates of birth, claims, and formulary
  entries are procedurally generated or hand-authored fixtures — none of it
  corresponds to a real person, provider, or plan.
- **No PHI is stored or transmitted.** There is no database; the dataset
  lives in memory for the life of the server process.
- **Administrative simulation only.** Nothing in `healthcare-tools.ts`
  performs or implies diagnosis, treatment, dosage changes, medication
  substitution, emergency dispatch, or a real coverage determination.
- **Confirmation-gated writes.** The only two tools that simulate a
  state change (`create_demo_case`, `simulate_demo_appeal`) require an
  explicit `confirmed: true` argument and return `confirmation_required`
  otherwise; even when confirmed, nothing is actually persisted.
- **No default member.** Every member-identified tool fails with
  `missing_required_parameter` unless it receives at least one complete
  identifier (`memberId`, full name, or `dob`) — the server never guesses
  or substitutes a fallback identity.
- **Before exposing this beyond local/mock evaluation:** add an
  `Authorization` header check in `app/api/mcp/route.ts` **and** in each of
  the six REST route files under `app/api/{members,eligibility,dependents,claims,pharmacy,providers}/[memberId]/`,
  backed by a Vercel environment variable and a matching Vapi secure
  credential / data-validation-config header. Never put the secret in the
  URL or in a tool's description string. None of these routes currently
  check authorization — see the `TODO` in `app/api/mcp/route.ts`.