Skip to main content
Glama
skoniog

hydra-ops-mcp

by skoniog

hydra-ops-mcp

Spin up a hydra-node, form a head with peers, and operate it — by talking to it. An MCP server that exposes the whole journey as tools an LLM client can call: generate keys, build the head's ledger parameters, exchange peer info with a counterparty, start your node, then drive the head — deposits, in-head transactions, decommit, close, fanout, selective partial fanout, deposit recovery — plus read-only views of head state and the L1, node logs, and on-chain error decoding.

Every operation that changes state describes what it would do and waits for your explicit confirmation before doing it.

v2 adds node provisioning, pluggable L1 access (Blockfrost / local cardano-cli / the devnet container), network configurability (devnet/preview/preprod/mainnet), and timing-safe lifecycle semantics for real contestation periods. The devnet remains the default and the zero-cost regression bed.


Contents


Why

Operating a head means holding several tools at once. The TUI shows you head state but not why a transaction was rejected. The WebSocket API gives you events but you're parsing JSON by hand. When something goes wrong the answer is usually in docker compose logs, correlated against head state, and decoded against error codes that live in the Plutus source.

This server puts all of that behind one conversational interface:

"The head won't fan out. What's wrong?"

Claude can check head state, pull the failing transaction from the node logs, decode the H39 abort code to FanoutUTxOHashMismatch, and tell you the two things that actually cause it — in one turn, because it has the head API, the container logs, and the error tables all in reach.

It's also useful for the routine parts: opening and funding a head, moving funds, and settling out, with each step explained and confirmed before it runs. And unlike a TUI session bound to a single node, every tool takes a node argument, so you can compare what alice, bob and carol each believe about the same head.

Currently targets the hydra demo devnet (three nodes, three parties). The API layer is not devnet-specific; the L1 helpers and key handling are (see Limitations).


Quick start

Prerequisites — Docker, Python 3.10+, and a checkout of cardano-scaling/hydra (for the demo devnet and the Plutus error tables).

git clone https://github.com/skoniog/hydra-ops-mcp && cd hydra-ops-mcp
python3 -m venv .venv                      # or: uv venv .venv
.venv/bin/pip install -r requirements.txt

./reset_devnet.sh                          # cardano-node + 3 hydra-nodes, seeded

Register the server with your MCP client. Claude Code:

claude mcp add hydra-ops -- /absolute/path/to/hydra-ops-mcp/.venv/bin/python \
    /absolute/path/to/hydra-ops-mcp/server.py

Claude Desktop — add to claude_desktop_config.json:

{
  "mcpServers": {
    "hydra-ops": {
      "command": "/absolute/path/to/hydra-ops-mcp/.venv/bin/python",
      "args": ["/absolute/path/to/hydra-ops-mcp/server.py"]
    }
  }
}

MCP launchers spawn the server with a stripped environment, so pass any overrides (HYDRA_DEMO_DIR, HYDRA_REPO) in an "env" block rather than exporting them in your shell.

Then ask:

"What state is the head in, and what does alice hold on L1?" "Open a head and commit alice's funds." "Send 5 ADA from alice to bob, then show me the head UTXO set."

New to operating a head this way? RUNBOOK.md walks the whole lifecycle — open, fund, transact, decommit, close, settle, and break it on purpose — as a series of guided sessions.


Architecture

  MCP client (Claude Code / Claude Desktop / anything speaking MCP)
        │  stdio
        ▼
  server.py                 FastMCP registration; thin wrappers only
        │
  tools/                    one module per domain, plain functions
   ├── observe.py           head state, UTXOs, L1 funds, params, events
   ├── lifecycle.py         init, commit, decommit, close, fanout, recover
   ├── transact.py          in-head transfers
   ├── diagnose.py          node logs, error-code decoding
   └── types.py             ok() / err() / needs_confirmation()
        │
        ├──▶ hydra_client.py    WebSocket + HTTP to hydra-node
        │                       async core, sync facade, event buffer
        ├──▶ tx_builder.py      PyCardano: build + sign in-head txs
        ├──▶ cardano.py         cardano-cli in the node container (L1)
        └──▶ errors.py          parses hydra-plutus for abort codes

hydra_client.py holds a WebSocket connection per node, running an async event loop on a daemon thread behind a synchronous facade — so tool functions stay simple while still awaiting protocol events. It buffers every server output for recent_events, tracks head status, and correlates confirmed transactions. Commands wait for their specific outcome event (DecommitDecommitFinalized, FanoutHeadIsFinalized) rather than returning optimistically, so a tool call that succeeds means the protocol step actually completed.

tx_builder.py builds and signs transactions with PyCardano — no cardano-cli round-trip per transaction. cardano.py handles the L1 side (address derivation, UTXO queries, signing and submitting deposit transactions) by exec'ing cardano-cli inside the running cardano-node container, which is also where the keys live.

errors.py parses HeadError.hs, DepositError.hs, HeadTokensError.hs and friends out of your local hydra checkout at call time, so decoded codes always match the version you're running rather than a table that drifts.


The confirmation model

Every tool that changes state takes confirm: bool = False. Called without it, the tool validates everything it can, resolves what it would actually do, and returns a description — having changed nothing:

{
  "status": "requires_confirmation",
  "action": "deposit alice's UTXO 4a3f…#0 (100,000,000,000 lovelace) into the head via node 1",
  "message": "This would deposit… Nothing has been done. Retry with confirm=True to execute.",
  "party": "alice", "utxo_ref": "4a3f…#0", "lovelace": 100000000000
}

In practice this means Claude proposes, you approve, and only then does anything happen on-chain. It matters most for the operations that are unilateral and irreversible: close_head affects every participant in the head, and fanout settles the head's final state.

The preview is resolved, not hypothetical — commit_funds names the exact UTXO it selected, decommit names the owner and amount it derived from the head's UTXO set, send_tx reports the transaction id it built. Validation runs before the gate, so you're never asked to confirm something that would have failed anyway. Read-only tools have no gate and run immediately.


Tool reference

All tools return {status, error, ...}; failures are {"status": "error", "error": "<message>", ...} rather than exceptions. Every tool accepts node: int = 1 (1 = alice, 2 = bob, 3 = carol) except l1_funds and explain_error.

Observability (read-only)

Tool

Signature

Returns

head_status

(node=1)

Head tag, WS-observed status, UTXO count, total lovelace, snapshot number, head version, contestation deadline

head_utxos

(node=1)

The head's UTxO set grouped by address, each with ref and value

l1_funds

(party="alice")

A party's L1 address, UTXO count, total lovelace, and per-UTXO values

protocol_parameters

(node=1)

The head's ledger parameters — full set plus a summary of the ones that bite (fees, min-UTXO, sizes)

pending_deposits

(node=1)

Deposits observed but not yet absorbed — the recovery candidates

recent_events

(node=1, tag=None, limit=25)

Server outputs seen on this connection, optionally filtered by tag

recent_events covers events since the server connected — the WS connection requests no history, so it's a live tail rather than the full log. For anything older, use node_logs.

Lifecycle (confirmation-gated)

Tool

Signature

Notes

init_head

(node=1, confirm=False)

Refuses unless the head is Idle. On 2.3.0 the head opens immediately and empty; funds follow via deposits

commit_funds

(party="alice", node=1, utxo_ref="", confirm=False)

Drafts the deposit via POST /commit, signs with the party's funds key, submits to L1, then waits for absorption. Deposits one UTXO — the largest unless utxo_ref names another

decommit

(utxo_ref, node=1, confirm=False)

Withdraws one head UTxO to L1 with the head still open. Derives the owner from the UTxO's address and builds a full-value self-transfer as the decommit tx

close_head

(node=1, confirm=False)

Posts the latest confirmed snapshot and starts the contestation period. Affects all participants

fanout

(node=1, confirm=False)

Waits for ReadyToFanout if needed, then distributes the whole UTxO set to L1

partial_fanout

(utxo_refs, node=1, confirm=False)

Settles a chosen subset; reports what was distributed and what remains. See Limitations — needs a node newer than 2.3.0

recover_deposit

(tx_id, node=1, confirm=False)

DELETE /commits/{txid} — returns a stuck deposit to L1

commit_funds deliberately deposits a single UTXO per call: multi-UTXO deposits are what wedge fanout with H39 on 2.3.0 (see Operational notes).

Transactions (confirmation-gated)

Tool

Signature

Notes

send_tx

(sender, receiver, amount_lovelace, node=1, confirm=False)

In-head transfer. sender is a party whose signing key is available; receiver is a party name or a bech32 address

Amounts below 1 ADA are refused. The head zeroes min-UTXO, so such an output is valid on L2 and then impossible to recreate on L1 — it would wedge fanout permanently. The transaction is rebuilt against the current UTxO set at confirmation time, so a preview that sat around doesn't spend stale inputs. The call returns once the transaction appears in a confirmed snapshot, not merely when it's accepted.

Diagnosis (read-only)

Tool

Signature

Notes

node_logs

(node=1, pattern="", since="10m", limit=40)

Container logs, optionally regex-filtered. Returns how many lines matched and the last limit of them

explain_error

(code)

Decodes an abort code (H39, D01, …) to its constructor and module from your local hydra checkout, with practical notes on the ones that actually come up


Compared with hydra-tui

The tool surface deliberately matches what hydra-tui exposes, so anything you can do in the TUI you can do here:

hydra-tui

here

i — init

init_head

commit dialog

commit_funds (drafts, signs, submits, waits for absorption)

n — new transaction

send_tx

d — decommit

decommit

c — close

close_head

f — fanout

fanout

p — partial fanout

partial_fanout

r — recover deposit

recover_deposit

main tab

head_status, head_utxos

funds tab

l1_funds

event history tab

recent_events

protocol_parameters, pending_deposits

Like the TUI, this doesn't expose Contest, SafeClose or SideLoadSnapshot. Those are protocol responses to specific on-chain conditions where one action is correct and timing is load-bearing; they belong in deterministic tooling with alerting, not behind a prompt.

Where this goes further:

  • Diagnosis. node_logs and explain_error have no TUI equivalent. This is the biggest practical gain — a wedged head goes from "the TUI says it failed" to a decoded abort code and the matching log lines.

  • Cross-node. A TUI session attaches to one node. Here every tool takes node, so you can ask what alice, bob and carol each believe about the same head — the fastest way to spot a node that has fallen behind.

  • L1 and L2 together. l1_funds queries the chain directly, so "did that decommit actually land?" is one question rather than a context switch to cardano-cli.

  • Guardrails. Sub-min-UTXO outputs and multi-UTXO deposits are refused by construction, because both silently wedge fanout later.

  • Composition. Multi-step operations happen in one request: "close the head, wait out contestation, fan out, and show me everyone's final L1 balances" is a single ask.

Where the TUI still wins: it's a live dashboard. MCP is request/response, so you get snapshots rather than a continuously updating view — for watching a head over time, keep the TUI open. Keystrokes also beat a model round-trip for repetitive work, and the TUI's UTxO pickers are visual where here you list then select.


Configuration

Everything is in config.py, with environment overrides:

Setting

Default

Meaning

NODES

4001, 4002, 4003 on localhost

Node index → WS/HTTP endpoints and party name

HYDRA_DEMO_DIR

/home/dev/claudecode/hydra/demo

Demo devnet: docker compose project and credentials

HYDRA_REPO

/home/dev/claudecode/hydra

Hydra checkout, for decoding abort codes

NETWORK_MAGIC

42

Devnet magic

MIN_OUTPUT_LOVELACE

1_000_000

Refusal threshold for in-head outputs

Signing keys are the demo's {alice,bob,carol}-funds pairs. Container-side paths are used for cardano-cli (signing and submitting on L1); host-side copies of the same keys are read by PyCardano for in-head transactions. Pointing at a different deployment with the same layout is a config change; pointing at a different topology is not (see Limitations).


Testing

.venv/bin/python test_ops.py           # offline — no devnet needed
.venv/bin/python test_ops_devnet.py    # live — needs a devnet with the head Idle

test_ops.py asserts that every state-changing tool returns requires_confirmation and reaches no client without confirm=True (the stub client raises if a command escapes the gate), that request payloads match the API, that the min-UTXO refusal and UTxO validation fire, that the error table parses and decodes, and that all 16 tools register with the server.

test_ops_devnet.py drives a real head through the whole lifecycle and asserts observability at each stage: gate check → initcommit → six read tools → two in-head payments → decommit, verified by the funds appearing on L1 while the head stays openclosefanout → back to Idle → logs and error decoding. It skips with a clear message if the devnet isn't up or the head isn't Idle.


Operational notes

Things worth knowing before they cost you a head.

H39 / FanoutUTxOHashMismatch wedges a head permanently. Fanout can't reproduce what the closed head committed to, so the head cannot settle and its funds are stuck. Two causes, both preventable and both guarded against here: multi-UTXO deposits on 2.3.0, and any head output below the L1 min-UTXO. Ask explain_error("H39") for the details.

The head zeroes min-UTXO; L1 does not. A 0.5 ADA output transacts happily on L2 and then cannot be recreated on L1. send_tx refuses below 1 ADA for this reason.

Deposits are absorbed after a deposit period, not instantly. commit_funds waits and reports if absorption doesn't happen; a deposit that never lands shows up in pending_deposits and comes back with recover_deposit.

Close is unilateral and affects everyone. Any participant can close, and the whole head must then settle. The gate exists mostly for this.

A head needs every participant online. If a payment hangs, check docker compose ps before suspecting the tooling.

The demo devnet's block producer can stall after long idle periods — cardano-cli query tip returns the same slot twice and everything hangs. ./reset_devnet.sh fixes it; the devnet is disposable by design.

Unparseable WebSocket input returns no tag. A command a node doesn't recognize comes back as a bare {"input", "reason"} object rather than a tagged event — worth knowing if you script against the API directly, since a client waiting on tagged events will hang. The client here handles it.


Provisioning a node (v2)

The path from nothing to a running participant, all as tool calls:

generate_keys("alice")            fuel + funds Cardano pairs, Hydra keys
   └─ fund the fuel address       ~30 ada — the one step no tool can do
build_protocol_parameters()       live network params, only fees zeroed
share_peer_info("alice", host)    → send to your counterparty
node_plan(...peers...)            preview the exact container command
start_node(...)                   run it; node_health() confirms peered

Nodes run as containers of HYDRA_NODE_IMAGE (default: the 2.3.0 release) on a shared docker network, so same-host nodes reach each other by container name; remote peers use published ports and a reachable advertise_host. Verified live: two parties provisioned from scratch on the devnet with zero funds, mutually peered (hydra_head_peers_connected=1), and — funded from the devnet faucet — a full head lifecycle through selective partial fanout on a master-built (unstable) node.

Select the axes with environment variables: HYDRA_OPS_NETWORK (devnet/preview/preprod/mainnet), HYDRA_OPS_PROVIDER (docker/cli/blockfrost), HYDRA_OPS_WORKSPACE (keys, configs, persistence), HYDRA_NODE_IMAGE, HYDRA_SCRIPTS_TX_ID (overrides published script ids — required for unstable builds, whose validators differ from every release).

Limitations

partial_fanout needs a node newer than 2.3.0 — the command postdates the latest release (hydra PR #2750). Against release images the tool reports the version gap precisely; against HYDRA_NODE_IMAGE=…:unstable the full selective-drain flow is verified working (see Provisioning above), which also requires publishing that build's own scripts.

Verified on preview, end to end, over Blockfrost — no cardano-node anywhere: two parties provisioned from scratch, faucet-funded, both nodes on the --blockfrost backend, then init → 10,000 tADA deposit → in-head payments (0.2 s finality) → decommit (funds verified back on L1 with the head open) → close (~3 min, the automatic close-retry fired and recovered a dropped Close) → 60 s contestation → fanout (~3 min) → final L1 balances exact to the lovelace.

Real-network operating notes learned from that run:

  • --unsynced-period defaults to CP/2, which on a short testnet CP (60 s) sits below preview's real inter-block gaps — the node then randomly rejects inputs with RejectedInputBecauseUnsynced. Set HYDRA_OPS_UNSYNCED_PERIOD (e.g. 600) for short-CP testing; on proper CPs the default is fine.

  • A 60 s deposit period is too tight on preview — the 3×DP deadline expires before absorption can settle and the deposit lapses (DepositExpired; recover_deposit gets the funds back, verified live). DP=180 absorbed reliably in ~6 min.

  • Right after absorption there is a brief settling window where spending the deposited UTXO is rejected with "all inputs are spent"; retry moments later succeeds.

  • L1-touching operations take minutes, not seconds: budget ~2–4 min each for close, decommit settlement, and fanout at preview block times.

Devnet keys for alice/bob/carol come from the demo directory; provisioned parties keep their keys in the workspace, chmod 600, and tools never return secret material. There is still no authentication on the MCP surface itself.

ADA only. Transaction building handles pure-lovelace UTXOs — no native tokens, scripts, datums, or minting.

Hard-won signing rule: a node-built draft (deposits) must never be re-serialized before witnessing — neither PyCardano nor cbor2 round-trips them byte-exactly, and a re-encoded body means the signature is over the wrong hash (InvalidWitnessesUTXOW). tx_builder.sign_envelope locates the body's exact byte span with a CBOR scanner and signs that.

Master's deposit timing differs: unstable builds add --deposit-activation (default 3600s) governing when a deposit becomes absorbable, independent of --deposit-period. Without setting it, deposits sit inactive for an hour. node_plan passes it automatically when the image supports it.

recover_deposit is untested against a genuinely stuck deposit. It follows the API, but the demo devnet absorbs deposits too reliably to produce one on demand.

No auth. Anyone who can reach the server can operate the head. That's appropriate for a local operator tool and would not be for anything exposed.


Extending

Adding a tool: write a plain function in the relevant tools/ module returning ok() / err() / needs_confirmation(), then register a thin wrapper in server.py. Tool modules don't import FastMCP, so they're directly callable from tests — which is how both suites drive them.

Adding a protocol command: add a method to HydraClient using _command_and_wait(command, ok_tags), which sends and waits for the outcome event while treating both CommandFailed and untagged parse rejections as errors.

Targeting another deployment: point NODES at the endpoints and HYDRA_DEMO_DIR / HYDRA_REPO at the right paths. Anything beyond the demo's three-party layout means revisiting key handling in cardano.py and tx_builder.py, and fees.

Further reading

The Hydra documentation for the protocol itself, and RUNBOOK.md for the guided tour of operating a head with these tools.