Skip to main content
Glama
README.md
# haia-trace-mcp

An MCP server that lets an AI agent read its own [Haia Trace](https://github.com/Haia-Finance/haia-trace)
Operation Receipts and check, before paying again, whether earlier paid
operations actually completed.

A wallet confirms funds left. A payment protocol confirms the payment was
accepted. A chain confirms settlement. None of them confirms that the
operation as a whole finished, so an agent can keep spending against outcomes
that never arrived. Haia Trace already produces a per-operation verdict
(`full` or `partial`) from the events an agent recorded on its own side. This
project puts that verdict in front of the agent through MCP, and adds a small
advisory pre-payment guard whose `review` outcomes go to a human.

Independent, MIT-licensed, built on Haia's published `@usehaia/*` packages.
**Not affiliated with or endorsed by Haia.**

![An agent pays two sellers; one never settles. Before paying it again, check_before_pay answers review, a human rejects it from the CLI, and the agent reads the decision and an explanation of the open receipt.](assets/demo.gif)

_The [demo](examples/demo/README.md), recorded from a real run with
`examples/demo/record.sh`. The money is simulated; the x402 client, the
recorder, the receipts and the MCP tools are real._

## What it does

| Tool                | Reads                        | Writes                                                          | Returns                                                                                           |
| ------------------- | ---------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `list_operations`   | runs                         | —                                                               | one row per receipt, newest first: `run_id`, `operation_id`, verdict, missing stages, fault count |
| `get_receipt`       | one run                      | —                                                               | the full Receipt exactly as `@usehaia/trace-core` assembles it, plus `run_id`                     |
| `check_before_pay`  | all runs, policy, review log | one `opened` line to the review log when the answer is `review` | `allow` / `review` / `block` with reasons and receipt evidence                                    |
| `explain`           | one run                      | —                                                               | a deterministic plain-language paragraph about one receipt                                        |
| `get_review_status` | review log                   | —                                                               | `pending` / `approved` / `rejected` with the reviewer's note                                      |

There is no tool to approve a review. That is the design, not an omission: a
human approves or rejects from the command line, and the agent can only read
the result.

```
agent ──MCP──▶ haia-trace-mcp ──reads──▶ .trace/events/*.ndjson   (written by the trace-x402 recorder)
                     │          ──reads──▶ .trace/templates, bundled templates
                     │          ──appends─▶ .trace/guard/decisions.ndjson
human ──CLI──▶ haia-trace-mcp review approve|reject ──appends─▶ .trace/guard/decisions.ndjson
```

## Quickstart (60 seconds)

Requires Node 22 and pnpm.

```sh
git clone <this repo> && cd haia-trace-mcp
pnpm install && pnpm build
sh examples/demo/run.sh
```

The demo pays two simulated sellers, one of which never settles, builds the
receipts with `haia-trace`, asks `check_before_pay` before paying the second
seller again, gets `review`, rejects it from the CLI, and has the agent read
the decision and the explanation. See [examples/demo](examples/demo/README.md).

To serve a real Trace directory to a host:

```sh
node dist/cli.js --dir /path/to/.trace --guard-config /path/to/haia-guard.config.json
```

Claude Desktop (`claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "haia-trace": {
      "command": "node",
      "args": [
        "/abs/path/haia-trace-mcp/dist/cli.js",
        "--dir",
        "/abs/path/to/.trace"
      ]
    }
  }
}
```

## Tool reference

Identity: an operation is `(run_id, operation_id)`. A run is one recorder
session, `.trace/events/<run_id>.ndjson`; `context_id` (the operation id) is
only unique within a run, so this server never merges runs and never looks an
operation up by id alone.

### `list_operations`

```jsonc
{
  "template": "x402-buyer",
  "run": "latest",
  "completeness": "partial",
  "limit": 20
}
// run: "latest", a run id, or omit for all runs. limit ≤ 100, default 50.
```

Returns `{ operations: [{ run_id, operation_id, template, completeness, missing_stages, exception_count, last_event_at }], runs_scanned }`.

### `get_receipt`

```jsonc
{
  "run_id": "1794165874559",
  "operation_id": "e4cc6413-…",
  "template": "x402-buyer"
}
```

Returns the Receipt from `@usehaia/trace-core` (`operation`, `completeness`,
`stages`, `missing`, `exceptions`, `events`) plus `run_id`. The receipt is
byte-for-byte what `haia-trace build --json` produces for the same run and
template; `test/receipts.parity.test.ts` checks this on every shipped fixture.

### `check_before_pay`

```jsonc
{
  "counterparty": "0xB0B0…0002",
  "amount": "0.25",
  "asset": "USDC",
  "template": "x402-buyer"
}
// counterparty: the seller's pay_to address, or the service host / URL.
// amount: a decimal string in display units. Never a float.
// asset: registry key, symbol or token address; default: the policy's asset.
```

Returns `{ decision, reasons: [{ rule, detail, evidence?: [{ run_id, operation_id }] }], review_id? }`.

Rules, in order; the first `block` wins, any `review` makes the decision
`review`, otherwise `allow`. `reasons` always ends with a `history` line.

| #   | rule                                                    | fires when                                                                                                                                                                            |
| --- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | `denylist`                                              | the counterparty is on the denylist → **block**                                                                                                                                       |
| 2   | `allowlist`                                             | the allowlist is non-empty and the counterparty is not on it                                                                                                                          |
| 3   | `open_partial`                                          | more than `maxOpenPartialPerCounterparty` receipts with this counterparty are _partial with the money stage confirmed and the outcome stage not_ (evidence lists them)                |
| —   | `unknown_asset`, `unparseable_amount`, `asset_mismatch` | the amount cannot be interpreted in the policy's asset; no money math is attempted                                                                                                    |
| 4   | `per_counterparty_limit`                                | spend to this counterparty inside `windowHours` plus the amount exceeds `perCounterpartyLimit.amount`; `mixed_assets` / `unparseable_history` report history that could not be summed |
| 5   | `review_above`                                          | the amount exceeds `reviewAbove`                                                                                                                                                      |

"Open partial" is deliberately narrow: a partial receipt where no payment was
ever made (the agent saw a 402 and walked away) is not a risk to the
counterparty and does not count. A partial with a fault event does count when
the money stage closed, because from the buyer's side a reported failure and
a silently settled payment cannot be told apart.

Idempotency: the same `(counterparty, amount, asset)` within 10 minutes
returns the existing pending `review_id` instead of opening another review.

### `explain`

Same input as `get_receipt`. Returns `{ run_id, operation_id, template, completeness, explanation }`.
The paragraph is built by fixed rules from the receipt, so the same receipt
always yields the same text. It states what was observed and what was not,
including the buyer-side blind spot: a payment whose settlement response
never arrived and one that settled silently look identical from the buyer's
side, and the text says so rather than guessing.

### `get_review_status`

```jsonc
{ "review_id": "49185038-…" }
```

Returns `{ review_id, status, created_at, decided_at?, note?, counterparty, amount, asset, template?, reasons }`.

## Guard policy: `haia-guard.config.json`

```json
{
  "allowlist": [],
  "denylist": [],
  "perCounterpartyLimit": { "amount": "5.00", "windowHours": 24 },
  "maxOpenPartialPerCounterparty": 0,
  "reviewAbove": "1.00",
  "defaultAsset": "eip155:8453/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
  "assets": {
    "eip155:8453/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": {
      "symbol": "USDC",
      "decimals": 6
    }
  }
}
```

Every key is optional; the values above are the defaults (USDC on Base is
known without configuration, as is USDC on Base Sepolia). Unknown keys are
rejected. Amounts are decimal strings converted once, at load, to atomic
units of `defaultAsset`; the config is never re-read while the server runs,
so nothing in a tool call or an event payload can change the policy.
Allowlist and denylist entries are `0x…` addresses or service hosts.

The x402 payload carries the asset's contract address but not its decimals,
so any asset the guard should reason about must be in `assets`. An unknown
asset sends the payment to review rather than guessing a scale.

## Human review

```sh
haia-trace-mcp review list [--dir .trace]
haia-trace-mcp review approve <review_id> [--note "…"]
haia-trace-mcp review reject  <review_id> [--note "…"]
```

`.trace/guard/decisions.ndjson` is append-only: `check_before_pay` appends an
`opened` line, the CLI appends a `decided` line, and the state of a review is
the fold of its lines. Nothing is edited, so the file is also the audit trail.
A review can be decided once.

## Boundaries

- **Advisory only.** `check_before_pay` never moves funds, signs, or calls a
  payment API. It returns a decision; acting on it is the agent's job, and
  nothing stops an agent that ignores it. Enforcement belongs in the wallet or
  the payment path.
- **The agent cannot approve its own request.** There is no approve tool on
  the MCP surface, by design.
- **Read-only over `.trace/events`.** The only file this server writes is
  `.trace/guard/decisions.ndjson`.
- **No outbound network.** The Control Plane sink is not used; nothing is
  fetched, not even a JWKS. The optional HTTP transport only listens.

## Limits

- **Buyer-side blindness.** Receipts are built from the buyer's own events.
  An unsettled payment and a silently settled one produce the same partial
  receipt. The guard treats both as "possibly paid, outcome unconfirmed" and
  `explain` says so; neither can know more than the buyer observed.
- **x402 only, for money fields.** Counterparty, amount and asset are read
  from the trace-x402 payload (`requirements.pay_to`, `requirements.amount`,
  `requirements.asset` / `network`). The `escrow-arc` template is listed and
  explained but its receipts carry no x402 money fields, so `check_before_pay`
  cannot attribute them to a counterparty.
- **Stage roles are a table.** Which stage means "paid" and which "settled"
  is known for the four shipped templates (`x402-buyer`, `x402-seller`,
  `x402-facilitator`, `escrow-arc`). A custom template is judged coarsely:
  any partial counts as open, and the `history` reason says so.
- **Hard links** into `.trace/events` cannot be detected. Symlinks are refused.
- **Pre-1.0 upstream.** `@usehaia/*` contracts may change before 1.0; the
  version is pinned exactly and the parity test will fail if receipts drift.
- **Local-first.** No multi-user review queue. The HTTP transport below
  authenticates callers but does not authorize them differently: every valid
  token sees the same tools over the same Trace root.

## HTTP transport (optional)

Stdio is the default. For a host that reaches the server over the network,
`--http` serves Streamable HTTP as an OAuth 2.1 **resource server**, in the
direction Haia Rooms describes: PKCE-issued, resource-bound bearer tokens,
verified locally. This project does not ship an authorization server; it
trusts one the operator already runs.

```sh
node dist/cli.js --dir .trace \
  --http 127.0.0.1:3000 \
  --auth-issuer https://auth.example \
  --auth-jwks ./auth-example-jwks.json \
  --auth-scope trace:read
```

- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata
  naming the issuer, so a client knows where to get a token.
- `POST /mcp` requires `Authorization: Bearer <jwt>`. The token must be
  signed by a key in the JWKS (RS256 or ES256) or by the HS256 secret file
  (`--auth-hmac-secret`, for a local authorization server only), carry
  `iss` equal to `--auth-issuer`, `aud` equal to the resource URL
  (`http://host:port/mcp`, or `--public-url` behind TLS termination), an
  `exp`, and every `--auth-scope`. Failures answer `401`/`403` with a
  `WWW-Authenticate` challenge pointing at the metadata.
- Verification is dependency-free (`node:crypto`) and makes no network call;
  the JWKS is a file the operator provides.
- Binding is loopback-only unless `--http-allow-remote` is passed, and
  `--http-insecure-no-auth` (for the MCP Inspector) is refused off loopback.
  Host and Origin headers are validated against DNS rebinding.

## Security

- The Trace root is resolved to its real path once. Every run file, template
  and the review log must resolve inside it; symlinks are refused with
  `O_NOFOLLOW` and a real-path check. `--templates-dir` gets the same rules.
- `run_id` and `operation_id` are looked up in what exists, never joined onto
  a path. Template names must be bare slugs.
- Every event line is validated against the Event Contract envelope before
  the assembler sees it; every payload field is validated where it is read.
- Payloads are already redacted by the adapters (allowlisted fields, never
  signatures or keys). This server adds no fields and logs no payloads.
- stdout is the protocol channel under stdio; all diagnostics go to stderr,
  and Biome's `noConsole` rule enforces it.

## Development

```sh
pnpm install
pnpm build         # tsc → dist/
pnpm test          # vitest: ~190 tests, including live parity against haia-trace build
pnpm check-types   # tsc --noEmit over src and tests
pnpm lint          # biome
```

CI runs all four on Node 22. Commits follow Conventional Commits.

Pinned upstream: `@usehaia/trace-core` **0.0.10** (runtime),
`@usehaia/trace-cli` 0.0.10 (dev: parity oracle and template/fixture drift
check), `@usehaia/trace-x402` 0.0.10 and `@x402/core` 2.20.0 (dev: demo).
MCP: `@modelcontextprotocol/server` 2.4.0 and `@modelcontextprotocol/node` 2.1.2. The four bundled templates and the
four sample runs are verbatim copies from `@usehaia/trace-cli` 0.0.10
(`templates/SOURCE.md`, `test/fixtures/SOURCE.md`); a test fails if they
drift from the installed package.

## License

MIT © Muhammad Jon Raza. Haia Trace is MIT © Haia. This project is not
affiliated with Haia Finance.