feat(dao): wire 8 dao_* MCP tools (Phase 1)

Tools added to WalletService:

DAO management (filesystem-only, no chain calls):
- dao_register      — save a DaoConfig under \$ALDABRA_DATA/daos/<name>.json
- dao_list          — show all registered DAO names + active marker
- dao_use           — set active DAO; subsequent dao_* calls without
                      explicit `dao` arg target this one
- dao_remove        — delete config; clears active if it was the active one
- dao_show          — render full DaoConfig JSON for audit

DAO live-state reads (Koios-backed, decoded into typed Rust):
- dao_governor_state  — singleton governor UTxO + thresholds + timing
                        + nextProposalId + per-stake proposal cap
- dao_stake_list      — all stakes for the DAO (filtered to gov-token
                        policy so the shared MLabs stakes addr doesn't
                        leak other DAOs into output). Renders pkh,
                        amount, locks, delegation per stake.
- dao_my_stake        — filters dao_stake_list to just THIS wallet's
                        stake (matches wallet pkh against StakeDatum.owner).
                        Empty array if not staked yet.

Plumbing:
- WalletService::new gains data_dir param (for DaoStore root)
- WalletInner gains dao_store + dao_reader fields
- wallet_pkh() helper extracts the wallet's payment-credential hash from
  bech32 for owner-match in dao_my_stake
- get_info() instructions advertise the new dao_* surface
- aldabra-mcp/Cargo.toml: aldabra-dao path dep + hex + pallas-addresses
This commit is contained in:
Kayos 2026-05-05 13:51:04 -07:00
parent 14902f4e01
commit 5fb616c6c5
3 changed files with 344 additions and 2 deletions

View file

@ -20,6 +20,15 @@ path = "src/main.rs"
[dependencies]
aldabra-core = { path = "../aldabra-core" }
aldabra-chain = { path = "../aldabra-chain" }
aldabra-dao = { path = "../aldabra-dao" }
# Used directly in tools.rs to decode the wallet's bech32 address into a
# payment-credential hash (so `dao_my_stake` can match against StakeDatum.owner).
# Comes in transitively via aldabra-core too; declared here for clarity.
pallas-addresses = { workspace = true }
# `hex::encode` for rendering pkh/script-hash bytes in dao_* JSON output.
hex = "0.4"
tokio = { workspace = true }
anyhow = { workspace = true }

View file

@ -134,6 +134,7 @@ async fn run() -> Result<()> {
payment_key,
stake_key,
cfg.max_send_lovelace,
cfg.data_dir.clone(),
);
let server = service
.serve(stdio())

View file

@ -25,9 +25,13 @@
//! - `Result<String, String>` lets us surface chain / build errors
//! as MCP tool-call errors instead of crashing the daemon.
use std::path::PathBuf;
use std::sync::Arc;
use aldabra_chain::{ChainBackend, KoiosClient};
use aldabra_dao::agora::stake::Credential as DaoCredential;
use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore};
use aldabra_dao::reader::{DaoReader, KoiosDaoReader};
use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD;
use aldabra_core::{
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
@ -76,6 +80,12 @@ struct WalletInner {
payment_key: PaymentKey,
stake_key: StakeKey,
max_send_lovelace: u64,
/// Per-DAO config store rooted at `<data_dir>/daos/`. See
/// `aldabra_dao::config::DaoStore` — load/save/active selector.
dao_store: DaoStore,
/// Reader-only Koios client for DAO-shape queries. Reuses the
/// koios_base; separate from `chain` so the trait surface stays clean.
dao_reader: KoiosDaoReader,
}
impl WalletService {
@ -86,19 +96,40 @@ impl WalletService {
payment_key: PaymentKey,
stake_key: StakeKey,
max_send_lovelace: u64,
data_dir: PathBuf,
) -> Self {
Self {
inner: Arc::new(WalletInner {
network,
address,
chain: KoiosClient::new(koios_base),
chain: KoiosClient::new(koios_base.clone()),
payment_key,
stake_key,
max_send_lovelace,
dao_store: DaoStore::new(&data_dir),
dao_reader: KoiosDaoReader::new(koios_base),
}),
}
}
/// Extract the wallet's payment-credential hash from the bech32
/// address. Used by `dao_my_stake` to match against
/// `StakeDatum.owner`. Returns the 28-byte pkh.
fn wallet_pkh(&self) -> Result<Vec<u8>, String> {
use pallas_addresses::{Address, ShelleyPaymentPart};
let addr = Address::from_bech32(&self.inner.address)
.map_err(|e| format!("address parse: {e}"))?;
match addr {
Address::Shelley(s) => match s.payment() {
ShelleyPaymentPart::Key(h) => Ok(h.as_ref().to_vec()),
ShelleyPaymentPart::Script(_) => {
Err("wallet address is script-credentialed; can't be a stake owner".into())
}
},
_ => Err("wallet address is not Shelley-era; unsupported".into()),
}
}
/// Reject if `lovelace` exceeds the wallet's hard cap unless
/// `force=true`. Used by every tool that moves lovelace to a
/// non-wallet destination — wallet_send, wallet_mint,
@ -1266,6 +1297,307 @@ impl WalletService {
.await
.map_err(|e| format!("koios: {e}"))
}
// ─── DAO management — `$ALDABRA_DATA/daos/` config files ─────────────────
//
// These are filesystem-only — no chain calls. Cheap to invoke; users can
// register Bob's DAO + Alice's DAO + Sulkta in one session and switch
// between them with `dao_use`.
#[tool(
name = "dao_register",
description = "Register a DAO config (Sulkta, Bob's, etc) at $ALDABRA_DATA/daos/<name>.json. First-registered DAO becomes active automatically. Args: name (lowercase letters/digits/underscore/dash), governor_addr, stakes_addr, treasury_addr (all bech32), gov_token_policy (56 hex chars), gov_token_name_hex (hex of asset name), initial_spend (txhash#index, the Agora bootstrap tx ref), max_cosigners (u32), treasury_ref_config (56 hex chars). Optional: description, network (mainnet/preprod/preview, default mainnet)."
)]
async fn dao_register(
&self,
#[tool(aggr)] DaoRegisterArgs {
name,
description,
governor_addr,
stakes_addr,
treasury_addr,
gov_token_policy,
gov_token_name_hex,
initial_spend,
max_cosigners,
treasury_ref_config,
network,
}: DaoRegisterArgs,
) -> Result<String, String> {
let cfg = DaoConfig {
name: name.clone(),
description,
governor_addr,
stakes_addr,
treasury_addr,
gov_token_policy,
gov_token_name_hex,
initial_spend,
max_cosigners,
treasury_ref_config,
network: match network.as_deref() {
Some("preprod") => DaoNetwork::Preprod,
Some("preview") => DaoNetwork::Preview,
_ => DaoNetwork::Mainnet,
},
};
self.inner
.dao_store
.register(&cfg)
.map_err(|e| e.to_string())?;
Ok(format!("registered DAO {name:?}"))
}
#[tool(
name = "dao_list",
description = "List all registered DAO config names (sorted) plus the currently active one. Returns JSON {active: \"<name>\"|null, all: [...]}."
)]
async fn dao_list(&self) -> Result<String, String> {
let all = self
.inner
.dao_store
.list()
.map_err(|e| e.to_string())?;
let active = self.inner.dao_store.get_active().ok().map(|a| a.name().to_string());
Ok(serde_json::json!({ "active": active, "all": all }).to_string())
}
#[tool(
name = "dao_use",
description = "Set the active DAO. Subsequent dao_* calls without an explicit `dao` arg target this one. Must already be registered. Args: name (string)."
)]
async fn dao_use(
&self,
#[tool(aggr)] DaoUseArgs { name }: DaoUseArgs,
) -> Result<String, String> {
self.inner
.dao_store
.set_active(&name)
.map_err(|e| e.to_string())?;
Ok(format!("active DAO is now {name:?}"))
}
#[tool(
name = "dao_remove",
description = "Delete a registered DAO config. If it was the active DAO, clears active. Doesn't touch chain — the DAO continues to exist on Cardano. Args: name (string)."
)]
async fn dao_remove(
&self,
#[tool(aggr)] DaoUseArgs { name }: DaoUseArgs,
) -> Result<String, String> {
self.inner
.dao_store
.remove(&name)
.map_err(|e| e.to_string())?;
Ok(format!("removed DAO {name:?}"))
}
#[tool(
name = "dao_show",
description = "Return the full DaoConfig for a named DAO (or the active one if `dao` is omitted). Returns JSON of every config field — useful for audit + seeing what's wired up. Args: dao (optional string)."
)]
async fn dao_show(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
serde_json::to_string(&cfg).map_err(|e| format!("serialize: {e}"))
}
// ─── DAO live-state reads ────────────────────────────────────────────────
#[tool(
name = "dao_governor_state",
description = "Read the live GovernorDatum for a DAO. Returns the singleton governor UTxO ref + decoded thresholds (execute/create/toVoting/vote/cosign GT amounts), nextProposalId, timing config (draft/voting/locking/executing periods in ms), and the per-stake proposal-creation cap. Args: dao (optional — defaults to active)."
)]
async fn dao_governor_state(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let (utxo_ref, datum) = self
.inner
.dao_reader
.get_governor(&cfg)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"dao": cfg.name,
"governor_utxo": utxo_ref,
"next_proposal_id": datum.next_proposal_id,
"thresholds": {
"execute": datum.proposal_thresholds.execute,
"create": datum.proposal_thresholds.create,
"to_voting": datum.proposal_thresholds.to_voting,
"vote": datum.proposal_thresholds.vote,
"cosign": datum.proposal_thresholds.cosign,
},
"timing_ms": {
"draft": datum.proposal_timings.draft_time,
"voting": datum.proposal_timings.voting_time,
"locking": datum.proposal_timings.locking_time,
"executing": datum.proposal_timings.executing_time,
"min_stake_voting": datum.proposal_timings.min_stake_voting_time,
"voting_time_range_max_width": datum.proposal_timings.voting_time_range_max_width,
},
"create_proposal_time_range_max_width_ms": datum.create_proposal_time_range_max_width,
"max_proposals_per_stake": datum.maximum_created_proposals_per_stake,
})
.to_string())
}
#[tool(
name = "dao_stake_list",
description = "List all live stakes for a DAO (filtered by gov-token policy — the shared MLabs stakes addr serves many DAOs). Returns JSON array of {utxo_ref, owner_pkh_hex, owner_kind (\"PubKey\"|\"Script\"), staked_amount, gov_token_quantity, lovelace, delegated_to_pkh_hex, locked_by: [...]}. Args: dao (optional — defaults to active)."
)]
async fn dao_stake_list(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let stakes = self
.inner
.dao_reader
.list_stakes(&cfg)
.await
.map_err(|e| e.to_string())?;
let arr: Vec<serde_json::Value> = stakes
.into_iter()
.map(|s| stake_utxo_to_json(&s))
.collect();
Ok(serde_json::json!({ "dao": cfg.name, "stakes": arr }).to_string())
}
#[tool(
name = "dao_my_stake",
description = "Filter dao_stake_list to just the stake owned by THIS wallet (by matching the wallet's payment-credential hash against StakeDatum.owner). Returns the same shape as dao_stake_list but with at most one entry. If the wallet has no stake yet, returns an empty stakes array. Args: dao (optional — defaults to active)."
)]
async fn dao_my_stake(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let pkh = self.wallet_pkh()?;
let stakes = self
.inner
.dao_reader
.list_stakes(&cfg)
.await
.map_err(|e| e.to_string())?;
let mine: Vec<serde_json::Value> = stakes
.into_iter()
.filter(|s| match &s.datum.owner {
DaoCredential::PubKey(h) => h == &pkh,
DaoCredential::Script(_) => false,
})
.map(|s| stake_utxo_to_json(&s))
.collect();
Ok(serde_json::json!({
"dao": cfg.name,
"wallet_pkh": hex::encode(&pkh),
"stakes": mine,
})
.to_string())
}
}
// ─── DAO arg structs ────────────────────────────────────────────────────────
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoRegisterArgs {
/// Lowercase letters, digits, `_`, `-`. Becomes the filename.
pub name: String,
/// Free-form description for humans.
#[serde(default)]
pub description: Option<String>,
pub governor_addr: String,
pub stakes_addr: String,
pub treasury_addr: String,
/// 56 hex chars (28 bytes).
pub gov_token_policy: String,
/// Hex-encoded asset name (e.g. "546572726170696e" for "Terrapin").
pub gov_token_name_hex: String,
/// `txhash#index` — the Agora bootstrap tx ref that identifies the DAO.
pub initial_spend: String,
pub max_cosigners: u32,
/// 56 hex chars (28 bytes).
pub treasury_ref_config: String,
/// "mainnet" | "preprod" | "preview". Default mainnet.
#[serde(default)]
pub network: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoUseArgs {
pub name: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoShowArgs {
/// Named DAO. Falls through to the active one if omitted.
#[serde(default)]
pub dao: Option<String>,
}
/// Render a [`aldabra_dao::reader::StakeUtxo`] as a JSON object for tool output.
///
/// Formatted as a free function rather than `impl Serialize for StakeUtxo` to
/// keep the dao crate's wire shape decoupled from the MCP tool surface — a
/// future Phase 4 may add fields to StakeUtxo that we don't want to expose.
fn stake_utxo_to_json(s: &aldabra_dao::reader::StakeUtxo) -> serde_json::Value {
use aldabra_dao::agora::stake::ProposalAction;
let (owner_kind, owner_pkh) = match &s.datum.owner {
DaoCredential::PubKey(h) => ("PubKey", hex::encode(h)),
DaoCredential::Script(h) => ("Script", hex::encode(h)),
};
let delegated = s.datum.delegated_to.as_ref().map(|c| match c {
DaoCredential::PubKey(h) => serde_json::json!({"kind":"PubKey","hex": hex::encode(h)}),
DaoCredential::Script(h) => serde_json::json!({"kind":"Script","hex": hex::encode(h)}),
});
let locks: Vec<serde_json::Value> = s
.datum
.locked_by
.iter()
.map(|l| {
let action = match &l.action {
ProposalAction::Created => serde_json::json!({"kind":"Created"}),
ProposalAction::Voted { result_tag, posix_time } => serde_json::json!({
"kind":"Voted","result_tag": result_tag, "posix_time_ms": posix_time,
}),
ProposalAction::Cosigned => serde_json::json!({"kind":"Cosigned"}),
};
serde_json::json!({
"proposal_id": l.proposal_id,
"action": action,
})
})
.collect();
serde_json::json!({
"utxo_ref": s.utxo_ref,
"owner_kind": owner_kind,
"owner_pkh_hex": owner_pkh,
"staked_amount": s.datum.staked_amount,
"gov_token_quantity": s.gov_token_quantity,
"lovelace": s.lovelace,
"delegated_to": delegated,
"locked_by": locks,
})
}
#[tool(tool_box)]
@ -1279,7 +1611,7 @@ impl ServerHandler for WalletService {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
instructions: Some(
"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(),
"aldabra — Cardano lite wallet + DAO client 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). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Phase 1 read-only; voting/proposing land in subsequent phases.".into(),
),
..Default::default()
}