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 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)" --> VapiRequest flow for one 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.
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.jsonFile-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)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).
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) 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.
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.
Running it locally
npm install
npm run devThe server listens at http://localhost:3000/api/mcp. 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).
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.Before sharing that URL beyond a local/mock evaluation, add the authorization check noted in the
TODOat the top ofapp/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.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.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.
This server cannot be deployed
Maintenance
Related MCP Connectors
- OkareoOAuthcom.okareo
Simulation, evaluation and monitoring for voice agents.
Build and manage AI-native customer support agents from Claude or any MCP client.
Give AI agents a phone layer for consent-based calls, transcripts, summaries, and outcomes.
Test the voice agents you run: scored transcripts, pass/fail verdicts, latency and WER metrics.
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 gradedqualityAmaintenanceEnables AI agents to verify medical-record claims against synthetic FHIR evidence using a deterministic, non-AI verifier. Provides MCP tools for evidence retrieval, claim verification, and benchmark evaluation without requiring real patient data.2Apache 2.0
- FlicenseNot gradedqualityCmaintenanceEnables 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.-
- 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