Skip to main content

agentis_pay/mcp/
handler.rs

1use std::collections::HashMap;
2use std::str::FromStr;
3use std::sync::{Arc, Mutex as StdMutex};
4use std::time::{Duration, Instant};
5
6use agentis_pay_shared::types::pix_payment_key::PixKeyPayment;
7use rmcp::handler::server::ServerHandler;
8use rmcp::handler::server::tool::ToolRouter;
9use rmcp::handler::server::wrapper::Parameters;
10use rmcp::model::{
11    CallToolResult, Content, Implementation, InitializeRequestParams, InitializeResult,
12    ServerCapabilities,
13};
14use rmcp::service::RequestContext;
15use rmcp::{ErrorData as McpError, RoleServer, tool, tool_handler, tool_router};
16use serde_json::json;
17use tokio::sync::Mutex;
18
19use crate::CommandName;
20use crate::display::OutputEnvelope;
21use crate::mcp::backend::{
22    BrcodeDecodeInput, HistoryInput, HistoryQuery, HistoryRecord, PayInput, PixBackend,
23};
24
25const MAX_BRCODE_LENGTH: usize = 4_096;
26const PAY_RATE_LIMIT: RateLimitRule = RateLimitRule {
27    limit: 5,
28    window: Duration::from_secs(60),
29};
30const BRCODE_RATE_LIMIT: RateLimitRule = RateLimitRule {
31    limit: 20,
32    window: Duration::from_secs(60),
33};
34
35#[derive(Clone)]
36pub struct AgentisPayServer {
37    backend: Arc<Mutex<Box<dyn PixBackend>>>,
38    rate_limiter: RateLimiter,
39    tool_router: ToolRouter<Self>,
40}
41
42#[derive(Clone, Default)]
43struct RateLimiter {
44    buckets: Arc<StdMutex<HashMap<&'static str, FixedWindow>>>,
45}
46
47#[derive(Clone, Copy)]
48struct RateLimitRule {
49    limit: u32,
50    window: Duration,
51}
52
53struct FixedWindow {
54    started_at: Instant,
55    used: u32,
56}
57
58impl RateLimiter {
59    fn check(&self, bucket: &'static str, rule: RateLimitRule) -> Result<(), McpError> {
60        let now = Instant::now();
61        let mut buckets = self
62            .buckets
63            .lock()
64            .map_err(|_| McpError::internal_error("rate limiter unavailable", None))?;
65        let entry = buckets.entry(bucket).or_insert(FixedWindow {
66            started_at: now,
67            used: 0,
68        });
69
70        let elapsed = now.saturating_duration_since(entry.started_at);
71        if elapsed >= rule.window {
72            entry.started_at = now;
73            entry.used = 0;
74        }
75
76        if entry.used >= rule.limit {
77            let retry_after_seconds = rule
78                .window
79                .checked_sub(elapsed)
80                .unwrap_or_default()
81                .as_secs()
82                .max(1);
83            return Err(McpError::invalid_request(
84                format!("rate limit exceeded for {bucket}; try again later"),
85                Some(json!({
86                    "bucket": bucket,
87                    "retry_after_seconds": retry_after_seconds,
88                })),
89            ));
90        }
91
92        entry.used += 1;
93        Ok(())
94    }
95}
96
97fn tool_result(command: CommandName, data: serde_json::Value) -> CallToolResult {
98    let envelope = OutputEnvelope { command, data };
99    let text =
100        serde_json::to_string(&envelope).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"));
101    CallToolResult::success(vec![Content::text(text)])
102}
103
104fn backend_error(operation: &'static str, error: anyhow::Error) -> McpError {
105    let raw = error.to_string();
106    if let Some(message) = safe_backend_message(raw.as_str()) {
107        return McpError::invalid_request(message, None);
108    }
109
110    McpError::internal_error(format!("{operation} failed; please retry later"), None)
111}
112
113fn safe_backend_message(raw: &str) -> Option<&'static str> {
114    match raw {
115        "transaction not found" => Some("transaction not found"),
116        "recipient key was not found" => Some("recipient key was not found"),
117        "recipient key lookup failed" => Some("recipient key lookup failed"),
118        "recipient key lookup was rate-limited" => Some("recipient key lookup was rate-limited"),
119        "recipient key is flagged as fraudulent" => Some("recipient key is flagged as fraudulent"),
120        "recipient key response was empty" => Some("recipient key response was empty"),
121        "No active session. Run `agentis-pay login` first." => {
122            Some("no active Agentis Pay session; run `agentis-pay login` again")
123        }
124        _ if raw.starts_with("session expired") => {
125            Some("stored Agentis Pay session expired; run `agentis-pay login` again")
126        }
127        _ => None,
128    }
129}
130
131fn invalid_params(error: anyhow::Error) -> McpError {
132    McpError::invalid_params(error.to_string(), None)
133}
134
135fn validate_brcode_input(raw: &str) -> Result<&str, McpError> {
136    let value = raw.trim();
137    if value.is_empty() {
138        return Err(McpError::invalid_params(
139            "brcode payload cannot be empty",
140            None,
141        ));
142    }
143    if value.len() > MAX_BRCODE_LENGTH {
144        return Err(McpError::invalid_params(
145            format!("brcode payload cannot exceed {MAX_BRCODE_LENGTH} characters"),
146            None,
147        ));
148    }
149
150    Ok(value)
151}
152
153#[tool_router]
154impl AgentisPayServer {
155    pub fn new(backend: Box<dyn PixBackend>) -> Self {
156        Self {
157            backend: Arc::new(Mutex::new(backend)),
158            rate_limiter: RateLimiter::default(),
159            tool_router: Self::tool_router(),
160        }
161    }
162
163    #[tool(
164        name = "agentispay_whoami",
165        description = "Show current session status."
166    )]
167    async fn whoami(&self) -> Result<CallToolResult, McpError> {
168        let mut backend = self.backend.lock().await;
169        let data = backend
170            .whoami()
171            .await
172            .map_err(|error| backend_error("session lookup", error))?;
173        Ok(tool_result(CommandName::Whoami, json!(data)))
174    }
175
176    #[tool(
177        name = "agentispay_account",
178        description = "Read account profile and metadata."
179    )]
180    async fn account(&self) -> Result<CallToolResult, McpError> {
181        let mut backend = self.backend.lock().await;
182        let data = backend
183            .account()
184            .await
185            .map_err(|error| backend_error("account lookup", error))?;
186        Ok(tool_result(CommandName::Account, json!(data)))
187    }
188
189    #[tool(
190        name = "agentispay_balance",
191        description = "Read available balance in cents."
192    )]
193    async fn balance(&self) -> Result<CallToolResult, McpError> {
194        let mut backend = self.backend.lock().await;
195        let data = backend
196            .balance()
197            .await
198            .map_err(|error| backend_error("balance lookup", error))?;
199        Ok(tool_result(CommandName::Balance, json!(data)))
200    }
201
202    #[tool(
203        name = "agentispay_history",
204        description = "Read transaction history, optionally by id, with optional list limit."
205    )]
206    async fn history(&self, params: Parameters<HistoryInput>) -> Result<CallToolResult, McpError> {
207        let mut backend = self.backend.lock().await;
208        let query = if let Some(id) = params.0.id {
209            HistoryQuery::Detail(id)
210        } else {
211            let limit = params.0.limit.unwrap_or(20);
212            if limit == 0 {
213                return Err(McpError::invalid_params(
214                    "history limit must be greater than zero",
215                    None,
216                ));
217            }
218            if limit > 50 {
219                return Err(McpError::invalid_params(
220                    "history limit cannot exceed 50",
221                    None,
222                ));
223            }
224            HistoryQuery::List { limit }
225        };
226        let payload = match backend
227            .history(query)
228            .await
229            .map_err(|error| backend_error("history lookup", error))?
230        {
231            HistoryRecord::List(list) => json!(list),
232            HistoryRecord::Detail(detail) => json!(detail),
233        };
234        Ok(tool_result(CommandName::History, payload))
235    }
236
237    #[tool(
238        name = "agentispay_brcode_decode",
239        description = "Decode a PIX BR Code payload."
240    )]
241    async fn brcode_decode(
242        &self,
243        params: Parameters<BrcodeDecodeInput>,
244    ) -> Result<CallToolResult, McpError> {
245        self.rate_limiter
246            .check("agentispay_brcode_decode", BRCODE_RATE_LIMIT)?;
247        let code = validate_brcode_input(&params.0.code)?;
248        let data = crate::commands::brcode::decode_payload(code).map_err(invalid_params)?;
249        Ok(tool_result(CommandName::BrcodeDecode, json!(data)))
250    }
251
252    #[tool(name = "agentispay_deposit", description = "List deposit PIX keys.")]
253    async fn deposit(&self) -> Result<CallToolResult, McpError> {
254        let mut backend = self.backend.lock().await;
255        let data = backend
256            .deposit()
257            .await
258            .map_err(|error| backend_error("deposit key lookup", error))?;
259        Ok(tool_result(CommandName::Deposit, json!(data)))
260    }
261
262    #[tool(
263        name = "agentispay_pix_keys",
264        description = "List configured PIX keys (CLI parity alias)."
265    )]
266    async fn pix_keys(&self) -> Result<CallToolResult, McpError> {
267        let mut backend = self.backend.lock().await;
268        let data = backend
269            .pix_list()
270            .await
271            .map_err(|error| backend_error("pix key lookup", error))?;
272        Ok(tool_result(CommandName::PixList, json!(data)))
273    }
274
275    #[tool(name = "agentispay_limits", description = "Read transfer risk limits.")]
276    async fn limits(&self) -> Result<CallToolResult, McpError> {
277        let mut backend = self.backend.lock().await;
278        let data = backend
279            .limits()
280            .await
281            .map_err(|error| backend_error("limits lookup", error))?;
282        Ok(tool_result(CommandName::Limits, json!(data)))
283    }
284
285    #[tool(name = "agentispay_pay", description = "Create a PIX transfer.")]
286    async fn pay(&self, params: Parameters<PayInput>) -> Result<CallToolResult, McpError> {
287        let idempotency_key = uuid::Uuid::now_v7();
288        self.pay_impl(params.0, idempotency_key).await
289    }
290
291    #[tool(
292        name = "agentispay_pix_send",
293        description = "Alias for agentispay_pay."
294    )]
295    async fn pix_send(&self, params: Parameters<PayInput>) -> Result<CallToolResult, McpError> {
296        let idempotency_key = uuid::Uuid::now_v7();
297        self.pay_impl(params.0, idempotency_key).await
298    }
299}
300
301impl AgentisPayServer {
302    async fn pay_impl(
303        &self,
304        mut input: PayInput,
305        idempotency_key: uuid::Uuid,
306    ) -> Result<CallToolResult, McpError> {
307        if input.amount_cents <= 0 {
308            return Err(McpError::invalid_params(
309                "amount_cents must be greater than zero",
310                None,
311            ));
312        }
313        if input.agent_message.trim().is_empty() {
314            return Err(McpError::invalid_params("agent_message is required", None));
315        }
316
317        let pix_key = PixKeyPayment::from_str(&input.key).map_err(|_| {
318            McpError::invalid_params(
319                "pix key format is invalid; expected a valid email, +55 phone, CPF, CNPJ, or EVP UUID",
320                None,
321            )
322        })?;
323
324        input.key = pix_key.to_string();
325        self.rate_limiter.check("agentispay_pay", PAY_RATE_LIMIT)?;
326
327        let mut backend = self.backend.lock().await;
328        let data = backend
329            .pay(input, idempotency_key)
330            .await
331            .map_err(|error| backend_error("payment request", error))?;
332        Ok(tool_result(CommandName::PixSend, json!(data)))
333    }
334}
335
336#[tool_handler]
337impl ServerHandler for AgentisPayServer {
338    async fn initialize(
339        &self,
340        request: InitializeRequestParams,
341        context: RequestContext<RoleServer>,
342    ) -> Result<InitializeResult, McpError> {
343        if context.peer.peer_info().is_none() {
344            context.peer.set_peer_info(request.clone());
345        }
346
347        let client_name = request.client_info.name.clone();
348        if !client_name.is_empty() {
349            let mut backend = self.backend.lock().await;
350            backend.set_mcp_client_name(client_name);
351        }
352
353        let cli_version =
354            std::env::var("AGENTIS_PAY_CLI_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
355
356        Ok(
357            InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
358                .with_server_info(Implementation::new("agentis-pay", cli_version)),
359        )
360    }
361}