agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// MCP stdio server exposing agent-facing AgentSwap tools.
// Exports: Config and serve_stdio.
// Deps: rmcp, crate::{client, redact, service, signer}.

use crate::client::Client;
use crate::service::submit::{NotConfirmed, Wait};
use crate::service::{intent, market, quote, trade, portfolio, grant_link, batch_sell, sweep};
use crate::signer::Signer;
use crate::tokens::{chain_name_to_id, unknown_chain_id};
use eyre::Result;
use rmcp::{
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::{ServerCapabilities, ServerInfo},
    tool, tool_handler, tool_router, Json, ServerHandler, ServiceExt,
};
use serde::Serialize;
mod models;
use models::*;
use std::sync::Arc;

#[cfg(test)]
mod tests;
#[cfg(test)]
mod portfolio_tests;
#[cfg(test)]
mod batch_sell_tests;
#[cfg(test)]
mod cli_dry_run_tests;
#[cfg(test)]
mod dry_run_fixture;
#[cfg(test)]
mod dry_run_tests;

#[derive(Clone)]
pub struct Config {
    pub client: Client,
    pub intent_client: Client,
    pub signer: Option<Arc<dyn Signer>>,
    pub allow_trade: bool,
    /// Operator-set per-trade notional cap; bounds any client-supplied cap.
    pub trade_max_amount: Option<String>,
}

#[derive(Clone)]
struct AgentSwapMcp {
    tool_router: ToolRouter<Self>,
    client: Client,
    intent_client: Client,
    signer: Option<Arc<dyn Signer>>,
    allow_trade: bool,
    trade_max_amount: Option<String>,
}

impl AgentSwapMcp {
    fn new(config: Config) -> Self {
        let mut tool_router = Self::tool_router();
        for route in tool_router.map.values_mut().filter(|route| matches!(
            route.attr.name.as_ref(), "trade" | "intent_place" | "intent_list" | "intent_status" | "policy"
        )) {
            route.attr.description = Some(format!("{} {}",
                route.attr.description.as_deref().unwrap_or_default(), crate::tokens::PROTOCOL_CHAINS_NOTE).into());
        }
        for route in tool_router.map.values_mut().filter(|route| matches!(route.attr.name.as_ref(), "portfolio" | "grant_link" | "batch_sell_plan" | "batch_sell_run" | "batch_sell_report")) {
            route.attr.description = Some(format!("{} {}", route.attr.description.as_deref().unwrap_or_default(), crate::tokens::HOLDINGS_CHAINS_NOTE).into());
        }
        Self {
            tool_router,
            client: config.client,
            intent_client: config.intent_client,
            signer: config.signer,
            allow_trade: config.allow_trade,
            trade_max_amount: config.trade_max_amount,
        }
    }
}

#[tool_router(router = tool_router)]
impl AgentSwapMcp {
    #[tool(description = "Create one unsigned batch-sell review request with exact balance caps and at most 100 tokens including receive. max_loss_bps is required; min_usd, max_usd, tokens and exclude select holdings without a default size threshold. Always pass name, a short agent name the owner will recognise on the review page (at most 32 characters); note gives the owner the reason for the sale (at most 140). Give the owner the returned URL, then use batch_sell_run after confirmation.")]
    async fn batch_sell_plan(&self, Parameters(input): Parameters<batch_sell::PlanInput>) -> std::result::Result<Json<batch_sell::PlanOutput>, String> {
        batch_sell::plan(&self.client, input).await.map(Json).map_err(tool_error)
    }

    #[tool(description = "Run a confirmed batch-sell request by id or app URL. Uses its tokens, proxy, receive token and owner-confirmed discount. Requires --key-file and --allow-trade for live signing; otherwise returns an unsigned preview. via defaults to intent; market pays gas from the agent wallet. start_premium_bps (0 to 1000, default 100) starts each intent above independent market value and its price falls linearly to the confirmed discount floor at expiry. wait bounds intent status polling. Returns placed/sold tokens, received amounts when known, and not-sold reasons. Afterwards send the owner the batch_sell_report result, and again after open intents close.")]
    async fn batch_sell_run(&self, Parameters(input): Parameters<batch_sell::RunInput>) -> std::result::Result<Json<sweep::Output>, String> {
        let record = batch_sell::load(&input).await.map_err(tool_error)?;
        let signer = self.signer.clone().ok_or("batch_sell_run requires --key-file")?;
        let client = if input.via == batch_sell::Via::Intent { &self.intent_client } else { &self.client };
        let output = batch_sell::run(client, signer, input, record, self.allow_trade, self.trade_max_amount.as_deref(), Wait::MCP).await.map_err(tool_error)?;
        if let Err(error) = output.check() {
            return Err(format!("{}\n{}", tool_error(error), serde_json::to_string(&output).map_err(|e| e.to_string())?));
        }
        Ok(Json(output))
    }

    #[tool(description = "Report a confirmed batch-sell request by id or app URL for its owner; needs no key. Per token: status (sold, partly_sold, open, expired, not_placed, unsold, or unknown while the intent index or the lens does not answer), cap, amount sold from the grant budget, net proceeds from fill records, value at independent prices, discount versus market in percent, and fill transaction and time; then totals, average and worst discount, unsold value, grant expiry and next steps. Reads the request, one batched grant read, the intentscan.net intent index for orders and fill records, and one batched lens read for each listed intent's status; no log scans. Send the owner this report after every batch_sell_run, and again after open intents close.")]
    async fn batch_sell_report(&self, Parameters(input): Parameters<batch_sell::ReportInput>) -> std::result::Result<Json<batch_sell::ReportOutput>, String> {
        batch_sell::report(&input).await.map(Json).map_err(tool_error)
    }

    #[tool(description = "Discover ERC-20 holdings from wallet-tokens and explicit addresses without scanning logs. Indexed holdings include balances and metadata; service catalog and registry reads are used only when indexing is unavailable. App prices include provenance and floor_eligible; Alchemy fallback prices are display-only and never floor-eligible. max_usd is an unsigned USD decimal, quotes full eligible balances and unpriced tokens. Quotes never sign payments. Discovery may be incomplete.")]
    async fn portfolio(&self, Parameters(input): Parameters<portfolio::Input>) -> std::result::Result<Json<portfolio::Output>, String> {
        portfolio::portfolio(&self.client, input).await.map(Json).map_err(tool_error)
    }

    #[tool(description = "Create an advisory grant URL without signing. Requires ERC-20 spend addresses, receive token, and either one_shot or epoch plus expiry. Recurring caps are explicit raw integers; one_shot omitted caps use current balances. Rejects duplicates, native tokens, more than 20 tokens, no positive cap and human caps longer than 32 characters. Live policies require replace.")]
    async fn grant_link(&self, Parameters(input): Parameters<grant_link::Input>) -> std::result::Result<Json<grant_link::Output>, String> {
        grant_link::grant_link(input).await.map(Json).map_err(tool_error)
    }

    #[tool(description = "Get a swap quote, including Ethereum (1). BNB Smart Chain uses the meta-aggregator and requires the owner's V6 proxy as taker. Amount is an unsigned decimal integer in the input token's smallest unit.")]
    async fn quote(&self, Parameters(input): Parameters<quote::QuoteInput>) -> std::result::Result<Json<quote::QuoteOutput>, String> {
        quote::quote(&self.client, input)
            .await
            .map(Json)
            .map_err(tool_error)
    }

    #[tool(description = "Get quotes for multiple FROM/TO pairs. BNB Smart Chain uses the meta-aggregator and requires the owner's V6 proxy as taker. Amount is an unsigned decimal integer in the input token's smallest unit.")]
    async fn batch_quote(&self, Parameters(input): Parameters<BatchQuoteInput>) -> std::result::Result<Json<BatchQuoteOutput>, String> {
        quote::batch_quote(&self.client, &input.chain_id, &input.pairs, &input.amount, input.taker.as_deref())
            .await
            .map(|results| Json(BatchQuoteOutput { results }))
            .map_err(tool_error)
    }

    #[tool(description = "List supported tokens, including Ethereum (1), optionally filtered by chain_id.")]
    async fn tokens(
        &self,
        Parameters(input): Parameters<TokensInput>,
    ) -> std::result::Result<Json<ValueOutput>, String> {
        if let Some(chain_id) = input.chain_id.as_deref() {
            chain_name_to_id(chain_id).ok_or_else(|| unknown_chain_id(chain_id))?;
        }
        match market::tokens(&self.client).await {
            Ok(tokens) => filter_tokens(tokens, input.chain_id.as_deref())
                .map(|value| Json(ValueOutput { value })),
            Err(e) => Err(tool_error(e)),
        }
    }

    #[tool(description = "Inspect a pool by chain ID and address.")]
    async fn pools(
        &self,
        Parameters(input): Parameters<PoolsInput>,
    ) -> std::result::Result<Json<ValueOutput>, String> {
        market::pool(&self.client, &input.chain_id, &input.address)
            .await
            .map(|value| Json(ValueOutput { value }))
            .map_err(tool_error)
    }

    #[tool(description = "Preview an unsigned AgentOrder or sign a live trade; amount, min_out and max_amount are unsigned decimal integers in raw token units. A live trade (dry_run=false) requires --allow-trade and min_out, because the quote server's output is not trusted as the protection floor. dry_run defaults to true and is forced true without --allow-trade. A dry-run returns the quote, unsigned AgentOrder and digest after reading policy generation and checking hash parity against a reachable RPC and deployed V6 proxy. It never signs or returns a signature or signed calldata, and never sends transactions. With self_submit, the result carries self_submit.txHash and txStatus; the receipt wait ends before common MCP request timeouts, and a reverted transaction, or one sent whose receipt was not read, is a tool error that carries the full outcome.")]
    async fn trade(
        &self,
        Parameters(mut input): Parameters<trade::TradeInput>,
    ) -> std::result::Result<Json<trade::TradeOutcome>, String> {
        crate::evm::agent_chain_config(&input.chain_id).map_err(tool_error)?;
        if !self.allow_trade {
            input.dry_run = true;
        }
        // Bound the client-supplied per-trade cap by the operator's server cap, if set.
        if let Some(server_cap) = &self.trade_max_amount {
            bound_trade_cap(&mut input, server_cap)?;
        }
        let Some(signer) = self.signer.clone() else {
            return Err("trade requires --key-file".to_string());
        };
        let outcome = trade::execute_trade(&self.client, signer, input, self.allow_trade, Wait::MCP)
            .await
            .map_err(tool_error)?;
        let failure = outcome.not_confirmed();
        confirmed(outcome, failure)
    }

    #[tool(description = "Preview an unsigned intent or sign and announce live; amount, start_out, end_out and max_amount are unsigned decimal integers in raw token units. dry_run is forced true without --allow-trade. A dry-run returns the unsigned intent and digest, plus agent authorization on V6, after on-chain hash parity checks; it never signs, creates or returns a signature or envelope, relays, publishes or sends transactions. Signature-based authorization validation runs only live. A live announcement requires --allow-trade and exactly one of relay, self_submit or broadcast. With self_submit, the result carries tx_hash and tx_status; the receipt wait ends before common MCP request timeouts, and a reverted announce, or one sent whose receipt was not read, is a tool error that carries the full outcome. Requires a reachable RPC and protocol deployment. Ethereum uses the owner signer and V7 proxy, with no agent authorization, deadline_secs or sponsored relay. broadcast publishes off chain under agentswap-v6 with the chain settler; on error, admission is unknown and the error carries the intent id and stream locator. The stream locator includes owner-history lookup instructions. Re-running after an error signs a new intent and may duplicate a still-active intent; a missing history record does not prove rejection. intent_status and intent_list by owner read the intent index first; intent_list by agent finds only on-chain announcements.")]
    async fn intent_place(
        &self,
        Parameters(mut input): Parameters<intent::PlaceInput>,
    ) -> std::result::Result<Json<intent::PlaceOutcome>, String> {
        if !self.allow_trade { input.dry_run = true; }
        bound_intent_cap(&mut input, self.trade_max_amount.as_deref())?;
        let Some(signer) = self.signer.clone() else { return Err("intent_place requires --key-file".to_string()); };
        let announcer = intent::Announcer { relay: &self.intent_client, wait: Wait::MCP, pacer: None };
        let outcome = intent::place(announcer, input, signer, self.allow_trade)
            .await.map_err(tool_error)?;
        let failure = outcome.not_confirmed();
        confirmed(outcome, failure)
    }

    #[tool(description = "List intents on chain_id. Either owner or agent is required; a call with neither is refused. By owner, intents come from the intentscan.net intent index, which also holds intents published only to the off-chain stream; by agent, or when the index does not answer, from IntentAnnounced logs within the lookback.")]
    async fn intent_list(
        &self,
        Parameters(input): Parameters<intent::ListInput>,
    ) -> std::result::Result<Json<IntentListOutput>, String> {
        if input.owner.is_none() && input.agent.is_none() {
            return Err("intent_list requires owner or agent".to_string());
        }
        intent::list(input).await.map(|intents| Json(IntentListOutput { intents })).map_err(tool_error)
    }

    #[tool(description = "Inspect one intent by bytes32 id on chain_id: its order from the intentscan.net intent index, which also holds intents published only to the off-chain stream, else from an IntentAnnounced log, with live state from IntentLensV3; with neither, the settler's filled or cancelled state.")]
    async fn intent_status(
        &self,
        Parameters(input): Parameters<intent::StatusInput>,
    ) -> std::result::Result<Json<intent::IntentRecord>, String> {
        intent::status(input).await.map(Json).map_err(tool_error)
    }

    #[tool(description = "Read a V6 agent policy and per-token cap/usage on chain_id. Token addresses are discovered from cap events inside the log lookback and current budgets are read from the proxy; pass tokens to include addresses older than the lookback.")]
    async fn policy(
        &self,
        Parameters(input): Parameters<intent::PolicyInput>,
    ) -> std::result::Result<Json<intent::PolicyOutput>, String> {
        intent::policy(input).await.map(Json).map_err(tool_error)
    }
}

#[tool_handler(router = self.tool_router)]
impl ServerHandler for AgentSwapMcp {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
            .with_instructions("AgentSwap tools: quote, batch_quote, tokens, pools, trade, intent_place, intent_list, intent_status, policy, portfolio, grant_link, batch_sell_plan, batch_sell_run, batch_sell_report. Use batch_sell_plan, give the owner the returned URL, then batch_sell_run after confirmation; send the owner the batch_sell_report result after each run and again after open intents close. Signing requires --allow-trade. Portfolio and grant_link are read-only; grant links require owner review in the app.")
    }
}

/// Tool error text, with RPC and service URLs reduced to their host.
fn tool_error(error: eyre::Report) -> String {
    crate::redact::urls(&format!("{error}"))
}

/// A sent transaction that did not confirm is a tool error; its text carries the full outcome,
/// hash included.
fn confirmed<T: Serialize>(outcome: T, failure: Option<NotConfirmed>) -> std::result::Result<Json<T>, String> {
    let Some(failure) = failure else { return Ok(Json(outcome)); };
    let detail = serde_json::to_string(&outcome).unwrap_or_default();
    Err(format!("{failure}\n{detail}"))
}

fn bound_intent_cap(input: &mut intent::PlaceInput, server_cap: Option<&str>) -> std::result::Result<(), String> {
    let Some(server_cap) = server_cap else { return Ok(()); };
    let server = crate::order_types::parse_raw_amount("MCP intent max-amount", server_cap).map_err(|e| format!("{e}"))?;
    let client = input.max_amount.as_deref().map(|value| crate::order_types::parse_raw_amount("MCP intent max-amount", value)).transpose().map_err(|e| format!("{e}"))?;
    input.max_amount = Some(client.map_or_else(|| server_cap.to_string(), |value| value.min(server).to_string()));
    Ok(())
}

fn bound_trade_cap(input: &mut trade::TradeInput, server_cap: &str) -> std::result::Result<(), String> {
    let server = crate::order_types::parse_raw_amount("MCP trade max-amount", server_cap).map_err(|e| format!("{e}"))?;
    let client = input.max_amount.as_deref().map(|value| crate::order_types::parse_raw_amount("MCP trade max-amount", value)).transpose().map_err(|e| format!("{e}"))?;
    input.max_amount = Some(client.map_or_else(|| server_cap.to_string(), |value| value.min(server).to_string()));
    Ok(())
}

pub async fn serve_stdio(config: Config) -> Result<()> {
    let service = AgentSwapMcp::new(config)
        .serve(rmcp::transport::stdio())
        .await?;
    service.waiting().await?;
    Ok(())
}

fn filter_tokens(tokens: serde_json::Value, chain_id: Option<&str>) -> std::result::Result<serde_json::Value, String> {
    let Some(chain_id) = chain_id else {
        return Ok(tokens);
    };
    let chain_id = chain_name_to_id(chain_id).ok_or_else(|| unknown_chain_id(chain_id))?;
    let Some(object) = tokens.as_object() else {
        return Ok(tokens);
    };
    let filtered = object
        .iter()
        .filter(|(_, token)| token["chain_id"].as_u64() == Some(chain_id))
        .map(|(addr, token)| (addr.clone(), token.clone()))
        .collect();
    Ok(serde_json::Value::Object(filtered))
}

#[cfg(test)]
mod fix_tests;