Skip to main content

agentis_pay/mcp/
handler.rs

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