diff --git a/mcp/src/agent.rs b/mcp/src/agent.rs index 91c4411..ecd39dc 100644 --- a/mcp/src/agent.rs +++ b/mcp/src/agent.rs @@ -1,66 +1,111 @@ +//! Agent management tools for the Torus MCP server. +//! +//! Agents are the core participants in the Torus network. They can be: +//! - **Miners**: produce off-chain utility via their API (exposed at their URL) +//! - **Validators**: rate miners by setting weights, earning dividends in return +//! +//! This module handles agent lifecycle: +//! - Register/deregister agents +//! - Get agent info +//! - Update agent settings (URL, metadata, fees) +//! - Whitelist/dewhitelist agents (requires curator/admin signer) +//! - Delegate curator permissions (requires admin signer) + use rmcp::{ ErrorData, model::{CallToolResult, Content}, }; -use torus_client::subxt_signer::sr25519::{ - Keypair, - dev::{self, alice}, -}; +use torus_client::subxt_signer::sr25519::Keypair; use crate::{ Client, interfaces::{ + // These types come from the auto-generated chain interfaces. + // They're created at build time from the chain's metadata. permission0::calls::types::delegate_curator_permission::{Duration, Revocation}, runtime_types::bounded_collections::bounded_btree_map::BoundedBTreeMap, + runtime_types::sp_arithmetic::per_things::Percent, }, - utils::keypair_from_name, + utils::{account_id_from_name_or_ss58, keypair_from_name}, }; +// ===================================================================== +// Request/Response types +// ===================================================================== + +/// Params for getting agent info by name. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct AgentInfoRequest { + /// Dev account name (e.g. "alice") or SS58 address (e.g. "5DoVVgN7R6vH...") account_name: String, } +/// Agent info returned to MCP client. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct AgentInfoResponse { + /// The name the agent registered with name: String, + /// Free-form metadata string metadata: String, + /// The URL where the agent's API is accessible url: String, } +/// Params for registering a new agent. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct AgentRegisterRequest { + /// Dev account to register as an agent account_name: String, + /// Optional metadata (defaults to "test_agent_metadata") metadata: Option, + /// Optional URL (defaults to "test_agent_url") url: Option, } +/// Params for deregistering an agent. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct AgentDeregisterRequest { account_name: String, } +/// Params for adding an agent to the whitelist. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct AgentWhitelistAddRequest { + /// Dev account name of the agent to whitelist (target) account_name: String, + /// Dev account name of the curator/admin signing this tx (e.g. "alice") + signer_name: String, } +/// Params for removing an agent from the whitelist (shares struct with add). #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct AgentWhitelistRemoveRequest { account_name: String, } +/// Params for making an agent a curator (governance role). #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct DelegateCuratorPermisionRequest { + /// Dev account name of the agent receiving curator permissions (target) account_name: String, + /// Dev account name of the admin signing this tx (e.g. "alice") + signer_name: String, } +// ===================================================================== +// Handler functions +// ===================================================================== + +/// Registers a dev account as an agent on the chain. +/// Checks if already registered first to give a better error message. +/// The agent gets a name, URL, and metadata stored on-chain. pub async fn register_agent( torus_client: &Client, request: AgentRegisterRequest, ) -> Result { let keypair = keypair_from_name(&request.account_name)?; + // Pre-check: don't waste gas if already registered if is_agent(torus_client, &keypair).await? { return Ok(CallToolResult::error(vec![Content::text( "The account is already registered as an agent.", @@ -71,7 +116,7 @@ pub async fn register_agent( .torus0() .calls() .register_agent_wait( - request.account_name.into_bytes(), + request.account_name.into_bytes(), // name (stored as bytes on-chain) request .url .unwrap_or_else(|| "test_agent_url".to_string()) @@ -80,12 +125,12 @@ pub async fn register_agent( .metadata .unwrap_or_else(|| "test_agent_metadata".to_string()) .into_bytes(), - keypair, + keypair, // signer ) .await { Ok(_) => Ok(CallToolResult::success(vec![Content::text( - "agent registered", + "agent registered (tx submitted)", )])), Err(err) => { dbg!(&err); @@ -94,6 +139,8 @@ pub async fn register_agent( } } +/// Removes an agent from the chain. +/// Checks if the account is actually an agent first. pub async fn deregister_agent( torus_client: &Client, request: AgentDeregisterRequest, @@ -113,7 +160,7 @@ pub async fn deregister_agent( .await { Ok(_) => Ok(CallToolResult::success(vec![Content::text( - "agent deregistered", + "agent deregistered (tx submitted)", )])), Err(err) => { dbg!(&err); @@ -122,19 +169,22 @@ pub async fn deregister_agent( } } +/// Adds an agent to the governance whitelist. +/// The signer must be a curator or admin (e.g. "alice" on devnet). pub async fn whitelist_agent( torus_client: &Client, request: AgentWhitelistAddRequest, ) -> Result { - let key = keypair_from_name(request.account_name)?; + let signer = keypair_from_name(&request.signer_name)?; + let account_id = account_id_from_name_or_ss58(&request.account_name)?; match torus_client .governance() .calls() - .add_to_whitelist_wait(key.public_key().to_account_id(), dev::alice()) + .add_to_whitelist_wait(account_id, signer) .await { Ok(_) => Ok(CallToolResult::success(vec![Content::text( - "successfully added to whitelist", + "successfully added to whitelist (tx submitted)", )])), Err(err) => { dbg!(&err); @@ -143,19 +193,22 @@ pub async fn whitelist_agent( } } +/// Removes an agent from the governance whitelist. +/// The signer must be a curator or admin (e.g. "alice" on devnet). pub async fn dewhitelist_agent( torus_client: &Client, request: AgentWhitelistAddRequest, ) -> Result { - let key = keypair_from_name(request.account_name)?; + let signer = keypair_from_name(&request.signer_name)?; + let account_id = account_id_from_name_or_ss58(&request.account_name)?; match torus_client .governance() .calls() - .remove_from_whitelist_wait(key.public_key().to_account_id(), dev::alice()) + .remove_from_whitelist_wait(account_id, signer) .await { Ok(_) => Ok(CallToolResult::success(vec![Content::text( - "successfully removed from whitelist", + "successfully removed from whitelist (tx submitted)", )])), Err(err) => { dbg!(&err); @@ -164,16 +217,19 @@ pub async fn dewhitelist_agent( } } +/// Gets on-chain info for an agent: name, URL, and metadata. +/// Accepts either a dev account name ("alice") or an SS58 address ("5DoVVg..."). +/// Returns an error if the account is not registered as an agent. pub async fn get_agent_info( torus_client: &Client, request: AgentInfoRequest, ) -> Result { - let keypair = keypair_from_name(request.account_name)?; + let account_id = account_id_from_name_or_ss58(request.account_name)?; let agent = match torus_client .torus0() .storage() - .agents_get(&keypair.public_key().to_account_id()) + .agents_get(&account_id) .await { Ok(Some(info)) => info, @@ -189,6 +245,7 @@ pub async fn get_agent_info( } }; + // Agent fields are BoundedVec on-chain, so we convert to String Ok(CallToolResult::success(vec![Content::json( AgentInfoResponse { name: String::from_utf8_lossy(&agent.name.0).to_string(), @@ -198,28 +255,32 @@ pub async fn get_agent_info( )?])) } +/// Grants curator permissions to an agent. +/// Curators can manage the whitelist of agents. +/// The signer must be an existing curator or admin (e.g. "alice" on devnet). pub async fn delegate_curator_permission( torus_client: &Client, request: DelegateCuratorPermisionRequest, ) -> Result { - let keypair = keypair_from_name(&request.account_name)?; + let signer = keypair_from_name(&request.signer_name)?; + let account_id = account_id_from_name_or_ss58(&request.account_name)?; match torus_client .permission0() .calls() .delegate_curator_permission_wait( - keypair.public_key().to_account_id(), + account_id, BoundedBTreeMap(vec![(None, u32::MAX)]), None, Duration::Indefinite, Revocation::RevocableByDelegator, u32::MAX, - alice(), + signer, ) .await { Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( - "agent {} is now a curator", + "agent {} is now a curator (tx submitted)", &request.account_name ))])), Err(err) => { @@ -229,6 +290,54 @@ pub async fn delegate_curator_permission( } } +/// Params for updating an agent's settings. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct UpdateAgentRequest { + /// The agent account to update + account_name: String, + /// New URL for the agent's API endpoint + url: String, + /// Optional new metadata (None = keep existing) + metadata: Option, + /// Optional new staking fee percentage (0-100, None = keep existing) + staking_fee: Option, + /// Optional new weight control fee percentage (0-100, None = keep existing) + weight_control_fee: Option, +} + +/// Updates an existing agent's on-chain settings. +/// You can change URL (required), and optionally metadata and fee percentages. +/// The Percent type wraps a u8 (0-100) for the fee fields. +pub async fn update_agent( + torus_client: &Client, + request: UpdateAgentRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + + match torus_client + .torus0() + .calls() + .update_agent_wait( + request.url.into_bytes(), + request.metadata.map(|m| m.into_bytes()), + request.staking_fee.map(Percent), // Wrap u8 in Percent newtype + request.weight_control_fee.map(Percent), + keypair, + ) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Agent updated successfully (tx submitted)", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Helper: checks if an account is registered as an agent. +/// Used by register/deregister to give better error messages. async fn is_agent(torus_client: &Client, keypair: &Keypair) -> Result { match torus_client .torus0() diff --git a/mcp/src/balance.rs b/mcp/src/balance.rs index 37b6187..12106d9 100644 --- a/mcp/src/balance.rs +++ b/mcp/src/balance.rs @@ -1,39 +1,56 @@ +//! Balance checking tool for the Torus MCP server. +//! +//! Queries the Substrate System pallet's Account storage to get +//! an account's free, reserved, and frozen balances. +//! All amounts are in planck (1 TORUS = 10^18 planck). + use rmcp::{ ErrorData, model::{CallToolResult, Content}, }; -use crate::{Client, utils::keypair_from_name}; +use crate::{Client, utils::account_id_from_name_or_ss58}; +/// Params for checking an account's balance. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct BalanceCheckRequest { + /// Dev account name (e.g. "alice") or SS58 address (e.g. "5DoVVg...") account_name: String, } +/// Balance breakdown returned to the MCP client. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct BalanceCheckResponse { + /// Tokens available for transfer/staking free: u128, + /// Tokens locked by the system (e.g. for identity deposits) reserved: u128, + /// Tokens frozen (e.g. staked tokens that can't be transferred) frozen: u128, } +/// Unused for now — was intended for a faucet tool to mint test tokens. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct FaucetRequest { account_name: String, } +/// Checks the balance of a dev account. +/// Queries the System pallet's Account storage (standard Substrate storage +/// that every chain has — tracks balances, nonces, etc.) pub async fn check_account_balance( torus_client: &Client, request: BalanceCheckRequest, ) -> Result { - let keypair = keypair_from_name(request.account_name)?; + let account_id = account_id_from_name_or_ss58(request.account_name)?; match torus_client - .system() + .system() // System pallet — built into every Substrate chain .storage() - .account_get(&keypair.public_key().to_account_id()) + .account_get(&account_id) .await { + // Returns Option — None means the account doesn't exist on-chain Ok(data) => Ok(CallToolResult::success(vec![Content::json( data.map(|data| BalanceCheckResponse { free: data.data.free, diff --git a/mcp/src/consensus.rs b/mcp/src/consensus.rs index 07a220a..9834d21 100644 --- a/mcp/src/consensus.rs +++ b/mcp/src/consensus.rs @@ -1,3 +1,13 @@ +//! Consensus member listing tool for the Torus MCP server. +//! +//! Consensus members are the agents participating in the current emission epoch. +//! Each member has: +//! - **weights**: ratings they've assigned to other agents (0–65535) +//! - **last_incentives**: their incentive score from the previous epoch +//! - **last_dividends**: their dividend score from the previous epoch +//! +//! This data drives the emission distribution algorithm (see docs/linear-emission.md). + use std::collections::HashMap; use rmcp::{ @@ -8,19 +18,27 @@ use torus_client::subxt::ext::futures::StreamExt; use crate::{Client, utils::name_or_key}; +/// Wrapper for the list response. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct ConsensusMembersResponse { consensus_members: Vec, } +/// A single consensus member's data. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct ConsensusMember { + /// Account name or SS58 address name: String, + /// Map of agent_name → weight (0–65535). Higher weight = more reward for that agent. weights: HashMap, + /// Incentive score from last emission round (u16, used for pruning decisions) last_incentives: u16, + /// Dividend score from last emission round (u16, used for pruning decisions) last_dividends: u16, } +/// Lists all current consensus members from the emission0 pallet. +/// This iterates the ConsensusMembers storage map in the emission pallet. pub async fn list_consensus_members(torus_client: &Client) -> Result { let mut stream = match torus_client .emission0() @@ -37,12 +55,21 @@ pub async fn list_consensus_members(torus_client: &Client) -> Result kv, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; members.push(ConsensusMember { name: name_or_key(&id), + // Convert the on-chain BoundedBTreeMap of (AccountId → u16) + // into a HashMap of (name_or_address → u16) for readability weights: member .weights - .0 + .0 // .0 unwraps BoundedBTreeMap to get inner Vec .into_iter() .map(|(id, weight)| (name_or_key(&id), weight)) .collect(), diff --git a/mcp/src/emission.rs b/mcp/src/emission.rs index ac4f05e..e537554 100644 --- a/mcp/src/emission.rs +++ b/mcp/src/emission.rs @@ -1,3 +1,15 @@ +//! Emission delegation tool for the Torus MCP server. +//! +//! "Emission delegation" lets an agent share a portion of their emission stream +//! with another agent. This is done via the permission0 pallet's stream permission +//! system — it creates an on-chain permission contract that allocates a percentage +//! of the delegator's emission stream to the recipient. +//! +//! This is one of the more complex tools because it involves: +//! - Looking up the agent's root namespace (via an RPC call) +//! - Constructing a stream permission with allocation, distribution, and duration params +//! - Submitting the permission delegation extrinsic + use std::str::FromStr; use crate::interfaces::runtime_types::{ @@ -12,29 +24,50 @@ use rmcp::{ ErrorData, model::{CallToolResult, Content}, }; -use torus_client::subxt::utils::H256; +use torus_client::subxt::utils::H256; // H256 = 256-bit hash, used for permission/stream IDs -use crate::{Client, utils::keypair_from_name}; +use crate::{ + Client, + utils::{account_id_from_name_or_ss58, keypair_from_name}, +}; +/// Params for delegating (or re-delegating) an emission stream. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct DelegateEmissionRequest { + /// Optional hex hash of the stream to delegate FROM. + /// If None, uses the agent's root namespace (default stream). + /// Required when re-delegating (changing an existing delegation). stream_hex: Option, + /// The agent delegating their emission — must be a dev account to sign agent_name: String, + /// The agent receiving the delegated emission — dev account name or SS58 address target_name: String, + /// Percentage of the stream to delegate (0–100) amount: u8, + /// How/when the emission gets distributed distribution: Distribution, + /// How long the delegation lasts duration: Duration, } +/// Controls when/how the delegated emission is distributed. +/// This is a simplified MCP-side enum that mirrors the on-chain DistributionControl type. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub enum Distribution { + /// Delegatee must manually trigger distribution Manual, + /// Automatically distribute when accumulated amount reaches this threshold Automatic(u128), + /// Distribute at a specific block number AtBlock(u64), + /// Distribute every N blocks Interval(u64), } impl Distribution { + /// Converts our MCP-side enum into the auto-generated on-chain type. + /// We need this because the on-chain types are generated from chain metadata + /// and aren't directly serializable by schemars (MCP's JSON schema library). pub fn as_generated_type(&self) -> DistributionControl { match self { Distribution::Manual => DistributionControl::Manual, @@ -45,13 +78,17 @@ impl Distribution { } } +/// How long the emission delegation lasts. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub enum Duration { + /// Expires at a specific block number UntilBlock(u64), + /// Lasts forever (until explicitly revoked) Indefinite, } impl Duration { + /// Converts to the auto-generated on-chain type. pub fn as_generated_type(&self) -> PermissionDuration { match self { Duration::UntilBlock(v) => PermissionDuration::UntilBlock(*v), @@ -60,42 +97,53 @@ impl Duration { } } +/// Scaffolded for future use — revocation term options for emission delegation. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub enum Revocation {} +/// Delegates (or re-delegates) an emission stream to another agent. +/// +/// If `stream_hex` is not provided, it automatically looks up the agent's +/// root namespace hash via an RPC call. This is the default stream that +/// every agent gets when they register. pub async fn delegate_emission( torus_client: &Client, request: DelegateEmissionRequest, ) -> Result { let source_keypair = keypair_from_name(&request.agent_name)?; - let target_keypair = keypair_from_name(&request.target_name)?; + let target_id = account_id_from_name_or_ss58(&request.target_name)?; + // Either use the provided stream hash, or look up the agent's root namespace let stream = if let Some(stream_hex) = request.stream_hex { - H256::from_str(&stream_hex).unwrap() + H256::from_str(&stream_hex) + .map_err(|e| ErrorData::invalid_request(format!("Invalid stream hex: {e}"), None))? } else { + // RPC call to get the root namespace hash for this account torus_client .rpc() .root_namespace_for_account(source_keypair.public_key().to_account_id()) .await - .unwrap() + .map_err(|e| { + ErrorData::internal_error(format!("Failed to get root namespace: {e}"), None) + })? }; + // Submit the stream permission delegation extrinsic match torus_client .permission0() .calls() .delegate_stream_permission_wait( - BoundedBTreeMap(vec![( - target_keypair.public_key().to_account_id(), - u16::MAX, - )]), + // Recipients map: target gets max share (u16::MAX = 100%) + BoundedBTreeMap(vec![(target_id, u16::MAX)]), + // Allocate a percentage of the specified stream StreamAllocation::Streams(BoundedBTreeMap(vec![(stream, Percent(request.amount))])), request.distribution.as_generated_type(), request.duration.as_generated_type(), - RevocationTerms::RevocableByDelegator, - EnforcementAuthority::None, - None, - None, - source_keypair, + RevocationTerms::RevocableByDelegator, // Delegator can revoke anytime + EnforcementAuthority::None, // No third-party enforcer + None, // No accumulation threshold + None, // No additional metadata + source_keypair, // Signer ) .await { diff --git a/mcp/src/governance.rs b/mcp/src/governance.rs new file mode 100644 index 0000000..047504d --- /dev/null +++ b/mcp/src/governance.rs @@ -0,0 +1,466 @@ +//! Governance tools for the Torus MCP server. +//! +//! The governance pallet is Torus's democracy system. It handles: +//! - **Applications**: Agents apply to join/leave the network (costs 100 TORUS, refunded if accepted) +//! - **Proposals**: Anyone can propose changes to network parameters, treasury transfers, etc. +//! - **Voting**: Stakers vote on proposals (weighted by stake) +//! - **Vote delegation**: You can let someone else vote on your behalf +//! +//! Most write operations require the signer to be a registered agent with sufficient stake. + +use rmcp::{ + ErrorData, + model::{CallToolResult, Content}, +}; +use torus_client::subxt::ext::futures::StreamExt; + +use crate::{ + Client, + utils::{account_id_from_name_or_ss58, keypair_from_name, name_or_key}, +}; + +// ===================================================================== +// Request types — define what JSON params each tool accepts +// ===================================================================== + +/// Params for submitting an agent application. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct SubmitApplicationRequest { + /// Dev account paying the application fee (100 TORUS) + account_name: String, + /// Agent to apply for — dev account name (e.g. "bob") or SS58 address (e.g. "5DoVVg...") + agent_name: String, + /// Free-form text describing why this agent should be added/removed + metadata: String, + /// false = applying to ADD agent, true = applying to REMOVE agent + removing: bool, +} + +/// Params for looking up a specific application by its numeric ID. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct GetApplicationRequest { + application_id: u32, +} + +/// Params for looking up a specific proposal by its numeric ID. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct GetProposalRequest { + proposal_id: u64, +} + +/// Params for creating a custom (free-form) governance proposal. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct AddCustomProposalRequest { + account_name: String, + /// Description of what you're proposing + metadata: String, +} + +/// Params for proposing a treasury transfer (send DAO funds to someone). +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct AddTreasuryTransferProposalRequest { + account_name: String, + /// Amount to transfer from treasury (in planck) + value: u128, + /// Who receives the funds — dev account name or SS58 address + destination_name: String, + /// Why this transfer should happen + metadata: String, +} + +/// Params for voting on a proposal. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct VoteProposalRequest { + account_name: String, + proposal_id: u64, + /// true = vote FOR, false = vote AGAINST + agree: bool, +} + +/// Params for removing a previously cast vote. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct RemoveVoteRequest { + account_name: String, + proposal_id: u64, +} + +/// Params for enabling/disabling vote delegation. +/// When enabled, others can vote using your stake weight. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct VoteDelegationRequest { + account_name: String, +} + +// ===================================================================== +// Response types — simplified versions of on-chain data for MCP output +// ===================================================================== + +/// Simplified view of an agent application. +/// Uses format!("{:?}") for enum fields (status, action) because the on-chain +/// types are complex generated enums — Debug formatting gives readable output +/// like "Open", "Accepted", "Removing" without us needing to mirror them. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct ApplicationResponse { + id: u32, + payer: String, + agent: String, + metadata: String, + /// Debug-formatted enum: "Open", "Accepted", "Rejected", etc. + status: String, + /// Debug-formatted enum: "Add" or "Remove" + action: String, +} + +/// Wrapper for list_applications response. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct ApplicationListResponse { + /// Total number of applications on-chain + total: usize, + /// Applications returned (capped at 50) + applications: Vec, +} + +/// Simplified view of a governance proposal. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct ProposalResponse { + id: u64, + proposer: String, + /// Debug-formatted: "Open", "Accepted", "Rejected", "Expired" + status: String, + /// Debug-formatted: "Custom", "TreasuryTransfer", "GlobalParams", etc. + data_type: String, + metadata: String, + /// Block number when this proposal expires + expiration_block: u64, +} + +/// Wrapper for list_proposals response. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct ProposalListResponse { + proposals: Vec, +} + +// ===================================================================== +// Handler functions +// ===================================================================== + +/// Submits an application to add or remove an agent. +/// Costs 100 TORUS (refunded if the application is accepted by governance). +pub async fn submit_application( + torus_client: &Client, + request: SubmitApplicationRequest, +) -> Result { + let payer_keypair = keypair_from_name(&request.account_name)?; + let agent_id = account_id_from_name_or_ss58(&request.agent_name)?; + + match torus_client + .governance() + .calls() + .submit_application_wait( + agent_id, + request.metadata.into_bytes(), + request.removing, + payer_keypair, + ) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Application submitted for agent {}", + request.agent_name + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Lists ALL applications on-chain (open, accepted, rejected — everything). +/// Iterates the AgentApplications storage map. +pub async fn list_applications(torus_client: &Client) -> Result { + let mut stream = match torus_client + .governance() + .storage() + .agent_applications_iter() + .await + { + Ok(stream) => stream, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + + let mut all = Vec::new(); + while let Some(item) = stream.next().await { + let (id, app) = match item { + Ok(kv) => kv, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + all.push(ApplicationResponse { + id, + payer: name_or_key(&app.payer_key), + agent: name_or_key(&app.agent_key), + metadata: String::from_utf8_lossy(&app.data.0).to_string(), + status: format!("{:?}", app.status), + action: format!("{:?}", app.action), + }); + } + + let total = all.len(); + // Sort descending by id so the most recent appear first, then cap at 50 + all.sort_by(|a, b| b.id.cmp(&a.id)); + all.truncate(50); + + Ok(CallToolResult::success(vec![Content::json( + ApplicationListResponse { + total, + applications: all, + }, + )?])) +} + +/// Gets a single application by its numeric ID. +pub async fn get_application( + torus_client: &Client, + request: GetApplicationRequest, +) -> Result { + match torus_client + .governance() + .storage() + .agent_applications_get(&request.application_id) + .await + { + Ok(Some(app)) => Ok(CallToolResult::success(vec![Content::json( + ApplicationResponse { + id: request.application_id, + payer: name_or_key(&app.payer_key), + agent: name_or_key(&app.agent_key), + metadata: String::from_utf8_lossy(&app.data.0).to_string(), + status: format!("{:?}", app.status), + action: format!("{:?}", app.action), + }, + )?])), + Ok(None) => Err(ErrorData::invalid_request("Application not found", None)), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Lists ALL governance proposals on-chain. +pub async fn list_proposals(torus_client: &Client) -> Result { + let mut stream = match torus_client.governance().storage().proposals_iter().await { + Ok(stream) => stream, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + + let mut proposals = Vec::new(); + while let Some(item) = stream.next().await { + let (id, proposal) = match item { + Ok(kv) => kv, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + proposals.push(ProposalResponse { + id, + proposer: name_or_key(&proposal.proposer), + status: format!("{:?}", proposal.status), + data_type: format!("{:?}", proposal.data), + metadata: String::from_utf8_lossy(&proposal.metadata.0).to_string(), + expiration_block: proposal.expiration_block, + }); + } + + Ok(CallToolResult::success(vec![Content::json( + ProposalListResponse { proposals }, + )?])) +} + +/// Gets a single proposal by its numeric ID. +pub async fn get_proposal( + torus_client: &Client, + request: GetProposalRequest, +) -> Result { + match torus_client + .governance() + .storage() + .proposals_get(&request.proposal_id) + .await + { + Ok(Some(proposal)) => Ok(CallToolResult::success(vec![Content::json( + ProposalResponse { + id: request.proposal_id, + proposer: name_or_key(&proposal.proposer), + status: format!("{:?}", proposal.status), + data_type: format!("{:?}", proposal.data), + metadata: String::from_utf8_lossy(&proposal.metadata.0).to_string(), + expiration_block: proposal.expiration_block, + }, + )?])), + Ok(None) => Err(ErrorData::invalid_request("Proposal not found", None)), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Creates a custom governance proposal (free-form text, no automatic execution). +/// Requires the signer to have enough stake to propose. +pub async fn add_custom_proposal( + torus_client: &Client, + request: AddCustomProposalRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + + match torus_client + .governance() + .calls() + .add_global_custom_proposal_wait(request.metadata.into_bytes(), keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Custom proposal submitted", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Creates a proposal to transfer funds from the DAO treasury to a destination account. +/// If the proposal passes voting, the transfer happens automatically. +pub async fn add_treasury_transfer_proposal( + torus_client: &Client, + request: AddTreasuryTransferProposalRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + let dest_id = account_id_from_name_or_ss58(&request.destination_name)?; + + match torus_client + .governance() + .calls() + .add_dao_treasury_transfer_proposal_wait( + request.value, + dest_id, + request.metadata.into_bytes(), + keypair, + ) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Treasury transfer proposal submitted", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Casts a vote on an existing proposal. Vote weight is proportional to stake. +pub async fn vote_proposal( + torus_client: &Client, + request: VoteProposalRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + + match torus_client + .governance() + .calls() + .vote_proposal_wait(request.proposal_id, request.agree, keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Vote {} on proposal {}", + if request.agree { "for" } else { "against" }, + request.proposal_id + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Removes a previously cast vote from a proposal (lets you change your mind). +pub async fn remove_vote( + torus_client: &Client, + request: RemoveVoteRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + + match torus_client + .governance() + .calls() + .remove_vote_proposal_wait(request.proposal_id, keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Vote removed from proposal {}", + request.proposal_id + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Enables vote delegation — allows other accounts to vote using your stake weight. +/// This is opt-in: you must explicitly enable it before others can delegate to you. +pub async fn enable_vote_delegation( + torus_client: &Client, + request: VoteDelegationRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + + match torus_client + .governance() + .calls() + .enable_vote_delegation_wait(keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Vote delegation enabled", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Disables vote delegation — stops others from voting with your stake. +pub async fn disable_vote_delegation( + torus_client: &Client, + request: VoteDelegationRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + + match torus_client + .governance() + .calls() + .disable_vote_delegation_wait(keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Vote delegation disabled", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} diff --git a/mcp/src/main.rs b/mcp/src/main.rs index 37a67fd..0710cbd 100644 --- a/mcp/src/main.rs +++ b/mcp/src/main.rs @@ -1,33 +1,73 @@ #![allow(dead_code)] - +//! Torus MCP Server — Entry point and tool registration. +//! +//! This is an MCP (Model Context Protocol) server that lets AI assistants +//! interact with the Torus blockchain. It exposes ~46 tools that can: +//! - Query chain state (agents, balances, proposals, etc.) +//! - Submit transactions (stake, transfer, vote, register, etc.) +//! +//! ## How it works +//! 1. The server connects to a Torus node via WebSocket +//! 2. It registers all tools with the MCP framework (rmcp) +//! 3. MCP clients (like Claude) can call these tools via JSON-RPC over stdio +//! +//! ## Account system +//! This MCP only supports hardcoded dev accounts (alice, bob, charlie, etc.) +//! Each tool that writes to the chain takes an `account_name` parameter +//! that maps to one of these dev keypairs. This is fine for testing but +//! would need real key management for production. + +// Compile-time check: you can only target one network at a time #[cfg(all(feature = "testnet", feature = "devnet"))] compile_error!("only one of the following features can be enabled at a time: testnet, devnet."); +// --- Framework imports --- use rmcp::handler::server::tool::{Parameters, ToolRouter}; use rmcp::model::{ CallToolResult, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo, }; -use rmcp::transport::stdio; +use rmcp::transport::stdio; // Communicate with MCP client over stdin/stdout use rmcp::{ErrorData, ServerHandler, ServiceExt, tool, tool_handler, tool_router}; use std::collections::HashMap; -use std::sync::Arc; -use torus_client::client::TorusClient; +use std::sync::Arc; // Arc = thread-safe reference counting pointer +use torus_client::client::TorusClient; // The blockchain client library use torus_client::subxt_signer::sr25519::Keypair; use torus_client::subxt_signer::sr25519::dev::{alice, bob, charlie, dave, eve, ferdie, one, two}; use tracing_subscriber::EnvFilter; +// --- Import request types from each module --- +// Each module defines its own request/response structs. +// We import them here so the #[tool] handlers can reference them. use crate::agent::{ AgentDeregisterRequest, AgentInfoRequest, AgentRegisterRequest, AgentWhitelistAddRequest, - DelegateCuratorPermisionRequest, + DelegateCuratorPermisionRequest, UpdateAgentRequest, }; use crate::balance::BalanceCheckRequest; use crate::emission::DelegateEmissionRequest; +use crate::governance::{ + AddCustomProposalRequest, AddTreasuryTransferProposalRequest, GetApplicationRequest, + GetProposalRequest, RemoveVoteRequest, SubmitApplicationRequest, VoteDelegationRequest, + VoteProposalRequest, +}; use crate::namespace::{ NamespaceCreationRequest, NamespaceDelegationRequest, NamespaceDeletionRequest, - PermissionSummaryRequest, + PermissionSummaryRequest, RevokePermissionRequest, TogglePermissionAccumulationRequest, +}; +use crate::queries::WhitelistCheckRequest; +use crate::staking::{ + AddStakeRequest, GetStakeRequest, GetStakersForAgentRequest, GetStakesForAccountRequest, + RemoveStakeRequest, TransferStakeRequest, +}; +use crate::transfer::TransferBalanceRequest; +use crate::weight_control::{ + DelegateWeightControlRequest, GetWeightControlDelegationRequest, RegainWeightControlRequest, }; use crate::weights::SetWeightsRequest; +/// Re-export the auto-generated chain interfaces. +/// These are created at build time from the chain's metadata and contain +/// all the type definitions for extrinsics, storage, events, etc. +/// The feature flag determines which network's metadata to use. pub mod interfaces { #[cfg(feature = "testnet")] pub use torus_client::interfaces::testnet::api::*; @@ -36,14 +76,26 @@ pub mod interfaces { pub use torus_client::interfaces::devnet::api::*; } -mod agent; -mod balance; -mod consensus; -mod emission; -mod namespace; -mod utils; -mod weights; - +// --- Module declarations --- +// Each module contains the handler functions and request/response types +// for a group of related tools. Rust's `mod` keyword is like an import +// that makes the module available as `modulename::function_name`. +mod agent; // Agent registration, info, whitelist, curator +mod balance; // Balance checking +mod consensus; // List consensus members +mod emission; // Emission delegation +mod governance; // Applications, proposals, voting +mod namespace; // Namespace CRUD, permissions +mod queries; // Read-only chain queries (agents list, burn, emission params) +mod staking; // Stake/unstake/transfer-stake, stake queries +mod transfer; // Token transfers +mod utils; // Helper functions (keypair_from_name, name_or_key) +mod weight_control; // Weight control delegation +mod weights; // Set validator weights + +// Hardcoded dev accounts available for testing. +// lazy_static! creates a global variable that's initialized once on first access. +// These are well-known Substrate dev accounts with deterministic private keys. lazy_static::lazy_static! { static ref ACCOUNTS: HashMap = HashMap::from([ ("alice".to_string(), alice()), @@ -57,18 +109,36 @@ lazy_static::lazy_static! { ]); } +/// Type alias for the blockchain client. +/// TorusClient is generic over the network type (TestNet vs DevNet). +/// This alias lets the rest of the code just say `Client` without caring +/// which network we're targeting. #[cfg(feature = "testnet")] pub type Client = TorusClient; #[cfg(feature = "devnet")] pub type Client = TorusClient; +/// The main MCP server struct. +/// Holds a shared reference to the blockchain client (Arc = thread-safe) +/// and the tool router that maps tool names to handler functions. #[derive(Clone)] pub struct TorusMcp { torus_client: Arc, tool_router: ToolRouter, } +/// Tool registration — this is where ALL tools are wired up. +/// +/// The #[tool_router] macro generates the routing code that maps +/// incoming MCP tool calls to the correct handler function. +/// +/// Each #[tool(description = "...")] method: +/// 1. Receives JSON params as a `Parameters` wrapper +/// 2. Delegates to the actual handler function in the corresponding module +/// 3. Returns a CallToolResult (success with content, or error) +/// +/// The `description` is what MCP clients see when they list available tools. #[tool_router] impl TorusMcp { pub async fn new(torus_client: Client) -> Self { @@ -78,6 +148,10 @@ impl TorusMcp { } } + // ================================================================ + // Agent tools — register/deregister/update agents on the network + // ================================================================ + #[tool( description = "Registers an account as an agent on the chain via it's name on the preconfigured accounts dictionary." )] @@ -108,7 +182,17 @@ impl TorusMcp { agent::get_agent_info(&self.torus_client, request).await } - #[tool(description = "Adds an agent to the whitelist (uses alice as the signer).")] + #[tool(description = "Updates an agent's URL, metadata, staking fee, or weight control fee.")] + async fn update_agent( + &self, + Parameters(request): Parameters, + ) -> Result { + agent::update_agent(&self.torus_client, request).await + } + + #[tool( + description = "Adds an agent to the whitelist. Signer must be a curator or admin dev account." + )] async fn whitelist_agent( &self, Parameters(request): Parameters, @@ -116,7 +200,9 @@ impl TorusMcp { agent::whitelist_agent(&self.torus_client, request).await } - #[tool(description = "Removes an agent from the whitelist (uses alice as the signer).")] + #[tool( + description = "Removes an agent from the whitelist. Signer must be a curator or admin dev account." + )] async fn dewhitelist_agent( &self, Parameters(request): Parameters, @@ -124,6 +210,205 @@ impl TorusMcp { agent::dewhitelist_agent(&self.torus_client, request).await } + #[tool(description = "Makes the given agent account a curator.")] + async fn delegate_curator_permission( + &self, + Parameters(request): Parameters, + ) -> Result { + agent::delegate_curator_permission(&self.torus_client, request).await + } + + // ================================================================ + // Balance tools — check account balances + // ================================================================ + + #[tool(description = "Checks the balance for the supplied account name.")] + async fn check_account_balance( + &self, + Parameters(request): Parameters, + ) -> Result { + balance::check_account_balance(&self.torus_client, request).await + } + + // ================================================================ + // Transfer tools — send tokens between accounts + // ================================================================ + + #[tool(description = "Transfers tokens from one account to another.")] + async fn transfer_balance( + &self, + Parameters(request): Parameters, + ) -> Result { + transfer::transfer_balance(&self.torus_client, request).await + } + + // ================================================================ + // Staking tools — stake/unstake tokens on agents + // ================================================================ + + #[tool(description = "Stakes tokens from a staker account to an agent.")] + async fn add_stake( + &self, + Parameters(request): Parameters, + ) -> Result { + staking::add_stake(&self.torus_client, request).await + } + + #[tool(description = "Removes stake from a staker account on an agent.")] + async fn remove_stake( + &self, + Parameters(request): Parameters, + ) -> Result { + staking::remove_stake(&self.torus_client, request).await + } + + #[tool(description = "Transfers stake from one agent to another.")] + async fn transfer_stake( + &self, + Parameters(request): Parameters, + ) -> Result { + staking::transfer_stake(&self.torus_client, request).await + } + + #[tool(description = "Gets the stake amount between a staker and an agent.")] + async fn get_stake( + &self, + Parameters(request): Parameters, + ) -> Result { + staking::get_stake(&self.torus_client, request).await + } + + #[tool(description = "Lists all staking positions for an account.")] + async fn get_stakes_for_account( + &self, + Parameters(request): Parameters, + ) -> Result { + staking::get_stakes_for_account(&self.torus_client, request).await + } + + #[tool(description = "Gets the total stake across the entire Torus network.")] + async fn get_total_stake(&self) -> Result { + staking::get_total_stake(&self.torus_client).await + } + + #[tool( + description = "Lists all stakers on a given agent and the total stake they hold. Accepts dev account names or mainnet SS58 addresses." + )] + async fn get_stakers_for_agent( + &self, + Parameters(request): Parameters, + ) -> Result { + staking::get_stakers_for_agent(&self.torus_client, request).await + } + + // ================================================================ + // Query tools — read-only chain state queries + // ================================================================ + + #[tool(description = "Lists all registered agents on the chain.")] + async fn list_agents(&self) -> Result { + queries::list_agents(&self.torus_client).await + } + + #[tool(description = "Gets the current dynamic burn amount required for agent registration.")] + async fn get_burn_amount(&self) -> Result { + queries::get_burn_amount(&self.torus_client).await + } + + #[tool(description = "Gets the burn configuration parameters.")] + async fn get_burn_config(&self) -> Result { + queries::get_burn_config(&self.torus_client).await + } + + #[tool(description = "Gets the pending emission amount to be distributed next epoch.")] + async fn get_pending_emission(&self) -> Result { + queries::get_pending_emission(&self.torus_client).await + } + + #[tool( + description = "Gets the current incentives ratio (balance between miner incentives and validator dividends)." + )] + async fn get_incentives_ratio(&self) -> Result { + queries::get_incentives_ratio(&self.torus_client).await + } + + #[tool(description = "Gets the current emission recycling percentage.")] + async fn get_emission_recycling_percentage(&self) -> Result { + queries::get_emission_recycling_percentage(&self.torus_client).await + } + + #[tool(description = "Checks whether an account is whitelisted.")] + async fn check_whitelist_status( + &self, + Parameters(request): Parameters, + ) -> Result { + queries::check_whitelist_status(&self.torus_client, request).await + } + + #[tool(description = "Gets the global governance configuration.")] + async fn get_global_governance_config(&self) -> Result { + queries::get_global_governance_config(&self.torus_client).await + } + + // ================================================================ + // Weights and emission tools — validator weight setting + // ================================================================ + + #[tool(description = "Sets the weights of an agent account.")] + async fn set_weights( + &self, + Parameters(request): Parameters, + ) -> Result { + weights::set_weights(&self.torus_client, request).await + } + + #[tool(description = "List all consensus members.")] + async fn list_consensus_members(&self) -> Result { + consensus::list_consensus_members(&self.torus_client).await + } + + #[tool( + description = "Delegates or re-delegates an emission stream to the named agent. Delegating does not require the stream to be supplied, redelegating does." + )] + async fn delegate_emission( + &self, + Parameters(request): Parameters, + ) -> Result { + emission::delegate_emission(&self.torus_client, request).await + } + + // ================================================================ + // Weight control tools — delegate weight-setting responsibility + // ================================================================ + + #[tool(description = "Delegates weight control to another validator (must be an allocator).")] + async fn delegate_weight_control( + &self, + Parameters(request): Parameters, + ) -> Result { + weight_control::delegate_weight_control(&self.torus_client, request).await + } + + #[tool(description = "Regains weight control from a previous delegation.")] + async fn regain_weight_control( + &self, + Parameters(request): Parameters, + ) -> Result { + weight_control::regain_weight_control(&self.torus_client, request).await + } + + #[tool(description = "Checks if an account has delegated weight control and to whom.")] + async fn get_weight_control_delegation( + &self, + Parameters(request): Parameters, + ) -> Result { + weight_control::get_weight_control_delegation(&self.torus_client, request).await + } + + // ================================================================ + // Namespace and permission tools — manage namespaces and access control + // ================================================================ + #[tool(description = "Creates a namespace on the designated preconfigured account agent.")] async fn create_namespace_for_agent( &self, @@ -158,46 +443,115 @@ impl TorusMcp { namespace::get_permission_summary_for_agent(&self.torus_client, request).await } - #[tool(description = "Checks the balance for the supplied account name.")] - async fn check_account_balance( + #[tool(description = "Revokes a permission by its ID (hex hash).")] + async fn revoke_permission( &self, - Parameters(request): Parameters, + Parameters(request): Parameters, ) -> Result { - balance::check_account_balance(&self.torus_client, request).await + namespace::revoke_permission(&self.torus_client, request).await } - #[tool(description = "Sets the weights of an agent account.")] - async fn set_weights( + #[tool(description = "Toggles whether a permission accumulates emissions.")] + async fn toggle_permission_accumulation( &self, - Parameters(request): Parameters, + Parameters(request): Parameters, ) -> Result { - weights::set_weights(&self.torus_client, request).await + namespace::toggle_permission_accumulation(&self.torus_client, request).await } - #[tool(description = "List all consensus members.")] - async fn list_consensus_members(&self) -> Result { - consensus::list_consensus_members(&self.torus_client).await + // ================================================================ + // Governance tools — applications, proposals, voting + // ================================================================ + + #[tool( + description = "Submits an application to add or remove an agent from the whitelist. Costs 100 TORUS (refunded if accepted)." + )] + async fn submit_application( + &self, + Parameters(request): Parameters, + ) -> Result { + governance::submit_application(&self.torus_client, request).await + } + + #[tool(description = "Lists all agent applications.")] + async fn list_applications(&self) -> Result { + governance::list_applications(&self.torus_client).await + } + + #[tool(description = "Gets details of a specific application by ID.")] + async fn get_application( + &self, + Parameters(request): Parameters, + ) -> Result { + governance::get_application(&self.torus_client, request).await + } + + #[tool(description = "Lists all governance proposals.")] + async fn list_proposals(&self) -> Result { + governance::list_proposals(&self.torus_client).await + } + + #[tool(description = "Gets details of a specific proposal by ID.")] + async fn get_proposal( + &self, + Parameters(request): Parameters, + ) -> Result { + governance::get_proposal(&self.torus_client, request).await + } + + #[tool(description = "Creates a custom global proposal with metadata.")] + async fn add_custom_proposal( + &self, + Parameters(request): Parameters, + ) -> Result { + governance::add_custom_proposal(&self.torus_client, request).await + } + + #[tool(description = "Creates a proposal to transfer funds from the DAO treasury.")] + async fn add_treasury_transfer_proposal( + &self, + Parameters(request): Parameters, + ) -> Result { + governance::add_treasury_transfer_proposal(&self.torus_client, request).await + } + + #[tool(description = "Votes for or against a proposal.")] + async fn vote_proposal( + &self, + Parameters(request): Parameters, + ) -> Result { + governance::vote_proposal(&self.torus_client, request).await + } + + #[tool(description = "Removes a vote from a proposal.")] + async fn remove_vote( + &self, + Parameters(request): Parameters, + ) -> Result { + governance::remove_vote(&self.torus_client, request).await } #[tool( - description = "Delegates or re-delegates an emission stream to the named agent. Delegating does not require the stream to be supplied, redelegating does." + description = "Enables vote power delegation for an account (allows others to vote with your stake)." )] - async fn delegate_emission( + async fn enable_vote_delegation( &self, - Parameters(request): Parameters, + Parameters(request): Parameters, ) -> Result { - emission::delegate_emission(&self.torus_client, request).await + governance::enable_vote_delegation(&self.torus_client, request).await } - #[tool(description = "Makes the given agent account a curator.")] - async fn delegate_curator_permission( + #[tool(description = "Disables vote power delegation for an account.")] + async fn disable_vote_delegation( &self, - Parameters(request): Parameters, + Parameters(request): Parameters, ) -> Result { - agent::delegate_curator_permission(&self.torus_client, request).await + governance::disable_vote_delegation(&self.torus_client, request).await } } +/// MCP server metadata — tells MCP clients what this server is and what it can do. +/// The #[tool_handler] macro wires up the tool router to actually handle incoming calls. #[tool_handler] impl ServerHandler for TorusMcp { fn get_info(&self) -> ServerInfo { @@ -205,13 +559,28 @@ impl ServerHandler for TorusMcp { protocol_version: ProtocolVersion::V_2024_11_05, capabilities: ServerCapabilities::builder().enable_tools().build(), server_info: Implementation::from_build_env(), - instructions: Some("This server provides an interface with the torus blockchain. Agents can be inspected with the 'get_agent_info' tool.".to_string()), + instructions: Some( + "This server provides a comprehensive interface with the Torus blockchain. \ + Use 'list_agents' to see all registered agents, 'check_account_balance' \ + for balances, and various staking/governance/permission tools to interact \ + with the chain." + .to_string(), + ), } } } +/// Entry point — starts the MCP server. +/// +/// 1. Sets up logging (to stderr, so it doesn't interfere with MCP's stdout protocol) +/// 2. Connects to the Torus blockchain node +/// 3. Creates the MCP server and starts listening on stdin/stdout +/// +/// #[tokio::main] turns this into an async runtime — needed because all +/// blockchain calls are async (they go over the network). #[tokio::main] async fn main() { + // Set up logging — output goes to stderr (not stdout, which is used for MCP protocol) tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into())) .with_writer(std::io::stderr) @@ -220,14 +589,23 @@ async fn main() { tracing::info!("Starting MCP server"); + // Connect to the blockchain node + // for_testnet() connects to wss://api.torus.network (mainnet/testnet) + // for_devnet() connects to a local dev node #[cfg(feature = "testnet")] - let torus_client = TorusClient::for_testnet().await.unwrap(); + let torus_client = TorusClient::for_testnet() + .await + .expect("Failed to connect to testnet node"); #[cfg(feature = "devnet")] - let torus_client = TorusClient::for_devnet().await.unwrap(); + let torus_client = TorusClient::for_devnet() + .await + .expect("Failed to connect to devnet node"); tracing::info!("Connected to torus client"); + // Create the MCP server and start serving over stdio + // stdio() = communicate via stdin (receives tool calls) and stdout (sends results) let service = TorusMcp::new(torus_client) .await .serve(stdio()) @@ -235,7 +613,11 @@ async fn main() { .inspect_err(|e| { eprintln!("serving error: {e:?}"); }) - .unwrap(); + .expect("Failed to start MCP server"); - service.waiting().await.unwrap(); + // Block until the MCP client disconnects + service + .waiting() + .await + .expect("MCP server terminated unexpectedly"); } diff --git a/mcp/src/namespace.rs b/mcp/src/namespace.rs index 9cbd4b6..bfaecb5 100644 --- a/mcp/src/namespace.rs +++ b/mcp/src/namespace.rs @@ -1,3 +1,24 @@ +//! Namespace and permission tools for the Torus MCP server. +//! +//! Namespaces are hierarchical paths (dot-separated) that agents own on-chain. +//! They're used for organizing emission streams and permissions. +//! +//! **Path format**: All agent-owned paths have the form `agent.{agent_name}.{suffix}`. +//! The tools here accept just the suffix (e.g. `"memory"` or `"tools.search"`) and +//! automatically prepend `agent.{agent_name}.` — so you never need to write the prefix. +//! +//! Valid characters per segment: lowercase ASCII letters, digits, `-`, `_`. +//! Max segment length: 63 characters. Max total path length: 256 characters. +//! +//! The permission system is the most complex part of Torus — it supports: +//! - **Namespace permissions**: delegate control over namespace paths +//! - **Curator permissions**: delegate whitelist management +//! - **Stream permissions**: delegate emission stream allocation (see emission.rs) +//! - **Wallet permissions**: delegate staking control +//! +//! Each permission is a "contract" with a delegator, recipient, scope, duration, +//! and revocation terms. Permissions are identified by H256 hashes. + use std::collections::HashMap; use crate::{ @@ -14,46 +35,86 @@ use crate::{ }, }, }; +use std::str::FromStr; + use rmcp::{ ErrorData, model::{CallToolResult, Content}, }; use torus_client::subxt::ext::futures::StreamExt; +use torus_client::subxt::utils::H256; // 256-bit hash used as permission IDs use crate::{ Client, - utils::{keypair_from_name, name_or_key}, + utils::{account_id_from_name_or_ss58, keypair_from_name, name_or_key}, }; +// ===================================================================== +// Request types +// ===================================================================== + +/// Params for creating a new namespace. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct NamespaceCreationRequest { + /// The agent that will own this namespace agent_name: String, + /// The namespace path suffix (e.g. "memory" or "tools.search"). + /// The full on-chain path will be "agent.{agent_name}.{namespace_path}". + /// Valid characters: lowercase letters, digits, hyphens, underscores. No uppercase or slashes. namespace_path: String, } +/// Params for deleting a namespace. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct NamespaceDeletionRequest { agent_name: String, + /// The namespace path suffix to delete (e.g. "memory"). + /// The full on-chain path will be "agent.{agent_name}.{namespace_path}". namespace_path: String, } +/// Params for delegating namespace access to another agent. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct NamespaceDelegationRequest { + /// Agent delegating the permission (must own the namespace) from_agent: String, + /// Agent receiving the permission to_agent: String, + /// The namespace path suffix to delegate (e.g. "memory"). + /// The full on-chain path will be "agent.{from_agent}.{namespace_path}". namespace_path: String, } +/// Params for getting all permissions affecting an account. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct PermissionSummaryRequest { - account_name: String, + /// Dev account name (e.g. "alice") or SS58 address (e.g. "5DoVVg...") + pub account_name: String, +} + +// ===================================================================== +// Response types — simplified MCP-friendly versions of on-chain data +// ===================================================================== + +/// A single permission entry with its on-chain ID. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct PermissionEntry { + /// Hex-encoded H256 permission ID — pass this to revoke_permission or toggle_permission_accumulation + pub id: String, + /// The permission details + pub detail: Permission, } +/// Summary of all permissions related to an account. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct PermissionSummaryResponse { - permissions: Vec, + /// Total number of permissions found for this account + total: usize, + /// Permissions returned (capped at 50) + permissions: Vec, } +/// A single permission — can be one of 4 types, each with a direction. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub enum Permission { Namespace((NamespacePermission, Direction)), @@ -62,66 +123,90 @@ pub enum Permission { Wallet((WalletPermission, Direction)), } +/// Whether this permission is one we gave out or one we received. #[derive(Clone, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub enum Direction { + /// We delegated this permission TO these accounts DelegatingTo(Vec), + /// We received this permission FROM this account DelegatedFrom(String), } +/// Namespace permission details. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct NamespacePermission { + /// The full on-chain namespace path (e.g. "agent.alice.memory") path: String, + /// Parent namespace hash, if this is a sub-namespace parent: Option, } +/// Curator permission (no extra fields — either you have it or you don't). #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct CuratorPermission {} +/// Stream (emission) permission details. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct StreamPermission { + /// How much of the stream is allocated allocation: Allocation, + /// When/how it gets distributed distribution: Distribution, + /// Map of recipient_name → share weight recipients: HashMap, + /// Whether this permission accumulates emissions over time accumulating: bool, } +/// How the stream allocation is defined. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub enum Allocation { + /// Percentage-based allocation from named streams Streams(HashMap), + /// Fixed token amount FixedAmount(u128), } +/// Wallet permission details. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct WalletPermission { + /// What kind of wallet access is granted r#type: WalletPermissionType, } +/// Types of wallet permissions. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub enum WalletPermissionType { Stake { + /// Whether the recipient can transfer stake to other agents can_transfer_stake: bool, + /// Whether only the recipient can stake (exclusive access) exclusive_stake_access: bool, }, } +// ===================================================================== +// Handler functions +// ===================================================================== + +/// Creates a new namespace owned by the specified agent. +/// Constructs the full on-chain path as "agent.{agent_name}.{namespace_path}". pub async fn create_namespace_for_agent( torus_client: &Client, request: NamespaceCreationRequest, ) -> Result { let keypair = keypair_from_name(&request.agent_name)?; + let full_path = format!("agent.{}.{}", request.agent_name, request.namespace_path); match torus_client .torus0() .calls() - .create_namespace_wait( - BoundedVec(request.namespace_path.as_bytes().to_vec()), - keypair, - ) + .create_namespace_wait(BoundedVec(full_path.as_bytes().to_vec()), keypair) .await { - Ok(_) => Ok(CallToolResult::success(vec![Content::text( - "namespace created", - )])), + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "namespace created: {full_path}" + ))])), Err(err) => { dbg!(&err); Err(ErrorData::invalid_request(err.to_string(), None)) @@ -129,24 +214,24 @@ pub async fn create_namespace_for_agent( } } +/// Deletes a namespace owned by the specified agent. +/// Constructs the full on-chain path as "agent.{agent_name}.{namespace_path}". pub async fn delete_namespace_for_agent( torus_client: &Client, request: NamespaceDeletionRequest, ) -> Result { let keypair = keypair_from_name(&request.agent_name)?; + let full_path = format!("agent.{}.{}", request.agent_name, request.namespace_path); match torus_client .torus0() .calls() - .delete_namespace_wait( - BoundedVec(request.namespace_path.as_bytes().to_vec()), - keypair, - ) + .delete_namespace_wait(BoundedVec(full_path.as_bytes().to_vec()), keypair) .await { - Ok(_) => Ok(CallToolResult::success(vec![Content::text( - "namespace deleted", - )])), + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "namespace deleted: {full_path}" + ))])), Err(err) => { dbg!(&err); Err(ErrorData::invalid_request(err.to_string(), None)) @@ -154,34 +239,38 @@ pub async fn delete_namespace_for_agent( } } +/// Delegates namespace permission from one agent to another. +/// This creates an on-chain permission contract that lets the recipient +/// operate within the specified namespace path. +/// Constructs the full on-chain path as "agent.{from_agent}.{namespace_path}". pub async fn delegate_namespace_permission_for_agent( torus_client: &Client, request: NamespaceDelegationRequest, ) -> Result { let from_keypair = keypair_from_name(&request.from_agent)?; - let to_account_id = keypair_from_name(&request.to_agent)? - .public_key() - .to_account_id(); + let to_account_id = account_id_from_name_or_ss58(&request.to_agent)?; + let full_path = format!("agent.{}.{}", request.from_agent, request.namespace_path); match torus_client .permission0() .calls() .delegate_namespace_permission_wait( to_account_id, + // Nested bounded collections: Map, Set> BoundedBTreeMap(vec![( - None, - BoundedBTreeSet(vec![BoundedVec(request.namespace_path.as_bytes().to_vec())]), + None, // No parent namespace (top-level) + BoundedBTreeSet(vec![BoundedVec(full_path.as_bytes().to_vec())]), )]), PermissionDuration::Indefinite, RevocationTerms::RevocableByDelegator, - 1, + 1, // Max usage count from_keypair, ) .await { - Ok(_) => Ok(CallToolResult::success(vec![Content::text( - "namespace deleted", - )])), + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "namespace permission delegated: {full_path}" + ))])), Err(err) => { dbg!(&err); Err(ErrorData::invalid_request(err.to_string(), None)) @@ -189,13 +278,20 @@ pub async fn delegate_namespace_permission_for_agent( } } +/// Gets a summary of ALL permissions affecting an account. +/// Iterates every permission on-chain and filters for ones where +/// this account is either the delegator or a recipient. +/// +/// This is the most complex read tool — it matches on all 4 permission +/// scope types (Stream, Curator, Namespace, Wallet) and converts each +/// into a simplified MCP-friendly representation. pub async fn get_permission_summary_for_agent( torus_client: &Client, request: PermissionSummaryRequest, ) -> Result { - let keypair = keypair_from_name(request.account_name)?; - let account_id = keypair.public_key().to_account_id(); + let account_id = account_id_from_name_or_ss58(request.account_name)?; + // Iterate ALL permissions on-chain (could be slow on a busy chain) let mut iter = match torus_client .permission0() .storage() @@ -212,7 +308,7 @@ pub async fn get_permission_summary_for_agent( let mut permissions = vec![]; while let Some(ele) = iter.next().await { - let (_hash, contract) = match ele { + let (hash, contract) = match ele { Ok(res) => res, Err(err) => { dbg!(&err); @@ -220,9 +316,21 @@ pub async fn get_permission_summary_for_agent( } }; + let permission_id = hash.to_string(); + + // Match on the permission scope type and build the appropriate response. + // Each arm first checks whether this account is actually involved (delegator OR recipient). + // If not, we skip the permission entirely — otherwise we'd return every permission on-chain. match contract.scope { + // --- Stream (emission) permissions --- PermissionScope::Stream(stream) => { - let direction = if contract.delegator == account_id { + let is_delegator = contract.delegator == account_id; + let is_recipient = stream.recipients.0.iter().any(|(k, _)| k == &account_id); + if !is_delegator && !is_recipient { + continue; + } + + let direction = if is_delegator { let recipients: Vec<_> = stream .recipients .0 @@ -252,69 +360,193 @@ pub async fn get_permission_summary_for_agent( DistributionControl::Interval(value) => Distribution::Interval(value), }; - let permission = StreamPermission { - allocation, - distribution, - recipients: stream - .recipients - .0 - .iter() - .map(|(account, amount)| (name_or_key(account), *amount)) - .collect(), - accumulating: stream.accumulating, - }; + let detail = Permission::Stream(( + StreamPermission { + allocation, + distribution, + recipients: stream + .recipients + .0 + .iter() + .map(|(account, amount)| (name_or_key(account), *amount)) + .collect(), + accumulating: stream.accumulating, + }, + direction, + )); - permissions.push(Permission::Stream((permission, direction))); + permissions.push(PermissionEntry { + id: permission_id, + detail, + }); } + // --- Curator permissions --- PermissionScope::Curator(curator) => { - let direction = if contract.delegator == account_id { + let is_delegator = contract.delegator == account_id; + let is_recipient = curator.recipient == account_id; + if !is_delegator && !is_recipient { + continue; + } + + let direction = if is_delegator { Direction::DelegatingTo(vec![name_or_key(&curator.recipient)]) } else { Direction::DelegatedFrom(name_or_key(&contract.delegator)) }; - permissions.push(Permission::Curator((CuratorPermission {}, direction))); + permissions.push(PermissionEntry { + id: permission_id, + detail: Permission::Curator((CuratorPermission {}, direction)), + }); } + // --- Namespace permissions --- PermissionScope::Namespace(namespace) => { - let direction = if contract.delegator == account_id { + let is_delegator = contract.delegator == account_id; + let is_recipient = namespace.recipient == account_id; + if !is_delegator && !is_recipient { + continue; + } + + let direction = if is_delegator { Direction::DelegatingTo(vec![name_or_key(&namespace.recipient)]) } else { Direction::DelegatedFrom(name_or_key(&contract.delegator)) }; + // A single permission can cover multiple paths under multiple parents for (parent, path) in namespace.paths.0 { for path in path.0 { - let permission = NamespacePermission { - path: String::from_utf8_lossy(&path.0.0[..]).to_string(), - parent: parent.map(|hash| hash.to_string()), - }; - - permissions.push(Permission::Namespace((permission, direction.clone()))); + let detail = Permission::Namespace(( + NamespacePermission { + path: String::from_utf8_lossy(&path.0.0[..]).to_string(), + parent: parent.map(|hash| hash.to_string()), + }, + direction.clone(), + )); + permissions.push(PermissionEntry { + id: permission_id.clone(), + detail, + }); } } } + // --- Wallet permissions --- PermissionScope::Wallet(wallet) => { - let direction = if contract.delegator == account_id { + let is_delegator = contract.delegator == account_id; + let is_recipient = wallet.recipient == account_id; + if !is_delegator && !is_recipient { + continue; + } + + let direction = if is_delegator { Direction::DelegatingTo(vec![name_or_key(&wallet.recipient)]) } else { Direction::DelegatedFrom(name_or_key(&contract.delegator)) }; - let permission = WalletPermission { - r#type: match wallet.r#type { - WalletScopeType::Stake(stake) => WalletPermissionType::Stake { - can_transfer_stake: stake.can_transfer_stake, - exclusive_stake_access: stake.exclusive_stake_access, + let detail = Permission::Wallet(( + WalletPermission { + r#type: match wallet.r#type { + WalletScopeType::Stake(stake) => WalletPermissionType::Stake { + can_transfer_stake: stake.can_transfer_stake, + exclusive_stake_access: stake.exclusive_stake_access, + }, }, }, - }; + direction, + )); - permissions.push(Permission::Wallet((permission, direction))); + permissions.push(PermissionEntry { + id: permission_id, + detail, + }); } } } - Ok(CallToolResult::success(vec![ - Content::json(PermissionSummaryResponse { permissions }).unwrap(), - ])) + let total = permissions.len(); + permissions.truncate(50); + + Ok(CallToolResult::success(vec![Content::json( + PermissionSummaryResponse { total, permissions }, + )?])) +} + +// ===================================================================== +// Permission management tools (added in MCP expansion) +// ===================================================================== + +/// Params for revoking a permission by its hash ID. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct RevokePermissionRequest { + /// Account that originally delegated the permission + account_name: String, + /// Hex-encoded H256 hash identifying the permission (e.g. "0xabcd...") + permission_id: String, +} + +/// Params for toggling emission accumulation on a permission. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct TogglePermissionAccumulationRequest { + account_name: String, + /// Hex-encoded H256 hash identifying the permission + permission_id: String, + /// true = accumulate emissions, false = distribute immediately + accumulating: bool, +} + +/// Revokes a permission contract by its ID. +/// Only the delegator (the one who created the permission) can revoke it, +/// and only if the revocation terms allow it. +pub async fn revoke_permission( + torus_client: &Client, + request: RevokePermissionRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + // Parse the hex string into an H256 hash + let permission_id = H256::from_str(&request.permission_id) + .map_err(|e| ErrorData::invalid_request(format!("Invalid permission ID: {e}"), None))?; + + match torus_client + .permission0() + .calls() + .revoke_permission_wait(permission_id, keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Permission revoked", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Toggles whether a stream permission accumulates emissions. +/// When accumulating=true, emissions build up until manually distributed. +/// When accumulating=false, emissions are distributed each epoch. +pub async fn toggle_permission_accumulation( + torus_client: &Client, + request: TogglePermissionAccumulationRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + let permission_id = H256::from_str(&request.permission_id) + .map_err(|e| ErrorData::invalid_request(format!("Invalid permission ID: {e}"), None))?; + + match torus_client + .permission0() + .calls() + .toggle_permission_accumulation_wait(permission_id, request.accumulating, keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Permission accumulation set to {}", + request.accumulating + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } } diff --git a/mcp/src/queries.rs b/mcp/src/queries.rs new file mode 100644 index 0000000..27a7b66 --- /dev/null +++ b/mcp/src/queries.rs @@ -0,0 +1,228 @@ +//! Read-only query tools for the Torus MCP server. +//! +//! These tools fetch on-chain state without submitting any transactions. +//! They're safe to call anytime — they don't cost gas or modify anything. +//! Think of them as "SELECT" queries against the blockchain's database. + +use rmcp::{ + ErrorData, + model::{CallToolResult, Content}, +}; +use torus_client::subxt::ext::futures::StreamExt; + +use crate::{ + Client, + utils::{account_id_from_name_or_ss58, name_or_key}, +}; + +// --- Response types --- + +/// One entry in the agents list. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct AgentListEntry { + /// Human-readable name the agent registered with + name: String, + /// Account address (dev name or SS58 address) + account: String, + /// API URL the agent exposes + url: String, + /// Free-form metadata string + metadata: String, +} + +/// Wrapper for the list_agents response. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct AgentListResponse { + agents: Vec, +} + +// --- Request types (only needed for tools that take parameters) --- + +/// Params for checking if an account is whitelisted. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct WhitelistCheckRequest { + /// Dev account name (e.g. "alice") or SS58 address (e.g. "5DoVVg...") + account_name: String, +} + +// --- Handler functions --- + +/// Lists all registered agents on the chain. +/// Iterates the Agents storage map and collects them into a JSON array. +pub async fn list_agents(torus_client: &Client) -> Result { + // agents_iter() returns an async stream of (AccountId, AgentData) pairs + let mut stream = match torus_client.torus0().storage().agents_iter().await { + Ok(stream) => stream, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + + let mut agents = Vec::new(); + while let Some(item) = stream.next().await { + let (id, agent) = match item { + Ok(kv) => kv, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + agents.push(AgentListEntry { + // Agent data is stored as raw bytes on-chain (BoundedVec) + // .0 accesses the inner Vec, then we convert to a String + name: String::from_utf8_lossy(&agent.name.0).to_string(), + account: name_or_key(&id), + url: String::from_utf8_lossy(&agent.url.0).to_string(), + metadata: String::from_utf8_lossy(&agent.metadata.0).to_string(), + }); + } + + Ok(CallToolResult::success(vec![Content::json( + AgentListResponse { agents }, + )?])) +} + +/// Gets the current burn amount — the cost (in planck) to register a new agent. +/// This value adjusts dynamically based on registration activity. +pub async fn get_burn_amount(torus_client: &Client) -> Result { + match torus_client.torus0().storage().burn().await { + // Returns Option — None means no burn is set (shouldn't happen normally) + Ok(amount) => Ok(CallToolResult::success(vec![Content::text(format!( + "{}", + amount.unwrap_or(0) + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Gets the full burn configuration (target registrations, adjust factor, etc.) +/// Returns a debug-formatted string of the config struct. +pub async fn get_burn_config(torus_client: &Client) -> Result { + match torus_client.torus0().storage().burn_config().await { + Ok(Some(config)) => Ok(CallToolResult::success(vec![Content::text(format!( + // {:?} is Rust's Debug format — prints the struct with field names + "{config:?}" + ))])), + Ok(None) => Ok(CallToolResult::success(vec![Content::text( + "No burn config found", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Gets the amount of tokens waiting to be distributed in the next emission epoch. +/// Emissions accumulate every block and get distributed at regular intervals. +pub async fn get_pending_emission(torus_client: &Client) -> Result { + match torus_client.emission0().storage().pending_emission().await { + Ok(amount) => Ok(CallToolResult::success(vec![Content::text(format!( + "{}", + amount.unwrap_or(0) + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Gets the incentives ratio — how emissions are split between miners and validators. +/// Returns a percentage (e.g. "50%" means 50% to miners, 50% to validators). +pub async fn get_incentives_ratio(torus_client: &Client) -> Result { + match torus_client.emission0().storage().incentives_ratio().await { + Ok(Some(ratio)) => Ok(CallToolResult::success(vec![Content::text(format!( + // ratio is a Percent struct, .0 gets the inner u8 value + "{}%", + ratio.0 + ))])), + Ok(None) => Ok(CallToolResult::success(vec![Content::text( + "No incentives ratio set", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Gets what percentage of emissions get recycled back into the system. +pub async fn get_emission_recycling_percentage( + torus_client: &Client, +) -> Result { + match torus_client + .emission0() + .storage() + .emission_recycling_percentage() + .await + { + Ok(Some(pct)) => Ok(CallToolResult::success(vec![Content::text(format!( + "{}%", + pct.0 + ))])), + Ok(None) => Ok(CallToolResult::success(vec![Content::text( + "No emission recycling percentage set", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Checks if a specific account is on the governance whitelist. +/// Whitelisted accounts have special privileges (e.g. can skip application process). +pub async fn check_whitelist_status( + torus_client: &Client, + request: WhitelistCheckRequest, +) -> Result { + let account_id = account_id_from_name_or_ss58(&request.account_name)?; + + match torus_client + .governance() + .storage() + .whitelist_get(&account_id) + .await + { + Ok(result) => { + // whitelist_get returns Option — Some means whitelisted, None means not + let is_whitelisted = result.is_some(); + Ok(CallToolResult::success(vec![Content::text(format!( + "{is_whitelisted}" + ))])) + } + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Gets the global governance configuration (vote thresholds, proposal costs, etc.) +/// Returns a debug-formatted string of the config struct. +pub async fn get_global_governance_config( + torus_client: &Client, +) -> Result { + match torus_client + .governance() + .storage() + .global_governance_config() + .await + { + Ok(Some(config)) => Ok(CallToolResult::success(vec![Content::text(format!( + "{config:?}" + ))])), + Ok(None) => Ok(CallToolResult::success(vec![Content::text( + "No global governance config found", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} diff --git a/mcp/src/staking.rs b/mcp/src/staking.rs new file mode 100644 index 0000000..19c6383 --- /dev/null +++ b/mcp/src/staking.rs @@ -0,0 +1,347 @@ +//! Staking tools for the Torus MCP server. +//! +//! These tools let you stake/unstake tokens on agents and query staking positions. +//! Staking is how you "vote with your wallet" — you lock TORUS tokens on an agent +//! to show support. Higher stake = more influence in the network. +//! +//! Signer fields (account_name) must be dev accounts (alice, bob, etc.) — we need the private key. +//! Agent target fields (agent_name, from_agent_name, to_agent_name) accept dev names OR SS58 addresses. +//! The `amount` fields are in planck (1 TORUS = 10^18 planck). + +use rmcp::{ + ErrorData, + model::{CallToolResult, Content}, +}; +use torus_client::subxt::ext::futures::StreamExt; + +use crate::{ + Client, + utils::{account_id_from_name_or_ss58, keypair_from_name, name_or_key}, +}; + +// --- Request structs --- +// Each struct defines the JSON parameters that the MCP tool accepts. +// The `derive` macros auto-generate: +// - JsonSchema: so MCP clients know what params are expected +// - Deserialize: to parse incoming JSON into this struct +// - Serialize: to convert back to JSON if needed + +/// Params for staking tokens on an agent. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct AddStakeRequest { + /// Dev account name doing the staking (e.g. "alice") — must be a dev account to sign + account_name: String, + /// Agent to stake on — dev account name (e.g. "bob") or SS58 address (e.g. "5DoVVg...") + agent_name: String, + /// Amount in planck (smallest unit). 1 TORUS = 10^18 planck. + amount: u128, +} + +/// Params for removing stake from an agent. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct RemoveStakeRequest { + /// Dev account name — must be a dev account to sign + account_name: String, + /// Agent to unstake from — dev account name or SS58 address + agent_name: String, + amount: u128, +} + +/// Params for moving stake from one agent to another (same staker). +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct TransferStakeRequest { + /// The account that owns the stake — must be a dev account to sign + account_name: String, + /// Agent to take stake from — dev account name or SS58 address + from_agent_name: String, + /// Agent to give stake to — dev account name or SS58 address + to_agent_name: String, + amount: u128, +} + +/// Params for querying a specific staker→agent stake amount. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct GetStakeRequest { + /// Dev account name or SS58 address of the staker + staker_name: String, + /// Dev account name or SS58 address of the agent + agent_name: String, +} + +/// Params for listing all staking positions of one account. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct GetStakesForAccountRequest { + /// Dev account name or SS58 address + account_name: String, +} + +/// Params for listing all stakers on a given agent (and their total staked amount). +/// Accepts dev account names (e.g. "alice") or SS58 addresses (mainnet agents). +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct GetStakersForAgentRequest { + /// Dev account name (e.g. "bob") or SS58 address (e.g. "5DoVVg...") + agent_name: String, +} + +// --- Response structs --- +// These define what the tool returns as JSON to the MCP client. + +/// A single staking position (who and how much). +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct StakeEntry { + /// Agent name or SS58 address if not a dev account + agent: String, + /// Staked amount in planck + amount: u128, +} + +/// List of all staking positions for an account. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct StakesResponse { + stakes: Vec, +} + +/// One staker and their staked amount on a specific agent. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct StakerEntry { + /// Staker account name or SS58 address + staker: String, + /// Staked amount in planck + amount: u128, +} + +/// All stakers on an agent, plus the total sum. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct StakersResponse { + stakers: Vec, + /// Sum of all individual stakes (in planck) + total_stake: u128, +} + +// --- Handler functions --- +// Each function follows the same pattern: +// 1. Convert account name strings → keypairs using keypair_from_name() +// 2. Call the chain via torus_client.pallet().calls().method_wait(params, signer) +// The "_wait" suffix means it waits for the transaction to be included in a block. +// 3. Match on Ok/Err and return a CallToolResult + +/// Stakes tokens from a staker account onto an agent. +/// This locks the tokens — they can't be transferred while staked. +pub async fn add_stake( + torus_client: &Client, + request: AddStakeRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + let agent_id = account_id_from_name_or_ss58(&request.agent_name)?; + + match torus_client + .torus0() + .calls() + .add_stake_wait( + agent_id, + request.amount, // how much + keypair, // who is signing/paying + ) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Successfully staked {} to agent {}", + request.amount, request.agent_name + ))])), + Err(err) => { + dbg!(&err); // Print to stderr for debugging + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Removes (unstakes) tokens from an agent back to the staker. +pub async fn remove_stake( + torus_client: &Client, + request: RemoveStakeRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + let agent_id = account_id_from_name_or_ss58(&request.agent_name)?; + + match torus_client + .torus0() + .calls() + .remove_stake_wait(agent_id, request.amount, keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Successfully removed {} stake from agent {}", + request.amount, request.agent_name + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Moves stake from one agent to another without unstaking first. +/// The signer (account_name) must be the one who originally staked. +pub async fn transfer_stake( + torus_client: &Client, + request: TransferStakeRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + let from_id = account_id_from_name_or_ss58(&request.from_agent_name)?; + let to_id = account_id_from_name_or_ss58(&request.to_agent_name)?; + + match torus_client + .torus0() + .calls() + .transfer_stake_wait(from_id, to_id, request.amount, keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Successfully transferred {} stake from agent {} to agent {}", + request.amount, request.from_agent_name, request.to_agent_name + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Queries the exact stake amount between a specific staker and agent. +/// This is a read-only storage query — no transaction needed. +pub async fn get_stake( + torus_client: &Client, + request: GetStakeRequest, +) -> Result { + let staker_id = account_id_from_name_or_ss58(&request.staker_name)?; + let agent_id = account_id_from_name_or_ss58(&request.agent_name)?; + + // Storage queries use .storage() instead of .calls() + // They read on-chain state without submitting a transaction + match torus_client + .torus0() + .storage() // Read-only access to on-chain storage + .staking_to_get(&staker_id, &agent_id) + .await + { + // Returns Option — None means no stake exists + Ok(amount) => Ok(CallToolResult::success(vec![Content::text(format!( + "{}", + amount.unwrap_or(0) + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} + +/// Lists all staking positions for one account across all agents. +/// Uses an iterator to scan the storage map. +pub async fn get_stakes_for_account( + torus_client: &Client, + request: GetStakesForAccountRequest, +) -> Result { + let account_id = account_id_from_name_or_ss58(&request.account_name)?; + + // _iter1 means "iterate the second key of a double-map, with the first key fixed" + // The StakingTo map is: (staker, agent) → amount + // So iter1(staker) gives us all (staker, agent) pairs for that staker + let mut stream = match torus_client + .torus0() + .storage() + .staking_to_iter1(&account_id) + .await + { + Ok(stream) => stream, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + + let mut stakes = Vec::new(); + while let Some(item) = stream.next().await { + let ((_staker, agent), amount) = match item { + Ok(kv) => kv, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + stakes.push(StakeEntry { + agent: name_or_key(&agent), // Shows "bob" instead of raw address for dev accounts + amount, + }); + } + + // Return as JSON using Content::json() + Ok(CallToolResult::success(vec![Content::json( + StakesResponse { stakes }, + )?])) +} + +/// Lists all stakers on a specific agent and the total stake they hold. +/// +/// Uses `StakedBy` storage (indexed by agent → staker → amount) to efficiently +/// fetch all staking positions for a given agent without scanning the full map. +/// Accepts both dev account names and mainnet SS58 addresses. +pub async fn get_stakers_for_agent( + torus_client: &Client, + request: GetStakersForAgentRequest, +) -> Result { + let agent_id = account_id_from_name_or_ss58(&request.agent_name)?; + + // staked_by_iter1 iterates the StakedBy double-map with the agent key fixed, + // yielding all (agent, staker) → amount entries for that agent. + let mut stream = match torus_client + .torus0() + .storage() + .staked_by_iter1(&agent_id) + .await + { + Ok(stream) => stream, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + + let mut stakers = Vec::new(); + let mut total_stake = 0u128; + while let Some(item) = stream.next().await { + let ((_, staker), amount) = match item { + Ok(kv) => kv, + Err(err) => { + dbg!(&err); + return Err(ErrorData::internal_error(err.to_string(), None)); + } + }; + total_stake = total_stake.saturating_add(amount); + stakers.push(StakerEntry { + staker: name_or_key(&staker), + amount, + }); + } + + Ok(CallToolResult::success(vec![Content::json( + StakersResponse { + stakers, + total_stake, + }, + )?])) +} + +/// Gets the total stake across the entire network. +pub async fn get_total_stake(torus_client: &Client) -> Result { + match torus_client.torus0().storage().total_stake().await { + Ok(amount) => Ok(CallToolResult::success(vec![Content::text(format!( + "{}", + amount.unwrap_or(0) + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} diff --git a/mcp/src/transfer.rs b/mcp/src/transfer.rs new file mode 100644 index 0000000..1581637 --- /dev/null +++ b/mcp/src/transfer.rs @@ -0,0 +1,61 @@ +//! Token transfer tool for the Torus MCP server. +//! +//! This sends TORUS tokens from one account to another. +//! Uses `transfer_keep_alive` which prevents the sender's account +//! from being "reaped" (deleted) if their balance drops below the +//! existential deposit (0.1 TORUS). The transaction will fail instead. + +use rmcp::{ + ErrorData, + model::{CallToolResult, Content}, +}; + +// MultiAddress wraps an AccountId for extrinsic parameters. +// The `Id` variant means "use a raw AccountId32" (as opposed to Index, Address20, etc.) +use torus_client::subxt::utils::MultiAddress; + +use crate::{ + Client, + utils::{account_id_from_name_or_ss58, keypair_from_name}, +}; + +/// Params for transferring tokens between two accounts. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct TransferBalanceRequest { + /// Dev account sending the tokens (e.g. "alice") — must be a dev account to sign + from_account_name: String, + /// Dev account receiving the tokens (e.g. "bob") or SS58 address (e.g. "5DoVVg...") + to_account_name: String, + /// Amount in planck (1 TORUS = 10^18 planck) + amount: u128, +} + +/// Transfers tokens from one dev account to another account or SS58 address. +/// This submits an extrinsic to the chain and waits for block inclusion. +pub async fn transfer_balance( + torus_client: &Client, + request: TransferBalanceRequest, +) -> Result { + let from_keypair = keypair_from_name(&request.from_account_name)?; + let to_id = account_id_from_name_or_ss58(&request.to_account_name)?; + + // Wrap the destination AccountId in MultiAddress::Id — this is how + // Substrate's balances pallet expects the destination parameter + let dest = MultiAddress::Id(to_id); + + match torus_client + .balances() // Access the balances pallet (standard Substrate pallet) + .calls() + .transfer_keep_alive_wait(dest, request.amount, from_keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Successfully transferred {} from {} to {}", + request.amount, request.from_account_name, request.to_account_name + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} diff --git a/mcp/src/utils.rs b/mcp/src/utils.rs index e27e881..1632e12 100644 --- a/mcp/src/utils.rs +++ b/mcp/src/utils.rs @@ -1,8 +1,22 @@ +//! Shared utility functions used across all MCP tool modules. +//! +//! The main helpers here solve the "name ↔ keypair/AccountId" mapping problem: +//! - `keypair_from_name`: "alice" → Keypair (for signing transactions) +//! - `account_id_from_name_or_ss58`: "alice" or "5Grw..." → AccountId32 (for read-only queries) +//! - `name_or_key`: AccountId → "alice" or "5Grw..." (for display) + use rmcp::ErrorData; use torus_client::{subxt::utils::AccountId32, subxt_signer::sr25519::Keypair}; use crate::ACCOUNTS; +/// Converts a dev account name (like "alice") into a signing Keypair. +/// +/// This is used by every write tool to get the private key for signing +/// transactions. The name is case-insensitive ("Alice" and "alice" both work). +/// +/// Returns an MCP error if the name doesn't match any dev account. +/// Valid names: alice, bob, charlie, dave, eve, ferdie, one, two pub fn keypair_from_name(name: impl AsRef) -> Result { let name = name.as_ref().to_lowercase(); ACCOUNTS @@ -10,9 +24,39 @@ pub fn keypair_from_name(name: impl AsRef) -> Result { .ok_or_else(|| { ErrorData::invalid_request(format!("{name} is not a valid account name."), None) }) - .cloned() + .cloned() // Keypair implements Clone, so we clone from the global HashMap +} + +/// Resolves a dev account name (e.g. "alice") or an SS58 address to an AccountId32. +/// +/// Useful for read-only tools that don't need to sign transactions but need to +/// query on-chain data for arbitrary accounts — including mainnet agents. +/// +/// Resolution order: +/// 1. Tries as a known dev account name (case-insensitive) +/// 2. Falls back to SS58 address parsing +pub fn account_id_from_name_or_ss58(input: impl AsRef) -> Result { + let input = input.as_ref(); + + if let Some(keypair) = ACCOUNTS.get(&input.to_lowercase()) { + return Ok(keypair.public_key().to_account_id()); + } + + input.parse::().map_err(|_| { + ErrorData::invalid_request( + format!("'{input}' is neither a valid account name nor a valid SS58 address."), + None, + ) + }) } +/// Converts an AccountId32 back to a human-readable name. +/// +/// If the AccountId matches a known dev account, returns the name (e.g. "alice"). +/// Otherwise, returns the SS58-encoded address string (e.g. "5GrwvaEF5zXb26F..."). +/// +/// This is used by read tools to make output more readable — instead of +/// showing raw 32-byte addresses, you see "alice" or "bob". pub fn name_or_key(account_id: &AccountId32) -> String { ACCOUNTS .iter() diff --git a/mcp/src/weight_control.rs b/mcp/src/weight_control.rs new file mode 100644 index 0000000..c231271 --- /dev/null +++ b/mcp/src/weight_control.rs @@ -0,0 +1,122 @@ +//! Weight control delegation tools for the Torus MCP server. +//! +//! "Weights" are how validators rate miners (0–65535 per miner). +//! These ratings directly control how emission rewards get distributed. +//! +//! Weight control delegation lets a validator say "hey, I trust you to +//! set weights on my behalf." The delegator pays a fee (weight_control_fee) +//! to the delegatee for this service. +//! +//! This is useful when a validator wants to participate in staking rewards +//! but doesn't want to actively assess miners themselves. + +use rmcp::{ + ErrorData, + model::{CallToolResult, Content}, +}; + +use crate::{ + Client, + utils::{account_id_from_name_or_ss58, keypair_from_name, name_or_key}, +}; + +/// Params for delegating weight control to another validator. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct DelegateWeightControlRequest { + /// The validator delegating their weight control (must be a dev account — signs the tx) + account_name: String, + /// The validator receiving the delegation — dev account name or SS58 address + target_name: String, +} + +/// Params for taking back weight control from a delegation. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct RegainWeightControlRequest { + account_name: String, +} + +/// Params for checking who an account has delegated weight control to. +#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +pub struct GetWeightControlDelegationRequest { + /// Dev account name (e.g. "alice") or SS58 address (e.g. "5DoVVg...") + account_name: String, +} + +/// Delegates weight control to another validator. +/// After this, the target validator's weights will be used for this account +/// during emission distribution, and this account pays them a fee. +pub async fn delegate_weight_control( + torus_client: &Client, + request: DelegateWeightControlRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + let target_id = account_id_from_name_or_ss58(&request.target_name)?; + + match torus_client + .emission0() // Weight control lives in the emission pallet + .calls() + .delegate_weight_control_wait(target_id, keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Weight control delegated to {}", + request.target_name + ))])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Takes back weight control after a previous delegation. +/// After this, the account must set their own weights again. +pub async fn regain_weight_control( + torus_client: &Client, + request: RegainWeightControlRequest, +) -> Result { + let keypair = keypair_from_name(&request.account_name)?; + + match torus_client + .emission0() + .calls() + .regain_weight_control_wait(keypair) + .await + { + Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Weight control regained", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::invalid_request(format!("{err:?}"), None)) + } + } +} + +/// Checks if an account has delegated weight control and to whom. +/// Returns the delegatee's name/address, or "Not delegating" if none. +pub async fn get_weight_control_delegation( + torus_client: &Client, + request: GetWeightControlDelegationRequest, +) -> Result { + let account_id = account_id_from_name_or_ss58(&request.account_name)?; + + match torus_client + .emission0() + .storage() + .weight_control_delegation_get(&account_id) + .await + { + Ok(Some(delegatee)) => Ok(CallToolResult::success(vec![Content::text(format!( + "Delegated to: {}", + name_or_key(&delegatee) + ))])), + Ok(None) => Ok(CallToolResult::success(vec![Content::text( + "Not delegating weight control", + )])), + Err(err) => { + dbg!(&err); + Err(ErrorData::internal_error(err.to_string(), None)) + } + } +} diff --git a/mcp/src/weights.rs b/mcp/src/weights.rs index cfdb8ab..c5630d5 100644 --- a/mcp/src/weights.rs +++ b/mcp/src/weights.rs @@ -1,3 +1,13 @@ +//! Validator weight-setting tool for the Torus MCP server. +//! +//! Validators rate miners by assigning "weights" — numbers from 0 to 65535. +//! These weights directly control how emission rewards are distributed: +//! - Higher weight on a miner = that miner gets more incentive tokens +//! - The validator who rated productive miners also gets more dividends +//! +//! Weights are set as a batch — you provide a map of {agent_name: weight} +//! and all weights get updated at once in a single extrinsic. + use std::collections::HashMap; use rmcp::{ @@ -6,30 +16,42 @@ use rmcp::{ }; use torus_client::subxt::utils::AccountId32; -use crate::{Client, utils::keypair_from_name}; +use crate::{ + Client, + utils::{account_id_from_name_or_ss58, keypair_from_name}, +}; +/// Params for setting weights. #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] pub struct SetWeightsRequest { + /// The validator setting weights (must be an allocator with enough stake) account_name: String, + /// Map of agent_name → weight (0–65535). + /// Example: {"bob": 10000, "charlie": 5000} + /// Keys can be dev account names or SS58 addresses. + /// Higher value = more reward allocated to that agent. weights_by_account_name: HashMap, } +/// Sets weights for a validator. +/// Converts the name→weight map into AccountId→weight pairs, then submits. pub async fn set_weights( torus_client: &Client, request: SetWeightsRequest, ) -> Result { let keypair = keypair_from_name(request.account_name)?; + + // Convert each agent name to its AccountId32 + // The collect::, _>>()? pattern collects results and + // short-circuits on the first error (if any name is invalid) let weights = request .weights_by_account_name .into_iter() - .map(|(name, weight)| match keypair_from_name(name) { - Ok(keypair) => Ok((keypair.public_key().to_account_id(), weight)), - Err(err) => Err(err), - }) + .map(|(name, weight)| account_id_from_name_or_ss58(&name).map(|id| (id, weight))) .collect::, _>>()?; match torus_client - .emission0() + .emission0() // Weights live in the emission pallet .calls() .set_weights_wait(weights, keypair) .await