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(¶ms.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}