MCP server · build log

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?

TypeScript @modelcontextprotocol/sdk zod vitest stdio transport seeded PRNG
32seeded accounts
4+1tools & resource
9/9tests passing
0–100explainable score
01 · 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.

02 · How it works

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.

MCP Client (Claude) stdio CUSTOMER-HEALTH-MCP PROCESS generator.ts runs once, at boot 32 accounts held in memory riskScore.ts never cached tools/ shape reply
The generator runs once at startup; every tool call re-runs the scorer fresh against the same in-memory accounts, so a risk summary can never disagree with the raw record one tool call away.
Lab note: the login signal that always read "today"

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.

03 · Scoring the risk

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.

watch ≥30 at_risk ≥60 Usage 30 pts max Adoption 20 pts max Support 25 pts max Billing past due: 32 canceled: 60 +10 downgrade
Billing is the only category where a real-world trigger (past due, canceled) can cross the watch or at_risk line on its own. That's on purpose: a billing failure is a harder churn signal than a soft dip in usage or a handful of open tickets.
CategorySignalMax pts
Usage30-day usage decline (trend) + days since last login (dormancy)18 + 12
Adoptionactive seats ÷ licensed seats: full points at 0%, none at 60%+20
Supportopen high/critical tickets, tickets-per-seat volume, resolution time12 + 8 + 5
Billingcanceled (60) or past due (32) payment status, plus a recent downgrade (+10)70
healthy · 0–29 watch · 30–59 at_risk · 60–100
Lab note: a canceled account that scored "healthy"

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.

04 · Four tools, one resource

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 / resourceKindWhat 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."
}
05 · Proving it works

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.

5unit tests
4integration tests
9/9passing
0npm audit findings
06 · Shipping it

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.

Lab note: it worked in one terminal and not another

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
07 · Stack & structure

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