Customer Health MCP
A Model Context Protocol server that combines three separate SaaS tools (billing, support, product usage) to answer one question none of them can answer alone: which accounts are actually at risk, and why?
Nobody's watching all three at once
Customer success teams sit at the intersection of billing, support, and product usage: three systems that rarely talk to each other. An account can have healthy MRR and zero open tickets while nobody's logged in for a month, and no single dashboard will catch that. The signal only shows up once you look at all three together.
customer-health-mcp is a portfolio project. I built it to work through that
synthesis problem as an actual MCP server, not just describe it in a slide. The data is
synthetic and deterministic (no API keys, no database, nothing real on the other end),
but I treated the tool design, input validation, structured output, and error handling
the same way I would for something headed to production. What follows is the build log:
the decisions I made, where I got them wrong the first time, and three bugs worth writing
down.
Where the data actually comes from
At startup, a seeded PRNG (mulberry32, seeded from a string via
xmur3) builds 32 fictional companies, entirely in memory. Each one carries
three sub-records shaped like the systems they're standing in for: a Stripe-style
BillingRecord (plan, MRR, payment status, downgrades), a support ticket list
styled after Intercom or Zendesk (severity, status, resolution time), and a 90-day
daily-usage series like you'd get from a product analytics tool, feature adoption
included. About 15–20% of accounts get pushed toward genuine risk on purpose, with usage
sliding, bills going unpaid, and tickets piling up, so there's actually something for the
tools to find.
Nothing gets cached. Every tool call recomputes its answer straight from that same in-memory dataset, which matters because it means the risk score in section 03 can never quietly drift out of sync with the raw data sitting one call away.
Bug: lastLoginDaysAgo was derived by scanning the 90-day
usage series for the first day with any activity, but the series is stored oldest-first,
so scanning front-to-back returned the oldest active day, not the most recent one. Every
account read as 89 days since login.
Fix #1: scan from the most recent day backwards instead.
Fix #2, the one that mattered more: after correcting the direction,
every account read 0 days since login instead, because the generator forced
"today's" value to always be non-zero. A declining account and a thriving one both looked
like someone had logged in that morning. The real fix was giving a share of struggling
accounts a genuine dormant tail: 5–45 consecutive days forced to zero activity, so "no one
has logged in" became something that could actually happen in the data, not just in the
field name.
How the score gets built
computeRiskScore() returns a number from 0 (no risk) to 100 (high risk),
built from four categories that are each capped independently, plus a plain-language
drivers list. That list is sorted by actual point contribution, not by
category order, so whatever's driving the score the most always shows up first.
| Category | Signal | Max pts |
|---|---|---|
| Usage | 30-day usage decline (trend) + days since last login (dormancy) | 18 + 12 |
| Adoption | active seats ÷ licensed seats: full points at 0%, none at 60%+ | 20 |
| Support | open high/critical tickets, tickets-per-seat volume, resolution time | 12 + 8 + 5 |
| Billing | canceled (60) or past due (32) payment status, plus a recent downgrade (+10) | 70 |
Bug: the first weighting pass capped billing at 25 points
(past_due: 18, canceled: 25). A unit test asserting that a past-due,
recently-downgraded account should not read as healthy failed: 18 + 7 = 25
points, five short of even the 30-point watch line. A fully canceled
subscription couldn't clear watch either, let alone at_risk.
Fix: rebalanced billing on purpose to be the highest-leverage category
(canceled: 60, past_due: 32, downgrade: +10, cap
raised to 70), so the two clearest real-world churn signals could actually stand on their
own. My code comments had already claimed that was the design intent before the numbers
backed it up.
Four tools, split two ways
Three of the four tools stay close to the underlying data: they filter, sort, and reshape, but they don't interpret anything. The fourth is the synthesis tool, the only one that pulls all three data sources into a single judgment call. Keeping them separate means a model (or a person reading its answer) can always go check a risk summary against the raw ticket history or usage series for that account. The scoring stays auditable instead of being the only thing you get to see.
| Tool / resource | Kind | What it returns |
|---|---|---|
| list_accounts | raw | Every account's plan, MRR, payment status, and risk band, filterable by plan and payment status. |
| get_account_details | raw | One account's full billing record, 90-day usage series, and ticket history, returned exactly as stored. |
| list_at_risk_accounts | raw | Accounts at or above a minimum risk band, sorted by score descending. |
| get_account_risk_summary | synthesis | Score, band, ranked drivers, and a templated natural-language paragraph. The flagship tool. |
| methodology://risk-scoring | resource | The weights and thresholds above, rendered live from the same constants the scorer runs on, so it cannot drift out of sync with the code. |
Here's an actual reply from get_account_risk_summary, for a seeded account
that's gone dark and stopped paying:
// get_account_risk_summary({ accountId: "acct_0014" }) { "companyName": "Tidewater Insurance", "riskBand": "at_risk", "riskScore": 100, "drivers": [ "Subscription has been canceled", "Only 0% of licensed seats are active", "Usage has dropped 100% over the last 30 days", "No product login in 43 days" ], "summary": "Tidewater Insurance is at meaningful risk of churning (risk score 100/100). Key factors: Subscription has been canceled; Only 0% of licensed seats are active; Usage has dropped 100% over the last 30 days." }
Testing against a real client
Five unit tests hit computeRiskScore directly: a healthy account, one with
declining usage, one with a billing problem, a mixed-signal account, and an edge case
that maxes everything out to check the driver ordering. Four integration tests go
further and actually spin up McpServer and a real MCP Client,
connected over the SDK's InMemoryTransport, then drive them through genuine
JSON-RPC calls (tools/list, resources/list,
tools/call) instead of just invoking the handler functions directly. One of
those tests checks that a specific seeded account (acct_0014, canceled and
dark under the default seed) actually shows up in list_at_risk_accounts and
actually gets explained correctly by get_account_risk_summary, not just
that the response comes back in the right shape.
Wiring it into Claude, twice
I wired the same stdio server into both of Claude's integration surfaces, since they're
built on entirely separate systems with no shared configuration between them. For
Claude Code (this CLI/agent surface), I registered it with
claude mcp add, which stores it in ~/.claude.json. For
Claude Desktop's regular chat, I packaged it for the Connectors system
instead, built on Anthropic's MCPB format (MCP Bundle, formerly called DXT): a signed
.mcpb file installed straight through the app.
Bug: claude mcp add customer-health -- node dist/index.js
worked fine in the terminal it was run from, then quietly failed to connect from a fresh
one. The registered command was just node, and that only resolves in a shell
that's already sourced nvm's init lines onto PATH. Different
terminals source different profiles, so whether Node happened to be on PATH
by the time claude started came down to luck.
Fix: pointed the registration at the actual resolved binary
(~/.nvm/versions/node/v20.20.2/bin/node) instead of the bare command name.
Now it doesn't matter which shell launches it.
Packaging for the Connectors surface used Anthropic's official mcpb CLI.
mcpb pack bundled up the project directory as-is, then mcpb clean
went back through and stripped out devDependencies, vitest and everything it drags in:
$ mcpb clean customer-health-mcp.mcpb
Removed development dependencies from node_modules
Before: 30.7 MB
After: 3.19 MB
What it's built from
TypeScript throughout, strict mode, ES2022/NodeNext modules.
@modelcontextprotocol/sdk handles the server and stdio transport, and every
tool has zod input/output schemas so a calling model gets typed
structuredContent back instead of text it has to re-parse.
vitest runs both the unit and integration tests. There's no database and no
external API. The whole dataset comes from one seed string, generated in memory, and
reproducible with a --seed flag on the CLI entrypoint.
src/ data/ generator.ts, rng.ts, types.ts // seeded synthetic dataset scoring/ riskScore.ts (+ tests) // the weighted model above tools/ 4 tools + shared accountId lookup resources/ methodology.ts // self-documenting weights server.ts, index.ts // wiring + stdio entrypoint tests/ integration.test.ts // real client, InMemoryTransport