v0.2: 8 chain_* read-only Koios passthrough MCP tools

Adds a parallel read-only API surface alongside wallet_*:

  chain_tx_info        full Koios tx_info (any hash)
  chain_address_info   balance + utxos at any address
  chain_pool_list      filter by ticker / pool_id_bech32
  chain_pool_info      detail per pool (delegators, blocks)
  chain_epoch_params   protocol params for an epoch
  chain_asset_info     supply, holders, mint history
  chain_account_info   stake address state
  chain_tip            current chain tip

All passthrough — Koios JSON returned verbatim, no re-shaping.
Network-aware via existing ALDABRA_KOIOS_BASE; mainnet vs preprod
just changes the URL. No keys touched, no signing path. Saves
the bash-curl friction Cobb flagged 2026-05-05 mid-mainnet
testing arc.

Wire-up: KoiosClient gets `post_raw_json` + `get_raw_json`
helpers that return raw response strings instead of decoding
into typed structures. The chain_* tools are thin wrappers
around those.

ServerInfo `instructions` updated to advertise the chain_*
surface alongside wallet_*.
This commit is contained in:
Kayos 2026-05-05 07:01:32 -07:00
parent 1ee124b545
commit ffdafc2028
2 changed files with 247 additions and 1 deletions

View file

@ -145,6 +145,51 @@ impl KoiosClient {
.await
.map_err(|e| ChainError::Decode(e.to_string()))
}
/// Generic POST that returns the raw JSON response as a `String` —
/// for the `chain_*` MCP passthrough tools where we don't want to
/// re-shape Koios's response into typed Rust structures. Caller
/// passes a serializable body (often `serde_json::json!({...})`)
/// and gets back the response body verbatim.
pub async fn post_raw_json<T: Serialize>(
&self,
path: &str,
body: &T,
) -> Result<String, ChainError> {
self.http
.post(self.url(path))
.json(body)
.send()
.await
.map_err(|e| ChainError::Network(e.to_string()))?
.error_for_status()
.map_err(|e| ChainError::Network(e.to_string()))?
.text()
.await
.map_err(|e| ChainError::Decode(e.to_string()))
}
/// Generic GET (with optional query string) that returns the raw
/// JSON response as a `String`. Used for Koios endpoints that
/// take filters as query params (`pool_list?ticker=eq.AHL`,
/// `epoch_params`, `tip`, etc.).
pub async fn get_raw_json(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<String, ChainError> {
self.http
.get(self.url(path))
.query(query)
.send()
.await
.map_err(|e| ChainError::Network(e.to_string()))?
.error_for_status()
.map_err(|e| ChainError::Network(e.to_string()))?
.text()
.await
.map_err(|e| ChainError::Decode(e.to_string()))
}
}
fn parse_u64(s: &str, field: &str) -> Result<u64, ChainError> {

View file

@ -143,6 +143,56 @@ pub struct TxStatusArgs {
pub tx_hash: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainTxInfoArgs {
/// Hex-encoded transaction hash to look up.
pub tx_hash: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainAddressArgs {
/// Bech32 address (any network).
pub address: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainPoolListArgs {
/// Optional exact-match ticker filter (e.g. "AHL", "BLOOM", "1PCT").
#[serde(default)]
pub ticker: Option<String>,
/// Optional exact-match bech32 pool id filter.
#[serde(default)]
pub pool_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainPoolInfoArgs {
/// One or more bech32 pool ids to fetch detail for.
pub pool_ids: Vec<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainEpochArgs {
/// Specific epoch number; omit for the latest active epoch.
#[serde(default)]
pub epoch_no: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainAssetArgs {
/// 56-hex-char policy id.
pub policy_id_hex: String,
/// Hex of raw asset name bytes (064 chars). Empty string for
/// policy-only / no-asset-name native assets.
pub asset_name_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainAccountArgs {
/// Bech32 stake address (`stake1...` mainnet or `stake_test1...`).
pub stake_address: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct UnsignedSendArgs {
/// Recipient bech32 address.
@ -1062,6 +1112,157 @@ impl WalletService {
}
Ok(hex)
}
// ===========================================================
// chain_* — read-only Koios passthroughs.
//
// No signing, no key access. Same network as the configured
// wallet (mainnet/preprod/preview routed via ALDABRA_KOIOS_BASE).
// Returns Koios JSON verbatim — caller parses what they need.
//
// Saves the bash-curl friction when looking at addresses/txs/
// pools/etc that aren't this wallet specifically.
// ===========================================================
#[tool(
name = "chain_tx_info",
description = "Full Koios `tx_info` for a transaction hash — inputs, outputs, certs, mint, metadata, plutus_contracts, withdrawals, etc. Heavy response (multi-MB possible on complex txs). Args: tx_hash (hex). Returns the raw Koios JSON."
)]
async fn chain_tx_info(
&self,
#[tool(aggr)] ChainTxInfoArgs { tx_hash }: ChainTxInfoArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_tx_hashes": [tx_hash]});
self.inner
.chain
.post_raw_json("tx_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_address_info",
description = "Full Koios `address_info` for any address — balance + utxo set + stake address. Use this to look up addresses other than this wallet (this wallet has the dedicated wallet_balance / wallet_utxos)."
)]
async fn chain_address_info(
&self,
#[tool(aggr)] ChainAddressArgs { address }: ChainAddressArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_addresses": [address]});
self.inner
.chain
.post_raw_json("address_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_pool_list",
description = "Filter the Koios pool list. Args: ticker (optional, exact match — e.g. \"AHL\"), pool_id (optional bech32). Returns array of matching pools with margin/pledge/relay info. Empty filter set returns all active pools (large)."
)]
async fn chain_pool_list(
&self,
#[tool(aggr)] ChainPoolListArgs { ticker, pool_id }: ChainPoolListArgs,
) -> Result<String, String> {
let mut q: Vec<(String, String)> = Vec::new();
if let Some(t) = ticker {
q.push(("ticker".into(), format!("eq.{t}")));
}
if let Some(p) = pool_id {
q.push(("pool_id_bech32".into(), format!("eq.{p}")));
}
let q_refs: Vec<(&str, &str)> = q.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
self.inner
.chain
.get_raw_json("pool_list", &q_refs)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_pool_info",
description = "Detailed Koios `pool_info` for one or more pools. Args: pool_ids (array of bech32 pool ids). Returns delegators, live stake, recent blocks, metadata."
)]
async fn chain_pool_info(
&self,
#[tool(aggr)] ChainPoolInfoArgs { pool_ids }: ChainPoolInfoArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_pool_bech32_ids": pool_ids});
self.inner
.chain
.post_raw_json("pool_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_epoch_params",
description = "Koios `epoch_params` for the current (or specified) epoch — protocol params: cost models, fees a/b, deposits, ex_units prices, max tx size, etc. Args: epoch_no (optional u64; latest if omitted)."
)]
async fn chain_epoch_params(
&self,
#[tool(aggr)] ChainEpochArgs { epoch_no }: ChainEpochArgs,
) -> Result<String, String> {
let mut q: Vec<(String, String)> = Vec::new();
if let Some(e) = epoch_no {
q.push(("_epoch_no".into(), e.to_string()));
}
let q_refs: Vec<(&str, &str)> = q.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
self.inner
.chain
.get_raw_json("epoch_params", &q_refs)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_asset_info",
description = "Koios `asset_info` for a native asset. Args: policy_id_hex (56 hex chars), asset_name_hex (hex of raw asset-name bytes). Returns supply, holders, mint history, metadata."
)]
async fn chain_asset_info(
&self,
#[tool(aggr)] ChainAssetArgs {
policy_id_hex,
asset_name_hex,
}: ChainAssetArgs,
) -> Result<String, String> {
let body = serde_json::json!({
"_asset_list": [[policy_id_hex, asset_name_hex]]
});
self.inner
.chain
.post_raw_json("asset_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_account_info",
description = "Koios `account_info` for a stake address. Args: stake_address (bech32 stake1...). Returns registered/active state, delegated_pool, rewards balance, total stake."
)]
async fn chain_account_info(
&self,
#[tool(aggr)] ChainAccountArgs { stake_address }: ChainAccountArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_stake_addresses": [stake_address]});
self.inner
.chain
.post_raw_json("account_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_tip",
description = "Koios `tip` — current chain tip: block height, slot, epoch, hash, timestamp. Useful for absolute-time anchoring + stale-data detection."
)]
async fn chain_tip(&self) -> Result<String, String> {
self.inner
.chain
.get_raw_json("tip", &[])
.await
.map_err(|e| format!("koios: {e}"))
}
}
#[tool(tool_box)]
@ -1069,7 +1270,7 @@ impl ServerHandler for WalletService {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"aldabra — Cardano lite wallet over MCP. Phase 1 (read): wallet_address, wallet_network, wallet_balance, wallet_utxos. Phase 2 (send): wallet_send (with native-asset bundle), wallet_send_unsigned + wallet_submit_signed_tx + wallet_sign_partial (cold-sign + multi-sig), wallet_tx_status. Phase 3 (mint): wallet_policy_create, wallet_mint (with CIP-25 metadata), wallet_mint_cip68_nft (ref + user NFT pair w/ inline datum). Plutus + stake delegation land in Phase 4.".into(),
"aldabra — Cardano lite wallet over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip) — for inspecting the chain at addresses/txs/pools beyond this wallet.".into(),
),
..Default::default()
}