Support Agent MCP
by malston
README.md
# Domain 2 -- Support-Agent MCP Tool & Error Design
A runnable, test-driven implementation of the CCA Domain 2 build exercise. A
support-agent MCP surface exposes three tools -- an ambiguous read pair
(`lookup_order` / `get_customer`) and a mutation (`issue_refund`) -- whose
**descriptions** route requests, returns **categorized errors** the model can act
on, and keeps an **access failure** distinct from a **valid empty result** so the
agent never lies about what it could not read.
- The design doc this implements: [`./deliverables/domain2-build-exercise.md`](./deliverables/domain2-build-exercise.md)
- The exercise prompt: [`./.prompts/domain2-build-exercise.prompt.md`](./.prompts/domain2-build-exercise.prompt.md)
## Quick start
```bash
make install # poetry install --with dev (no API key needed)
make test # full test suite, no key
make demo # offline routing + error demo
```
`make help` lists every target. No Make? The commands are `poetry install --with dev`,
`poetry run pytest`, and `poetry run python -m support_agent.demo`.
The demo runs four sections against the real code:
```
=== routing: "check order #12345 status" ===
with mutual boundaries -> lookup_order
with naive getters -> AMBIGUOUS (ambiguous between get_customer, lookup_order)
=== four error categories ===
transient isError=True category=transient retryable=True code=ORDERS_UPSTREAM_TIMEOUT
validation isError=True category=validation retryable=True code=INVALID_ORDER_ID
business isError=True category=business retryable=False code=ALREADY_REFUNDED
permission isError=True category=permission retryable=False code=REFUND_FORBIDDEN
=== access failure vs. valid empty (same request, opposite meaning) ===
order absent isError=False category=None retryable=False code=ORDER_NOT_FOUND
db unreachable isError=True category=transient retryable=True code=ORDERS_DB_UNREACHABLE
=== cross-domain resolve_cap_basis ===
no fees (valid) isError=False category=None retryable=False code=NO_FEES_ON_RECORD resolved_basis=0
fee svc down isError=True category=transient retryable=True code=FEE_SERVICE_UNREACHABLE resolved_basis=NONE
```
## Optional -- live model + MCP protocol client
The demo above is deterministic and needs no key. Two optional paths exercise the
real model and the real MCP protocol -- install the live extras first:
```bash
make install-live # adds anthropic, mcp, python-dotenv
cp .env.example .env # set ANTHROPIC_API_KEY (only the live router needs it)
make live # live routing: the real model picks the tool via
# tool_choice="auto" (support_agent.live_demo)
make mcp-demo # a plain MCP client over the protocol -- no model, no key
# (support_agent.mcp_client_demo)
```
`make mcp-demo` needs the `mcp` SDK but no key: it lists and calls the tools over
the protocol, showing that the same descriptions the router scores are the contract
every client sees. The server it talks to (`python -m support_agent.server`,
declared in `.mcp.json`) is what a real MCP client such as Claude Code launches.
## The headline
"check order #12345 status" **must** route to `lookup_order`, never to
`get_customer`. The routing is done by the **description** -- specifically the
mutual `WHEN NOT TO USE` boundary, where each of the ambiguous pair names the other
as the place to send colliding requests. With the real boundaried descriptions the
request routes to `lookup_order`; swap in naive "Retrieves [entity] information"
getters and the same request goes **ambiguous**. That delta is the proof the
boundary is load-bearing, not decorative.
`route` (`router.py`) is the deterministic stand-in for the model's tool selection
-- the offline seam, exactly like Domain 1's `ScriptedClient`. The thing under test
is the descriptions, not the router: the scorer is uniform and tool-agnostic, so
changing the outcome means changing the descriptions. The live analog
(`ModelRouter` in `live.py`) sends the same descriptions to the real model with
`tool_choice="auto"`. See `tests/test_routing.py` and `tests/test_tool_descriptions.py`.
## How the deliverables map to code
| Deliverable | Where | Correct pattern (demonstrated) | Distractor (shown failing) |
| --------------------------------- | -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ |
| 1. Three tool definitions | `tools.py` | Four-component descriptions; mutation not overloaded | "Retrieves X information" generic getters |
| 2. Mutual disambiguation | `tools.py`, `router.py` | Each boundary names the **other** tool; sibling is disqualified | One-sided boundary -> the other reads as a catch-all |
| 3. Four categorized errors | `errors.py`, `handlers.py` | `isError`+`_meta`; business is `isError:true`/retryable **false** | Model `business` failure as a valid empty result |
| 4. Access-failure vs. valid-empty | `handlers.py`, `errors.py` | `isError:false` (absent) vs `isError:true`+transient (unreachable) | Same shape for both -> "no such order" on an outage |
| 5. `.mcp.json` with `${VAR}` | `.mcp.json`, `config.py` | Project scope, `${VAR}` on every secret, scanner proves zero leaks | Hardcoded credential in committed config |
| Cross-domain `resolve_cap_basis` | `cap_basis.py` | Unreachable -> no number; no-fees -> valid `$0` | Collapse "unreachable" into "fees = $0" (fake zero exposure) |
| Categories survive propagation | `propagation.py` | Coordinator reads `error_category`/`retryable` off `_meta` | Subagent flattens the error to "it failed" |
## The one decision the field name encodes -- `retryable`
`retryable` answers **"can recovery-by-retry succeed?"**, not "retry the identical
call." Transient -> yes, same call. Validation -> yes, **but only after the agent
fixes the arguments** (a verbatim retry fails the same way -- the prose says so).
Business / permission -> no; change strategy or escalate. This keeps a coordinator's
recovery deterministic: a `retryable: true` validation error never means "replay the
same bytes." See the design doc's note on why this is pinned.
## Access failure vs. valid empty -- the confident lie
The dangerous failure is making the two identical. If a database outage returned the
same shape as a genuine miss (`isError: false`, "no order found"), the model cannot
tell "we looked and it isn't there" from "we couldn't look" -- and it will fluently
tell the customer the order does not exist. The `isError` split forces the loop to
branch: `false` -> answer the user; `true` + `transient` -> retry, never assert
non-existence. `tests/test_access_vs_empty.py` asserts the outage never renders as
"no order found" and always reads "UNKNOWN".
## Cross-domain hook -- `resolve_cap_basis` (for the Domain 1 reviewer)
The Domain 1 reviewer handles numeric caps with deterministic arithmetic. A
**formula** cap ("fees paid in the trailing 12 months") needs an external figure,
and resolving it is the _same_ access-vs-empty pattern pointed at a fee service:
- fee service unreachable -> **access failure** (`isError: true`, transient), and it
carries **no** `resolved_basis` -- collapsing it into `$0` would make an unbounded
cap read as zero exposure and clear the reviewer's send gate on a fabricated number.
- account genuinely has no fees -> **valid empty** (`isError: false`,
`resolved_basis: 0`).
`propagation.py` shows the category surviving from an isolated subagent up to a
coordinator (the distractor `flatten_to_failed` throws it away). **What the
coordinator then DOES with an unresolved cap** -- escalate rather than fabricate a
clean "no exposure" verdict -- is **Domain 5** (load-bearing failure), flagged
there, not solved here.
## `tool_choice` is not a routing fix
Routing is a description problem. The live path uses `tool_choice="auto"` (model
decides whether and which tool to call). Forcing a tool would fix one request and
misroute every other; `any` would force a call on turns that should just talk to the
user. Forced choice is for a known constrained sub-step, never a substitute for
descriptions that route on their own.
## Module guide
| Module | Responsibility |
| ---------------- | --------------------------------------------------------------------------------------- |
| `tools.py` | The three tool definitions + `render_description`; the naive distractor surface |
| `router.py` | `route` -- the deterministic tool-selection seam (offline analog of the model) |
| `errors.py` | `mcp_error` / `mcp_ok` builders + the named scenario objects (the exact prose) |
| `handlers.py` | `lookup_order` / `get_customer` / `issue_refund` -- map backend outcomes to results |
| `backend.py` | `OrdersBackend` / `CustomersBackend` seam + `StubBackend` (stages each condition) |
| `cap_basis.py` | `resolve_cap_basis` + `StubFeeService` -- the cross-domain access-vs-empty tool |
| `propagation.py` | Category survival from subagent to coordinator (and the flattening distractor) |
| `config.py` | Load `.mcp.json`, expand `${VAR}`, scan for hardcoded secrets (rejects default secrets) |
| `server.py` | The MCP server in `.mcp.json` -- testable `dispatch` core + optional `mcp`-SDK glue |
| `live.py` | Optional `ModelRouter` -- real-model routing via `tool_choice="auto"` |
| `demo.py` | The four-section offline demonstration |
## The live path (optional)
```bash
poetry install --with dev --with live
export ANTHROPIC_API_KEY=... # or cp .env.example .env
```
`ModelRouter` (`live.py`) sends the real descriptions to `claude-opus-4-8` with
`tool_choice="auto"` and reports which tool the model selected. The deterministic
`route` powers every test, so the suite never needs a key.
The `live` group also installs the `mcp` SDK, so the server in `.mcp.json` runs:
```bash
python -m support_agent.server # stdio MCP server over in-memory sample data
```
`server.py`'s `dispatch` core is covered by the offline suite; only the stdio glue
needs the SDK (same opt-in pattern as the model path).
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues