Skip to main content
Glama

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.

It exists to let a voice/chat agent (e.g. Vapi) 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 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 below.


Contents


Related MCP server: MEVA Health AI MCP Server

Architecture

flowchart TB
    subgraph Client["MCP Client"]
        Vapi["Vapi voice agent\n(or any MCP client)"]
    end

    subgraph Server["Next.js app (Vercel)"]
        Route["app/api/mcp/route.ts\nStreamable HTTP endpoint\ncreateMcpHandler(...)"]
        Tools["lib/healthcare-tools.ts\n18 tool handlers\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
    Tools -- "reads/looks up\n(never mutates)" --> Data
    Tools -- "ToolSuccess | ToolError\nenvelope" --> Route
    Route -- "JSON-RPC result\n(SSE-framed)" --> Vapi

Request flow for one 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.

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.


Folder structure

healthcare-mock-mcp/
├── app/
│   └── api/
│       └── mcp/
│           └── route.ts        # MCP Streamable HTTP endpoint (GET/POST/DELETE)
├── lib/
│   ├── demo-data.ts             # Synthetic dataset + lookup helpers
│   └── healthcare-tools.ts      # 18 tool implementations + response envelope
├── .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 and the only HTTP-facing file in the project.

  • 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 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).

lib/healthcare-tools.ts

The business logic for all 18 tools — 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 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), 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.

lib/demo-data.ts

The synthetic dataset and the only place randomness is generated. See 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), 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 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 M1000M1019) — 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 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 (M1000M1019 in the seeded dataset).

  • firstName and lastName together (a partial name alone isn't treated as a complete identifier).

  • dobYYYY-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:

// success
{
  "success": true,
  "synthetic": true,
  "tool": "get_demo_eligibility",
  "data": { /* tool-specific */ },
  "asOf": "2026-09-09T17:30:00.000Z",
  "warnings": []
}
// 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.


Running it locally

npm install
npm run dev

The server listens at http://localhost:3000/api/mcp. You can check it's up with:

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:

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:

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):

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):

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 M1000M1019 (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 PRV2000PRV2015, claims CLM5000+ (only members M1000M1014 have claims), prescription references RX-DEMO-00010004, formulary drugs metformin, semaglutide, adalimumab, experimental-compound-x (not covered).


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, 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.

  4. Before sharing that URL beyond a local/mock evaluation, add the authorization check noted in the TODO at the top of app/api/mcp/route.ts (see Safety model).


Wiring it into Vapi

{
  "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, backed by a Vercel environment variable and a matching Vapi secure credential. Never put the secret in the URL or in a tool's description string.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Provides a mock interface for managing health insurance operations, including claims processing, benefit inquiries, provider searches, and prior authorization requests. It enables developers to test healthcare workflows using synthetic data through the Model Context Protocol.
    Apache 2.0
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to autonomously investigate simulated production incidents by checking live service health, logs, deployments, and database status while retrieving relevant historical postmortems and runbooks to propose evidence-backed root causes.
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables portable synthetic virtual-care visits through MCP Apps and browser, allowing users to prepare appointments, rehearse consultations, simulate insurance and self-pay billing, and export FHIR records.
    MIT