From ffdafc2028323e9025eef2cfa86ed15aed967253 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 5 May 2026 07:01:32 -0700 Subject: [PATCH] v0.2: 8 chain_* read-only Koios passthrough MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_*. --- crates/aldabra-chain/src/koios.rs | 45 +++++++ crates/aldabra-mcp/src/tools.rs | 203 +++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/crates/aldabra-chain/src/koios.rs b/crates/aldabra-chain/src/koios.rs index 2157f00..80d00f7 100644 --- a/crates/aldabra-chain/src/koios.rs +++ b/crates/aldabra-chain/src/koios.rs @@ -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( + &self, + path: &str, + body: &T, + ) -> Result { + 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 { + 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 { diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 6d28eaf..d4c00db 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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, + /// Optional exact-match bech32 pool id filter. + #[serde(default)] + pub pool_id: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ChainPoolInfoArgs { + /// One or more bech32 pool ids to fetch detail for. + pub pool_ids: Vec, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ChainEpochArgs { + /// Specific epoch number; omit for the latest active epoch. + #[serde(default)] + pub epoch_no: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ChainAssetArgs { + /// 56-hex-char policy id. + pub policy_id_hex: String, + /// Hex of raw asset name bytes (0–64 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() }