Skip to main content
Glama

haia-trace-mcp

An MCP server that lets an AI agent read its own 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.

The demo, 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

Related MCP server: agenticrail-mcp

Quickstart (60 seconds)

Requires Node 22 and pnpm.

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.

To serve a real Trace directory to a host:

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

Claude Desktop (claude_desktop_config.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

{
  "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

{
  "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

{
  "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

{ "review_id": "49185038-…" }

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

Guard policy: haia-guard.config.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

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.

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

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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      C
      maintenance
      Provides tools to evaluate an AI agent's step before execution, returning signed ALLOW/DENY receipts, and to verify receipt chain integrity.
      Apache 2.0
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables AI agents to verify proposed payments against their assigned task, budgets, permitted categories, and counterparties, returning an ALLOW or DENY decision with a tamper-evident audit trail.
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables AI agents to inspect, check, and audit signed offline spending authorizations, and to issue hash-chained receipts for each action taken against them. Enforces per-transaction, total, expiry, merchant, and human-approval constraints, returning allow, needs_human, or deny with the specific failing constraint.
      Apache 2.0