legacy-mcp
README.md
# Legacy MCP
**MCP-ifying a legacy system: governed, business-language tools over a SOAP + stored-procedure stand-in — with a semantic data dictionary, compensation for transactionless writes, and measured load protection.**
> Suggested GitHub repo name: `legacy-mcp`
---
## Why this project exists
Enterprises run on decades-old systems: SOAP services, stored-procedure databases, cryptic schemas nobody fully remembers. The current wave of forward-deployed work is making those systems usable by AI agents *without replacing them* — wrapping legacy interfaces in governed, modern tool layers. The craft is not the MCP part; it's the archaeology (what does `PROC_UPD_47` actually do?), the safe-write patterns on a backend with no cross-call transactions, and the duty of care toward infrastructure that dies if you hammer it.
This project builds the legacy system *and* the wrapper, so every claim is checkable end to end.
## The legacy stand-in (authentically awful, on purpose)
A wholesale order-management backend, reachable only through a SOAP-style XML endpoint fronting stored procedures:
- tables `T_CST`/`T_ITM`/`T_ORD_H`/`T_ORD_L`; columns like `C_STS CHAR(1)` with magic letters (`A`/`H`/`X`), money in integer cents, dates as `YYYYMMDD` ints
- positional parameters (`<P1>`, `<P2>`…), faults like `ERR-3007 CONSTRAINT VIOLATION SEGMENT 4`
- **no cross-call transactions**: creating an order is a three-procedure protocol (`HDR_INS` → `LN_INS` per line → `FIN`); die in the middle and an incomplete header is stranded forever (the seed data ships with orphan order 9005 as the exhibit)
- **undocumented side effects**: `PROC_UPD_47` "sets customer status" but also recalculates the credit-block flag from open order exposure — so releasing a hold does not necessarily unblock the customer
- **fragility**: above ~5 calls/s it faults `ERR-9999 SYSTEM BUSY`; under injected latency it just gets slower
The wrapper never imports the database — it speaks the XML wire only, like a real engagement.
## The deliverables
**1. The semantic data dictionary** (`docs/data_dictionary.md`) — every table, column, status letter, procedure contract, error code, and landmine, with how each was recovered (trial calls, audit-log diffing). `semantics.py` is its executable form; tests pin them to each other.
**2. Governed MCP tools** (official SDK) designed around business operations, not endpoints: `lookup_customer`, `get_customer_credit`, `check_item_availability`, `get_order_status`, `list_orders`, `place_order`, `cancel_order`, `release_hold`, `set_customer_status`, `cleanup_incomplete_orders`. Statuses are words, money is decimal, dates are ISO. Every failure — including a malformed argument from the agent — arrives as one actionable sentence: what happened, what to do, and the raw code for the humans. Verbatim:
> `ERR-3007` → *Blocked during order creation for customer 4711: the customer is on hold or over their credit limit. Use get_customer_credit to see exposure vs limit; release_hold clears a hold but will NOT clear an over-limit credit flag. [legacy: ERR-3007 CONSTRAINT VIOLATION SEGMENT 4]*
**3. Compensation for transactionless writes.** `place_order` drives the three-call protocol; on any mid-protocol failure it deletes the incomplete order and says so. The failed-order path is tested down to "no orphan rows, stock untouched."
**4. Load protection, measured live.** Every legacy call passes a token bucket (throttle by waiting, not shedding) and a circuit breaker (open on consecutive infra failures; half-open probe; business faults never count).
## Measured results
All produced by commands in this repo on 2026-07-31 (`results/` committed). Tests: **75/75 passed**.
### Before/after capability (`legacy-mcp capability`)
Same six tasks; a scripted *generic-competence* operator against the raw SOAP surface vs the same shell using the MCP tools. Judged only by end-state database checkers and exact numbers — no graders. (Methodology and the raw operator's generous assumptions are documented in `capability.py`; the knowledge it lacks — status letters, the three-call protocol, cents, fault semantics — is precisely what the wrapper packages.)
| task | raw interface | MCP tools | wrapped detail |
|---|---|---|---|
| place-simple-order | FAIL | PASS | pending order, correct total, stock decremented |
| blocked-order-explained | FAIL | PASS | diagnosed: hold AND over-limit; release won't fix |
| partial-failure-cleanup | FAIL | PASS | failure explained, no orphans, stock untouched |
| cancel-and-restock | FAIL | PASS | cancelled; restock observed (8 → 18) |
| janitor-incomplete-orders | FAIL | PASS | orphan 9005 identified and removed |
| credit-headroom | FAIL | PASS | 12,000.00 limit − 1,540.00 open = 10,460.00 |
**raw 0/6 — wrapped 6/6.** Representative raw failures: the order left stranded in `I` because nothing advertises that `OP_ORD_FIN` exists; headroom computed from cents and shipped orders; "stuck orders" invisible because `I` is just a letter.
### Protection under a degraded backend (`legacy-mcp protection-demo`, real sockets)
Slow backend (2s/call, wrapper timeout 0.5s, breaker threshold 3):
| call | outcome | wall (s) |
|---|---|---|
| 1–3 | timeout | ~0.50 each |
| 4–8 | fast-fail, circuit open | 0.000 |
| 9 (after recovery + cool-down) | ok — half-open probe closed the circuit | 0.003 |
The breaker held total backend calls to **4** across the whole episode (3 timed-out probes + 1 recovery probe); five agent calls were answered instantly with an actionable "backend degraded, retry in Ns" instead of hanging.
Busy backend (faults above 5 calls/s), 25 reads:
| caller | succeeded | SYSTEM BUSY faults | wall |
|---|---|---|---|
| unthrottled | 5/25 | 20 | 0.08s |
| wrapped (bucket 1 + 4/s) | **25/25** | **0** | 6.11s |
The wrapper spent 5.8s deliberately waiting — trading its own latency for the backend's health, which is the entire duty of care. Successes are reported next to faults on purpose: "zero busy faults" would also be true of a caller that fast-failed everything, so the table has to show that all 25 calls actually completed. The first sizing attempt (bucket 4 + 4/s) still produced 2 busy faults because burst + refill exceeded the backend's ceiling in the first second; the fix (capacity 1) is kept in the code comment as the lesson: *size the bucket to the backend's measured capacity, not to a round number.*
## Quickstart
```bash
uv sync --extra dev
cp .env.example .env
.venv/bin/pytest # 75 tests: stand-in, semantics, compensation, protection
.venv/bin/legacy-mcp capability # the before/after table -> results/capability.md
.venv/bin/legacy-mcp protection-demo # live slow/busy scenarios -> results/protection.md
.venv/bin/legacy-mcp raw-peek # feel the raw interface yourself
# run it for a real agent
.venv/bin/legacy-mcp serve-legacy & # the legacy stand-in (:8093)
.venv/bin/legacy-mcp serve-mcp # MCP over stdio, wired to it
# degrade the backend and watch the wrapper cope:
LEGACY_SLOW_MS=2000 .venv/bin/legacy-mcp serve-legacy
```
`protection-demo` stands up its own backends on `SOAP_PORT` and the two ports
above it, so stop a backgrounded `serve-legacy` first or point it elsewhere with
`SOAP_PORT=8200`. It says which port it could not bind rather than hanging.
## Repository layout
```
legacy-mcp/
├── docs/data_dictionary.md # the archaeology deliverable
├── results/ # committed capability + protection evidence
├── src/legacy_mcp/
│ ├── legacy/db.py # the stand-in: procs, protocol, faults, fragility
│ ├── legacy/soap.py # XML envelope endpoint (the only wall socket)
│ ├── legacy/client.py # typed wire client; adds no meaning
│ ├── semantics.py # recovered meaning: codes, units, error translation
│ ├── tools.py # business-operation tools + compensation
│ ├── protection.py # token bucket + circuit breaker (Guard)
│ ├── protection_demo.py # live measured scenarios
│ ├── capability.py # before/after suite, end-state checkers
│ ├── server.py # MCP registration (official SDK)
│ └── cli.py # serve-legacy | serve-mcp | capability | protection-demo
└── tests/ # 75 tests, incl. the agent-facing failure contract
```
## Honesty notes
- The "raw operator" in the capability suite is scripted, not an LLM; its generic competence and its ignorance are both explicit in code. The suite measures what the *interface* affords, and the strict test (`raw 0/6, wrapped 6/6`) fails in both directions if either side drifts.
- The legacy stand-in is self-built, so its awfulness is curated rather than accreted. Every quirk it has is one documented from real systems (transactionless multi-call writes, overloaded error codes, side-effecting status procs, cents/YYYYMMDD encodings, SYSTEM BUSY ceilings).
- The protection numbers come from real sockets and real timeouts, not mocks; the deterministic breaker/bucket unit tests use a simulated transport and say so.
- The failure contract is tested, not asserted in prose: `tests/test_error_contract.py` renders every fault template against every operation phrase the tools actually pass, and drives fifteen kinds of malformed argument through `place_order` to prove nothing escapes as a raw exception. Both suites exist because both properties were broken — the templates read "Customer order creation for customer 4711 is blocked" and bad quantities surfaced a `TypeError`.
TDQS
C2.9/5.0
Scored across 10 tools
Disambiguation5/5
Each tool has a clear, distinct purpose: customer lookup, credit, hold, status; order lifecycle (get, list, place, cancel); item availability; and cleanup. No overlapping functionality.
Naming Consistency5/5
All tool names follow a consistent verb_noun pattern (e.g., get_order_status, lookup_customer), making them predictable and easy to understand.
Tool Count5/5
10 tools cover the core operations of a legacy order/customer system without being excessive or too sparse.
Completeness4/5
Covers customer management, order lifecycle, inventory check, and cleanup. Missing update/modify order tool, but otherwise well-scoped for the domain.
Maintenance
ActivitySlowing
ResponsivenessNo issues