healthcare-mock-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@healthcare-mock-mcpCheck eligibility for member M1000"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 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 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) 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 below.
Contents
Related MCP server: Virtual Care MCP
Architecture
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)" --> RESTRequest flow for one MCP tool call:
The MCP client sends
POST /api/mcpwith a JSON-RPCtools/callmessage ({"method":"tools/call","params":{"name":"get_demo_eligibility","arguments":{"memberId":"M1000"}}}).mcp-handler(insideroute.ts) parses the request, validates the arguments against the tool's Zod schema, and invokes the matching function inhealthcare-tools.ts.That function validates required fields, looks up synthetic records in
demo-data.ts, and returns either asuccessenvelope withdata, or anerrorenvelope with a structuredcode/message— it never fabricates data ad hoc or falls back to a default member.route.tswraps 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 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.
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.jsonFile-by-file description
app/api/mcp/route.ts
The MCP server's entry point.
Calls
createMcpHandler(registerFn, serverOptions, config)frommcp-handler, which builds a single Fetch-API-compatible handler supporting Streamable HTTP (POSTfor JSON-RPC calls;GET/DELETEto that same path are explicitly rejected — see Calling the server manually for why a browser visit to the URL returns a405).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 (viamissing_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.tsand 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 samemcp-handler-produced function, which internally branches onrequest.method.Carries a
TODOcomment marking where anAuthorizationheader check should be added before this is exposed beyond local/mock evaluation (see 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 for full examples. Each is a
thin, near-identical wrapper:
Reads
memberIdout of the dynamic route segment (paramsis aPromiseunder Next.js 15's App Router), and — for the four "bundle" routes (dependents, claims, pharmacy, providers) — spreads every query param (req.nextUrl.searchParams, viaObject.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, orgetDemoProviderProfile) — no business logic lives in the route file itself.getDemoDependentsis 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/ToolErrorenvelope as-is viaNextResponse.json(result, { status: restErrorStatus(result.error.code) })on failure (every*_not_foundcode →404, everything else →400) or200on success.restErrorStatusis a small shared helper exported fromhealthcare-tools.tsso 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) andToolError(success: false,error: { code, message }).synthetic: trueand thetoolname 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 missingmemberIdnever silently defaults to a fallback member: if the field isn't a non-empty string, the function returns before any lookup happens.requireMember— wrapsfindMemberfromdemo-data.ts; converts a miss intomember_not_found. Used only by the two tools wherememberIdis 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 bymemberId(authoritative — looked up alone if present), by full name (firstNameandlastNametogether), bydob, or any combination of the three. At least one complete identifier is required (missing_required_parameterif none is given); zero matches returnsmember_not_found; more than one match (possible only via name/dob, sincememberIdis unique) returnsambiguous_member_matchasking for an additional identifier. SeefindMembersByIdentifiersbelow 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 callingestimate_demo_costtwice 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 —
simulateDemoAppealandcreateDemoCase— both checkinput.confirmed !== trueand returnconfirmation_requiredif 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 onememberId-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'spcpProviderIdto 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 onrequireMemberfor why (a realundefined-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 optionalinputfields (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.getDemoDependentsis shared with theget_demo_dependentsMCP tool, so that tool gaineddependentIdtoo; the other three are REST-only, so their filters are REST-only. Each filter independently returns a domain-specific*_not_founderror (viaerr(...)) when the ID doesn't match, exceptpharmacyId, which returns an empty list — no error — mirroringsearch_demo_pharmacies's existing zero-match behavior.restErrorStatus(code)— maps aToolError.error.codeto an HTTP status for the REST routes: any code ending in_not_found→404, everything else →400. Centralizing this means a new_not_foundcode (like thedependent_not_found/provider_not_foundadded alongside the filters above) automatically gets the right status everywhere, without touching route files.prescriptionRejectionsis exposed as an array, not the underlyingPRESCRIPTION_REJECTIONSRecord<string, ...>—getDemoPharmacyProfilemaps 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 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
constarray built once at module load:PROVIDERS(16),MEMBERS(20),DEPENDENTS(derived from members),ACCUMULATORS(one entry per member, keyed bymemberId),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 deliberatelycovered: falseentry for testingdrug_not_found-adjacent flows) andPHARMACIES(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 functionshealthcare-tools.tsimports from this file. Tool code never reaches into the raw arrays directly.findMembersByIdentifiers({ memberId, firstName, lastName, dob })is what backsresolveMember(see above): ifmemberIdis given it's looked up alone (authoritative, unique); otherwise it filtersMEMBERSby whichever offirstName/lastName/dobwere 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.
Deterministic randomness. A mulberry32 PRNG is seeded with the fixed literal
20260101. Two thin wrappers,pick(array)andint(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.Providers first (
PROVIDERS, 16 entries) — each gets a synthetic name (Dr. {first} {last}), a specialty, a city, and randomizednetworkStatus(80% in-network) /acceptingNewPatients(60% true). Providers are generated before members because members reference them.Members (
MEMBERS, 20 entries, IDsM1000–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, soget_demo_eligibility/coverageStatushas a guaranteed non-active case to demo. Every 4th member (i % 4 === 0) getspcpProviderId: nullto exercise the "no PCP assigned" branch ofget_demo_pcp.Dependents (
DEPENDENTS) — derived fromMEMBERSviaflatMap: 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 forget_demo_dependents.Accumulators (
ACCUMULATORS, keyed bymemberId) — every member gets individual deductible/out-of-pocket progress ($1,500/$6,000limits) with a random amount already "met". Members who have at least one dependent also get a family accumulator ($3,000/$12,000limits); members with no dependents getfamily: null.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.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 throughsubmitted → processing → paid → denied, and aprocessingHistorytimeline whose last entry only appears once the claim reachespaidordenied.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 ofget_demo_formulary/price_demo_medicationhas a matching fixture.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 |
| member-identified | Synthetic member's basic profile and plan info |
| member-identified | Coverage status, plan, effective/termination dates |
| member-identified | Dependents and their coverage status |
| member-identified | PCP assignment, or "no PCP assigned" |
|
| Provider directory search + network status |
| member-identified, | Copay, coinsurance, deductible, limits, exclusions, PA requirement |
| member-identified | Individual/family deductible & OOP totals |
| member-identified, | Simulated cost range with assumptions (estimate-only) |
| member-identified | Claims matching optional filters |
|
| Detailed claim status, amounts, codes, history |
|
| Billed/allowed/plan-paid/disallowed/member-responsibility amounts |
| member-identified, | Simulated appeal submission (requires |
| member-identified, | Coverage tier, PA, step therapy, quantity limits |
| member-identified, | Simulated pricing by pharmacy channel |
| member-identified, | Pharmacy search with network category & distance |
| member-identified, | Simulated rejection code + explanation + next action |
| member-identified, | Simulated case creation (requires |
|
| Simulated escalation routing ( |
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–M1019in the seeded dataset).firstNameandlastNametogether (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:
// 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, dependent_not_found (REST-only, from the
dependentId/?dependentId= filter — see REST API
endpoints), provider_not_found (REST-only, from
the ?providerId= filter).
Running it locally
npm install
npm run devThe server listens at http://localhost:3000 — the MCP endpoint is at
/api/mcp; the REST endpoints (see 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:
netstat -ano | grep ":3000" | grep LISTENING # Git BashVisiting that URL in a browser will show a
405 Method not allowederror — that's expected. Streamable HTTP only acceptsPOSTfor JSON-RPC calls; a browser tab only ever sendsGET.
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 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):
npm run devEvery 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 |
|
| Profile + plan | — (already a single record) |
2 | Eligibility |
|
| Coverage status + plan dates | — (already a single record) |
3 | Dependents |
|
| Spouse/child records + their coverage status |
|
4 | Claims & Benefits |
|
| Claims history + accumulators + benefits catalog |
|
5 | Pharmacy/PBM |
|
| Formulary + pharmacy directory + prescription rejections |
|
6 | Providers |
|
| PCP assignment (resolved to full provider record) + provider directory |
|
1. Member domain — GET /api/members/{memberId}
Basic profile and plan info: memberId, firstName, lastName, dob,
gender, location, plan, coverageStatus, pcpProviderId.
curl -s https://healthcare-mock-mcp.vercel.app/api/members/M1000Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/members/M1000" -Method GetExample response (M1000):
{
"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.
curl -s https://healthcare-mock-mcp.vercel.app/api/eligibility/M1000Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/eligibility/M1000" -Method GetExample response (M1000):
{
"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.
curl -s https://healthcare-mock-mcp.vercel.app/api/dependents/M1000Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/dependents/M1000" -Method GetExample response (M1000, who has 2 dependents — every 5th member does):
{
"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
dobvalues come out un-padded ("202-08-14"above should be"2002-08-14") — the generator at demo-data.ts:133 builds the year as`20${int(2, 20)}`without zero-paddingint(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— narrowsclaims[]to the one matching entry.404 claim_not_foundif it doesn't belong to this member.serviceType(+ optionalnetworkLevel, defaults toin_network) — narrowsbenefitsCatalog[]to the one matching entry.400 conflicting_demo_dataif no such entry exists.
E.g. ?claimId=CLM5031&serviceType=imaging&networkLevel=out_of_network.
curl -s https://healthcare-mock-mcp.vercel.app/api/claims/M1000Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/claims/M1000" -Method GetExample response (M1000, truncated — claims has 3 entries and
benefitsCatalog has 18; one of each is shown):
{
"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— narrowsformulary[]to the one matching entry.404 drug_not_foundif it doesn't exist.pharmacyId— narrowspharmacies[]to the one matching entry. No error on zero matches (returnspharmacies: []), mirroringsearch_demo_pharmacies.prescriptionReference— narrowsprescriptionRejections[]to the one matching entry.404 prescription_not_foundif it isn't one of the canned references (RX-DEMO-0001–0004).
E.g. ?drugName=lisinopril&pharmacyId=PHM03.
curl -s https://healthcare-mock-mcp.vercel.app/api/pharmacy/M1000Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/pharmacy/M1000" -Method GetExample response (M1000, truncated — formulary has 10 entries,
pharmacies has 5, and prescriptionRejections has 4; one of each is
shown):
{
"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.
curl -s https://healthcare-mock-mcp.vercel.app/api/providers/M1001Invoke-RestMethod -Uri "https://healthcare-mock-mcp.vercel.app/api/providers/M1001" -Method GetExample response (M1001, truncated — providerDirectory has 16 entries;
one is shown):
{
"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:
curl -s -w "\nHTTP %{http_code}\n" https://healthcare-mock-mcp.vercel.app/api/members/M9999{
"success": false,
"synthetic": true,
"tool": "get_demo_member",
"error": {
"code": "member_not_found",
"message": "No synthetic member matched the supplied identifiers."
}
}
// HTTP 404Using these with a data-validation config
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.
Push to GitHub (already done — see repo history).
In the Vercel dashboard, import the
healthcare-mock-mcpGitHub repo, or runnpx vercelfrom this folder to deploy via CLI.Once deployed, your MCP endpoint is
https://<your-project>.vercel.app/api/mcp, and the REST endpoints are athttps://<your-project>.vercel.app/api/{members,eligibility,dependents,claims,pharmacy,providers}/{memberId}.Before sharing those URLs beyond a local/mock evaluation, add the authorization check noted in the
TODOat the top ofapp/api/mcp/route.ts— and equivalently to the REST routes, which currently carry no auth check either (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.tsperforms 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 explicitconfirmed: trueargument and returnconfirmation_requiredotherwise; even when confirmed, nothing is actually persisted.No default member. Every member-identified tool fails with
missing_required_parameterunless it receives at least one complete identifier (memberId, full name, ordob) — the server never guesses or substitutes a fallback identity.Before exposing this beyond local/mock evaluation: add an
Authorizationheader check inapp/api/mcp/route.tsand in each of the six REST route files underapp/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 theTODOinapp/api/mcp/route.ts.
This server cannot be deployed
Maintenance
Related MCP Connectors
- OkareoOAuthcom.okareo
Simulation, evaluation and monitoring for voice agents.
Conversational AI Coaching from calls; permissioned Team Dynamics reports in a limited U.S. pilot.
Training gym where AI assistants practice real tasks and earn signed, verifiable scores.
Build and manage AI-native customer support agents from Claude or any MCP client.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceProvides 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
- AlicenseNot gradedqualityBmaintenanceEnables 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
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to run tests, generate synthetic insurance data, analyze coverage and code quality, and validate insurance business rules.MIT
- AlicenseNot gradedqualityDmaintenanceEnables AI agents to generate and query clinically realistic synthetic health data with FHIR R4/R5 support and differential privacy.1MIT