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