Skip to main content
Glama
saivarun1410

insurance-mcp-poc

by saivarun1410
README.md
# insurance-mcp-poc

A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes a life-insurance
back office — policy documents, in-flight applications, and product/underwriting rules — as tools an
LLM agent can call.

The point of the POC: an agent shouldn't need a bespoke integration per assistant. Implement the
domain once as an MCP server, and any MCP-capable client (Claude Code, Microsoft Foundry agents,
an internal chat surface) gets the same twenty-four tools with the same contracts.

All data in this repository is **synthetic**. The schema, products, rules, and documents were
invented for this demo and are not derived from any production system.

Built alongside [Microsoft Applied Skills: Integrate model context protocol tools with agents in
Microsoft Foundry](https://learn.microsoft.com/api/credentials/share/en-us/VarunThupakula-1670/8851AA0DDD09A6F2?sharingId=3D36474C20E4579C).
That assessment covers the client side — attaching an MCP tool to a Foundry agent and validating its
calls. This repository is the other half: the server those tools come from.

## Quickstart

Requires Docker and Node 20+.

```bash
npm install
npm run setup     # starts Postgres+pgvector, seeds the corpus, runs the smoke test
```

`npm run setup` is the whole demo: it stands up the database, embeds and inserts 13 documents, then
connects to the MCP server as a real MCP client and exercises all twenty-four tools. Expected tail:

```
Connected. Server exposes 24 tools:
  - search_policy_documents: Search policy documents
  - get_application_status: Get application status
  ...
  - update_requirement_status: Update requirement status
  - reassign_application: Reassign application
...
All tool calls completed.
```

Then `npm run demo` walks the chained flow an agent actually performs — see below. Tear down
with `npm run db:down`.

## The tools

Twenty-four tools. The design rule: **a tool maps to a decision someone makes, not to a table.** There is no
`get_product` or `list_events` here — an agent that has to assemble answers from CRUD primitives
burns turns and invents joins. Each tool below answers a question a person actually asks.

**Case handling** — one known application

| Tool | Answers |
| --- | --- |
| `get_application_status` | "Where is APP-100242 and what is it waiting on?" Status, blocking step, underwriter, full event timeline. |
| `get_outstanding_requirements` | "Why isn't it moving, and what do I chase today?" Open requirements with age in days, plus the follow-up action the procedure calls for at 14 / 28 / 90 days. |
| `find_applicant` | "What's happening with Priya's application?" Name, whole or partial, to application numbers. Everything else here needs the number — this is how you get it. |
| `add_case_note` | "Record that I called the provider." Appends to the timeline. |
| `update_requirement_status` | "The APS came in." Marks a requirement received or waived and logs it. |
| `order_requirement` | "Order him an EKG." The other half of the requirement lifecycle — `update_requirement_status` can only close ones that exist. |
| `record_underwriting_decision` | "Approve it at standard." The actual decision: approved, declined, referred, or approved with a rating. |
| `create_application` | "Take this application." The entry point — every other case tool needs a case that already exists. |
| `withdraw_application` | "She's gone with someone else." Closes a case from the customer side, which is not an underwriting outcome. |
| `amend_application` | "He wants $4M instead of $750k." Revises cover or product on an in-flight case, re-checking the limits. |

**Pipeline** — across the whole book

| Tool | Answers |
| --- | --- |
| `find_applications` | "What's stuck?" Filter by status, product, underwriter, state, or days untouched. |
| `get_underwriter_workload` | "Who is overloaded?" Open cases, face amount at risk, oldest untouched case per underwriter. |
| `get_pipeline_metrics` | "How are we doing?" Cases and face amount by status, plus outstanding requirements bucketed against the 14 / 28 / 90-day thresholds. |
| `reassign_application` | "Move this off D. Lindqvist." Reassigns and records why. |
| `get_requirement_catalog` | "How long will an APS hold this up, and which vendors are slow?" Historical turnaround, with the sample size attached so a single data point isn't mistaken for a benchmark. |

**Sales and pricing** — before a case exists

| Tool | Answers |
| --- | --- |
| `find_eligible_products` | "What can I sell a 62-year-old in Texas for $2M?" Every issuable product plus the rules that will fire. The inverse of `lookup_product_rules`. |
| `estimate_premium` | "What will it cost?" Annual and monthly premium from the rate table, by age band and risk class. |
| `get_rate_card` | "How was that derived?" Every risk class and age band for a product. |
| `compare_products` | "What can I sell her, and what does each cost?" Eligible products priced and sorted cheapest first, in one call instead of four. |
| `lookup_product_rules` | "What are the limits on UL-200?" Issue limits and underwriting rules; evaluates hard eligibility when given an applicant. |

**Knowledge**

| Tool | Answers |
| --- | --- |
| `search_policy_documents` | "What does the contract actually say?" Hybrid search over contracts, riders, guidelines and procedures, returning excerpts with `doc_id` so answers can cite a source. |
| `list_documents` | "What guidance exists for this product?" Enumerates the corpus without searching — also the fallback when a search returns nothing. |
| `find_similar_documents` | "What else relates to this clause?" Starts from a document rather than a question, reusing the embeddings already stored. |
| `get_document` | "Quote me the exact clause." One document in full, since search truncates excerpts at 600 characters. Also returns the product's underwriting rules, which is usually what a reader needs next. |

Sixteen of the twenty-four are read-only and marked `readOnlyHint: true`. The eight writers differ in a
way the annotations capture, because clients use them to decide what needs human confirmation:

| Tool | Semantics | Annotations |
| --- | --- | --- |
| `add_case_note` | Appends. Two calls write two notes. | `idempotentHint: false` |
| `update_requirement_status` | Edits a row, but re-applying the same value changes nothing. | `idempotentHint: true` |
| `reassign_application` | Overwrites the assignment; same target twice is a no-op. | `idempotentHint: true` |
| `order_requirement` | Inserts, but refuses to duplicate an outstanding requirement. | `idempotentHint: true` |
| `record_underwriting_decision` | Sets status; recording the same decision twice is a no-op. | `idempotentHint: true` |
| `create_application` | Inserts a new case. Two calls create two applications. | `idempotentHint: false` |
| `withdraw_application` | Closes a case; withdrawing twice changes nothing. | `idempotentHint: true` |
| `amend_application` | Revises cover; an amendment matching current values is a no-op. | `idempotentHint: true` |

None are marked destructive: nothing here deletes, and every writer checks its target exists first
and returns a plain "nothing was changed" result rather than throwing.

### Guardrails sit at four levels

Worth separating, because only the first is free:

1. **Schema** — zod becomes JSON Schema and the SDK validates *before* the handler runs. A missing
   required field, a string where a number belongs, an age of 999, an unknown enum value: all
   rejected with a message naming the offending field, and the database is never touched.
2. **Existence** — every writer confirms its target exists and returns a plain result saying nothing
   changed, rather than throwing.
3. **Domain** — the rules no schema can express, because they depend on other rows or on the
   catalogue. `record_underwriting_decision` refuses to approve while requirements are outstanding;
   `create_application` refuses a product the applicant cannot be issued; `withdraw_application`
   refuses a case that has already been decided; `amend_application` re-checks the revised figure
   against the product limits and refuses a case that is already closed. Each refusal names what blocked it and which tool
   moves things forward, so a model can recover instead of stalling.
4. **Database** — `CHECK` constraints on every status and class column, foreign keys throughout. The
   last line of defence if the code above is wrong.

Parameterised SQL everywhere, so `'; DROP TABLE applications; --` is searched for as a name and
matches nobody.

Try, once connected: *"Rowan Kessler's application is stuck — what's it waiting on, and what does the
guideline actually say about that requirement?"* No single tool answers that. The agent chains all
three, and `npm run demo` shows the same chain step by step:

```
[1] get_application_status("APP-100242")
      -> Rowan Kessler, age 61, SecureTerm 20-Year
      -> status=pending_underwriting  blocked on: awaiting_paramedical
      -> rules fired: TRM20-AGE-01, TRM20-FACE-01
[2] lookup_product_rules("TRM-20", age=61, face=1500000)
      -> TRM20-AGE-01 [require_evidence]: Applicants over 60 require a paramedical exam…
      -> TRM20-FACE-01 [refer]: Face amounts above $1,000,000 are referred…
[3] search_policy_documents("when is a paramedical examination required")
      -> GUIDE-UW-01  (rrf 0.03154, vector rank 6, text rank 1)
```

Note step 3: the right document ranked **6th by vector similarity but 1st by full-text**. Fusing the
two rankings is what surfaces it.

## How retrieval works

Hybrid search, fused with **Reciprocal Rank Fusion** (`score = Σ 1/(60 + rank_i)`).

Two things forced this design, both found by testing rather than assumed:

- **Weighted score blending doesn't work here.** Cosine similarity lands around 0.1–0.4 while
  `ts_rank_cd` returns values an order of magnitude smaller, so any fixed weighting lets whichever
  metric happens to be larger dominate. RRF combines *ranks*, which are scale-free.
- **`websearch_to_tsquery` ANDs every term**, so a full-sentence question matches zero documents and
  the hybrid silently degrades to vector-only. The operators are rewritten to `OR`, making the
  lexical side rank by how many query terms a document contains.

Each result reports `vector_rank` and `text_rank` alongside the fused `score`, so it stays visible
which half did the work — and a `text_rank` of `null` means that document matched no query term.

## Connecting it to Claude Code

```bash
claude mcp add insurance --  node /absolute/path/to/insurance-mcp-poc/src/index.js
```

Or add to `.mcp.json`:

```json
{
  "mcpServers": {
    "insurance": {
      "command": "node",
      "args": ["/absolute/path/to/insurance-mcp-poc/src/index.js"],
      "env": { "DATABASE_URL": "postgres://insurance:insurance@localhost:55432/insurance" }
    }
  }
}
```

## What happens when a client calls a tool

Worth being precise about, because the common mental picture — "the model calls my API" — is wrong
in two ways. **The model never talks to this server.** The client does. And there is no HTTP
involved: this server speaks JSON-RPC 2.0 over its own stdin and stdout.

**Startup, once per session.** The client (Claude Code, a Foundry agent) *spawns this process* —
`node src/index.js` — and holds its stdin/stdout pipes. It sends `initialize`, the server replies
with protocol version and capabilities, the client sends the `initialized` notification. Then the
client calls `tools/list`, and the SDK answers with all twenty-four tools: name, description, annotations,
and a **JSON Schema** for the arguments, which it generated from the zod schemas in `src/index.js`.

**The client puts those tool definitions into the model's context.** This is the step people skip.
The description strings above are not documentation — they are the prompt. A tool the model
misunderstands is a tool it calls wrongly, which is why each description says *when to reach for
this one* rather than just what it returns.

**Per call.** The model emits a tool-use request naming a tool and its arguments. The client — not
the model — sends:

```jsonc
// stdin →
{"jsonrpc":"2.0","id":7,"method":"tools/call",
 "params":{"name":"get_application_status","arguments":{"application_number":"APP-100242"}}}
```

The SDK validates `arguments` against that tool's schema and **rejects the call before the handler
runs** if it doesn't fit — the model gets a schema error back and can retry. On success it invokes
the handler, which runs parameterised SQL against Postgres and returns content blocks:

```jsonc
// ← stdout
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"{ \"application_number\": ... }"}]}}
```

The client feeds that result back into the model's context as the tool result, and the model decides
what to do next — often calling another tool, which is exactly the chain the demo above walks.

**Two distinct failure modes**, worth keeping straight:

- A **protocol error** (unknown tool, malformed arguments) returns a JSON-RPC `error`. The model sees
  it went wrong mechanically.
- A **tool-level failure** — "no application found with number APP-999999" — is a *successful*
  JSON-RPC response whose content says so. That's deliberate: it's information the model should
  reason about, not a crash. `add_case_note` does this rather than throwing, and writes nothing.

**Concurrency and lifetime.** Requests carry an `id`, so the client may have several in flight at
once; responses are matched by id, not by order. The process lives as long as the client session and
holds a `pg` connection pool across calls — so state like the pool is per-session, and anything you
want durable belongs in Postgres.

## How it fits together

```
MCP client (Claude Code / Foundry agent)
        │  stdio, JSON-RPC
        ▼
   src/index.js          tool definitions + zod input schemas
        │
        ├── src/embed.js  query → vector
        └── src/db.js     pg pool
                 │
                 ▼
        Postgres 16 + pgvector      docker-compose, port 55432
```

Layout: `src/index.js` (server and all twenty-four tools) · `src/embed.js` (embedding) · `src/db.js`
(pool) · `db/init.sql` (schema + seed) · `scripts/seed.mjs` (documents + embeddings) ·
`scripts/smoke.mjs` (exercises every tool) · `scripts/demo.mjs` (the chained flow) ·
`scripts/benchmark.mjs` (retrieval quality).

## The embedding, and why it was measured

`src/embed.js` runs **all-MiniLM-L6-v2 locally, in-process**, via transformers.js (ONNX). No API
key, no separate service, no Python. Weights (~23MB quantized) download once on first use and cache
under `~/.cache/huggingface`; the model loads lazily on the first search, so spawning the server
stays instant.

It replaced a hashed bag-of-words stand-in, and `npm run benchmark` records what that was worth —
ten questions with a known-correct document, plus paraphrases that share no vocabulary with their
target:

| | hashed bag-of-words | all-MiniLM-L6-v2 |
| --- | --- | --- |
| vector only | 8/10 | **10/10** |
| full-text only | 9/10 | 9/10 |
| hybrid (RRF) | 9/10 | **10/10** |
| paraphrases | 1/3 | **2/3** |

The number that matters is the last row. The old embedding scored **0.0000** cosine between
"dies by suicide" and "takes their own life" — identical to its score against an unrelated sentence
about grace periods, because it matched shared words and those phrases share none. Full-text search
has the same ceiling for the same reason. Only a trained model can close that gap, and it is the
sole reason the vector half exists: on the ten literal questions, plain Postgres full-text was
already beating vector-only 9 to 8.

The one paraphrase still missed lands at **rank 3 of 13**, not wildly off, and adding a few words
of context ("...within two years of the policy date") puts it first. Short queries are ambiguous.

Changing model means changing `EMBEDDING_DIM` here **and** `vector(384)` in `db/init.sql`, then
`npm run db:down && npm run db:up && npm run seed`. Old vectors are meaningless under a new model.

## Notes and limitations

Worth stating plainly, since they're the things a reviewer would ask about:

- **No approximate index, on purpose.** An early version had `ivfflat ... WITH (lists = 10)` over 12
  rows. It silently returned wrong and short result sets — a single probe scans a near-empty
  partition. Approximate indexes only pay off at volume. At this corpus size an exact scan is both
  correct and instant; `db/init.sql` says where to add HNSW once the corpus justifies it.
- **Rules are data, not an engine.** `underwriting_rules.condition` holds plain-language conditions
  for the agent to reason over. Only the hard limits (age band, face band, state availability) are
  actually evaluated in code. A production version would compile these to an executable rule set —
  an LLM interpreting underwriting conditions free-hand is not something to ship.
- **No authentication or tenancy.** The server trusts its caller completely. Real deployment needs
  per-caller authorization, since these tools read customer data.
- **The model has no idea what insurance is.** It learned which phrases keep company with which,
  which is enough for paraphrase but leaves a known blind spot: antonyms and negation sit close
  together, because "the premium increased" and "the premium decreased" appear in near-identical
  contexts. For a policy corpus that is not academic — retrieving the opposite clause and citing it
  confidently is worse than returning nothing. It is the strongest argument for keeping the
  full-text half, which is dumb about meaning but never confuses "not covered" with "covered".
- **RRF ties are common and were non-deterministic.** A document ranked (1,2) scores identically to
  one ranked (2,1), and without a tie-break Postgres returned whichever the plan emitted first —
  the same query gave different answers in different contexts. Now broken by vector rank, then
  `doc_id`. Worth knowing if you fuse rankings anywhere else.
- **`node_modules` is 401MB.** Running the model in-process means shipping ONNX runtime binaries.
  A hosted embedding endpoint would trade that for an API key and a network hop.

## License

MIT

TDQS

A4.4/5.0

Scored across 3 tools

Disambiguation5/5

Each tool targets a distinct aspect of insurance operations: policy document search, application status tracking, and product rule evaluation. There is no overlap in their purposes or outputs.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern with snake_case: search_policy_documents, get_application_status, lookup_product_rules. The verbs (search, get, lookup) are semantically appropriate and uniform.

Tool Count5/5

With only 3 tools, the server is tightly scoped to a specific insurance POC use case. Each tool addresses a core need without redundancy, making the count appropriate.

Completeness4/5

The main workflows of querying policy content, checking application status, and verifying product rules are covered. Minor gaps exist (e.g., no tools for creating/updating applications), but for a POC the surface is reasonably complete.

Maintenance

ActivitySlowing
ResponsivenessNo issues