Skip to main content
Glama
hritiksahu

reach-dispute-mcp

by hritiksahu
README.md
# Reach Dispute Resolver — MCP server

Read-only tools over the REACH billing system of record that diagnose customer
billing disputes: find the contradiction behind the complaint, cite the exact
rows it rests on, score confidence, and decide auto-resolve or escalate.

It is not a chatbot. It is a set of tools that plugs into Claude — or anything
else that speaks MCP, including the DCAL team's existing chatbot.

**The division of labour is the point.** Deterministic Python finds the
contradiction and sets the confidence score. The language model only turns that
finding into a sentence a person can read. The model can lower confidence; it
can never raise it.

---

## Quick start

```bash
cd ~/Documents/Project/reach-dispute-mcp

.venv/bin/python check.py             # data layer          — 15 checks
.venv/bin/python check_mcp.py         # live MCP handshake  — 18 checks
.venv/bin/python check_detectors.py   # verdicts are right  — 28 checks
```

All three should end with `all checks passed`. That's 61 assertions and takes
about three seconds — run them before demoing.

The server is already registered in `~/.claude.json`. In Claude Code:

```
/mcp
```

`reach-dispute` should appear with **7 tools**. If it doesn't, see
[Troubleshooting](#troubleshooting).

---

## The 60-second demo

Two customers. That's the whole show — one the system can answer, one it knows
it can't.

### Setup (10s)

> "This is an MCP server. Seven read-only tools over our billing data. Claude is
> just the client — the same tools could plug into the DCAL chatbot."

Run `/mcp` and show `reach-dispute · 7 tools`.

### Case 1 — it can answer (25s)

Type:

> **Customer CUS-4471 says they paid but they're still suspended. What does the data show?**

Watch it call `get_customer_snapshot`, `list_ledger_transactions`, then
`diagnose_dispute`. Point at three things in the answer:

| Point at | Say |
|---|---|
| The evidence rows | "Every claim traces to a row — table, key, field, value. It physically cannot cite a record it didn't read." |
| **Confidence 0.91** | "Java-style deterministic rules produced that number, not the model. The model can't change it." |
| **auto_resolve** | "Payment landed 22 July, suspension was 20 July, reconnection never ran. Two records disagreeing." |

### Case 2 — it knows it can't (25s)

Type:

> **CUS-6614 says you charged them $10.50 twice. Did you?**

> "This is RTJSM-41555, a real ticket that took three days and four people."

| Point at | Say |
|---|---|
| **Confidence 0.40 · escalate** | "It does *not* tell the customer they're wrong." |
| The reason | "The second charge, if it exists, is in IPpay — which this server cannot read." |
| `HPY202607021852176` | "It hands L2 the transaction ID we do hold. Half-solved ticket instead of a cold start." |

### Close (5s)

> "Same system, two customers. It correctly knows which one it can answer.
> Knowing where its knowledge stops is the product."

---

## The five scenarios

Synthetic data. Real scenarios — each reconstructs the contradiction its ticket
turned on.

| Customer | Mobile | Scenario | Modelled on | Verdict |
|---|---|---|---|---|
| `CUS-4471` | 5550142201 | Paid, still suspended | RTJSM-41687 | 0.91 · auto-resolve |
| `CUS-2210` | 5550163344 | Charged twice ($10.50) | REACHTS-12017 | 0.93 · auto-resolve |
| `CUS-8802` | 5550178899 | Bill jumped to $335.84 | RTJSM-40005 | 0.90 · auto-resolve |
| `CUS-3390` | 5550119055 | Promo credit not applied | REACHTS-12005 | 0.86 · auto-resolve |
| `CUS-6614` | 5550188120 | Gateway mismatch | RTJSM-41555 | 0.40 · **escalate** |

Look them up by customer ID, mobile number, or email. Ask Claude *"what dispute
scenarios are available?"* to list them.

`CUS-8802` is a good second-choice demo — the bill decomposes exactly:
`83.85 plan + 251.88 carried forward + 0.11 tax = 335.84`, and it names the three
failed payments that created the carry-forward. In the real ticket, everything
needed to work that out was already in the description and nobody did the
subtraction for seven days.

---

## Pasting a real ticket

Claude extracts the parameters from ticket text. It needs:

- **Required** — something identifying the customer: ID, mobile number, or email.
- **Helps a lot** — the claimed amount and how many times, if the customer named
  them (`"$10.50 twice"`). This is what lets the rules detect *"you say two, I
  see one"* rather than just reporting what we hold.
- **Ignored** — everything else. Harmless, just not used.

> Running on fixtures, only the five customers above resolve. A real ticket needs
> `REACH_DATA_SOURCE=dev` and that customer to exist in the dev database.

---

## How it works

```
Claude Code ──stdio──▶ server.py ──▶ tools/diagnose.py ──▶ data/provider.py
                        7 tools       4 detectors           fixtures | dev
                                      + decision gate
```

**Six read tools** fetch data. Each returns `{data, evidence[]}`, where evidence
is the actual rows read as `{table, key, field, value}`.

**`diagnose_dispute`** is the seventh and the one that decides. Four detectors,
each a comparison between two records:

| Detector | The contradiction |
|---|---|
| `paid_but_suspended` | successful payment dated after `lastSuspendedDate`, no reconnect recorded |
| `duplicate_charge` | two ledger rows, same amount and category, < 24h apart, distinct transaction ids |
| `credit_not_applied` | valid unapplied credit while the bill shows no adjustment |
| `bill_increase_explained` | bill decomposes exactly into plan + carry-forward + tax |
| `claim_exceeds_our_records` | customer claims more charges than the ledger holds → **unverifiable** |

### The decision gate

Auto-resolve requires **all** of:

- confidence ≥ **0.80**
- nothing flagged unverifiable
- if the remedy moves money (refund, apply credit), amount ≤ **$25**

The money cap applies only to money-moving remedies. `CUS-4471` is a $52.40
dispute and still auto-resolves, because the remedy is *reconnect a line we
wrongly suspended* — that costs nothing and can't be the wrong call. Explaining
a bill is likewise always safe.

### The one-way lock

`diagnose_dispute` accepts a `concern` parameter. Supplying one caps confidence
at 0.50 and forces escalation:

```
without concern:  0.91  → auto_resolve
with concern:     0.50  → escalate
```

It clamps with `min()`. There is no path that raises confidence.

---

## Say these unprompted

Getting caught not saying them is far worse than saying them.

1. **The data is synthetic. The scenarios are real tickets** — numbers are in the
   table above.
2. **It is not wired into JSM.** You give it a customer, not a ticket. Deliberate:
   wiring is a day of plumbing that proves nothing about whether the diagnosis is
   right — and posting a comment to a live ticket would be a write, which breaks
   the read-only guarantee.
3. **No blanket time claim.** In a 100-ticket sample the median was 2.45 days and
   29% closed same-day. The claim is hours back on the 71% that don't, plus
   knowing whether the refunds we grant blind were actually owed.
4. **The fixtures and the detectors were written together**, so of course they
   agree. This proves the wiring and the logic, not that detectors fire correctly
   on production data. One dev-database run is what settles that.

---

## Read-only guarantees

Three layers, none of them "we promise":

1. **The interface has no write method.** `DisputeDataProvider` declares six
   methods, all reads. There is nothing to call.
2. **Every tool advertises `readOnlyHint=true`** in its MCP annotations — the host
   can see it, not just take the README's word.
3. **Evidence is read from the row**, never passed alongside it. Citing a field
   that doesn't exist raises rather than emitting a blank.

---

## Switching to real data

```bash
REACH_DATA_SOURCE=dev .venv/bin/python server.py
```

Or change `env` in the `~/.claude.json` entry.

**Working as of 11 Aug 2026.** `data/dynamo.py` is implemented and the dev path
is live. Run `check_dynamo.py` first — it verifies credentials, table names and
index names, and tells you which layer is broken instead of dumping a botocore
traceback.

Credentials are temporary session keys in `~/.aws/credentials` with no SSO cache,
so when they lapse they have to be re-pasted from the console — there is no
`aws sso login` path. Export `REACH_DEV_ACCOUNT_ID` if you want the check to
assert it reached the intended account. A read-only scoped role would be better
than running this under admin.

Before pointing at dev, check whether that data is seeded or copied from prod —
if it's a prod copy, real customer records land on screen during the demo.

---

## Troubleshooting

**`reach-dispute` missing from `/mcp`** — restart Claude Code; the config is only
read at startup. Then verify the entry in `~/.claude.json` points at the venv
python by absolute path.

**Server won't start** — run it directly and read stderr:
```bash
.venv/bin/python server.py
```
`reach-dispute MCP server starting (data source: fixtures)` then a wait for input
is correct. It's a stdio server — there is no port to curl.

**"No customer matches that identifier"** — you're on fixtures and used an
identifier outside the five above.

**Checks fail after an edit** — `check_detectors.py` names the scenario and the
assertion. The fixtures and detectors are coupled by design; changing a fixture
value will correctly break the detector test.

---

## Layout

```
server.py             MCP server — tool registration and descriptions
data/
  provider.py         read-only contract; fixtures/dev switch
  records.py          Row and Evidence — the citation machinery
  fixtures.py         the five scenarios (data only, no logic)
tools/
  read_tools.py       the six read tools
  diagnose.py         four detectors + the decision gate
check.py              data layer          — 15 checks
check_mcp.py          live MCP handshake  — 18 checks
check_detectors.py    verdicts are right  — 28 checks
```

`data/records.py` is the smallest file and the load-bearing one: sixty-odd lines
are the reason the model cannot cite a row it never read.

---

## Environment

- Python 3.12, isolated `.venv` — system Python untouched
- `mcp==2.0.0` (note: 2.0 uses `MCPServer`, not the 1.x `FastMCP`)
- `boto3` only needed for `REACH_DATA_SOURCE=dev`
- No `ANTHROPIC_API_KEY` — Claude Code is the host and drives the loop