monoloop_loop/transaction/mcp/
gateway.rs1use super::binding::{CapabilityToken, McpInstallError, McpRouteTable, PendingMcpBinding};
4use super::handler::TransactionMcpHandler;
5use crate::transaction::dispatcher::TransactionToolDispatcher;
6use crate::transaction::resolved_tools::ResolvedToolSet;
7use axum::body::Body;
8use axum::extract::{Path, Request, State};
9use axum::http::{Response, StatusCode};
10use axum::routing::any;
11use axum::Router;
12use monoloop_contracts::{ExchangeId, TransactionId};
13use rmcp::transport::streamable_http_server::{
14 session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
15};
16use std::net::SocketAddr;
17use std::sync::Arc;
18use tokio::task::JoinHandle;
19use tokio_util::sync::CancellationToken;
20
21#[derive(Clone)]
23pub struct McpGatewayHandle {
24 routes: Arc<McpRouteTable>,
25 base_url: String,
26 local_addr: SocketAddr,
27}
28
29impl McpGatewayHandle {
30 pub fn local_addr(&self) -> SocketAddr {
32 self.local_addr
33 }
34
35 pub fn base_url(&self) -> &str {
37 &self.base_url
38 }
39
40 pub fn routes(&self) -> &Arc<McpRouteTable> {
42 &self.routes
43 }
44
45 pub fn install_pending(
47 &self,
48 transaction_id: TransactionId,
49 tools: ResolvedToolSet,
50 dispatcher: Arc<TransactionToolDispatcher>,
51 exchange_id: ExchangeId,
52 ) -> Result<PendingMcpBinding, McpInstallError> {
53 self.routes.install_pending(
54 transaction_id,
55 tools,
56 dispatcher,
57 exchange_id,
58 &self.base_url,
59 )
60 }
61
62 pub fn activate(&self, token: &CapabilityToken) -> Result<(), McpInstallError> {
64 self.routes.activate(token)
65 }
66
67 pub fn revoke(&self, token: &CapabilityToken) -> bool {
69 let removed = self.routes.revoke(token);
70 if removed {
71 drop_capability_service(&token.to_hex());
72 }
73 removed
74 }
75}
76
77pub struct McpGateway {
79 handle: McpGatewayHandle,
80 cancel: CancellationToken,
81 join: JoinHandle<()>,
82}
83
84impl McpGateway {
85 pub async fn bind_loopback(max_routes: usize) -> Result<Self, McpInstallError> {
87 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
88 .await
89 .map_err(|_| McpInstallError::InvalidDescriptor)?;
90 let local_addr = listener
91 .local_addr()
92 .map_err(|_| McpInstallError::InvalidDescriptor)?;
93 if !local_addr.ip().is_loopback() {
94 return Err(McpInstallError::InvalidDescriptor);
95 }
96
97 let routes = McpRouteTable::new(max_routes);
98 let base_url = format!("http://{}", local_addr);
99 let cancel = CancellationToken::new();
100 let cancel_serve = cancel.clone();
101 let routes_state = Arc::clone(&routes);
102
103 let app = Router::new()
104 .route("/mcp/{token}", any(mcp_dispatch))
105 .route("/mcp/{token}/{*rest}", any(mcp_dispatch_rest))
106 .with_state(routes_state);
107
108 let join = tokio::spawn(async move {
109 let _ = axum::serve(listener, app)
110 .with_graceful_shutdown(async move {
111 cancel_serve.cancelled().await;
112 })
113 .await;
114 });
115
116 Ok(Self {
117 handle: McpGatewayHandle {
118 routes,
119 base_url,
120 local_addr,
121 },
122 cancel,
123 join,
124 })
125 }
126
127 pub fn handle(&self) -> McpGatewayHandle {
129 self.handle.clone()
130 }
131
132 pub fn local_addr(&self) -> SocketAddr {
134 self.handle.local_addr()
135 }
136
137 pub fn base_url(&self) -> &str {
139 self.handle.base_url()
140 }
141
142 pub fn routes(&self) -> &Arc<McpRouteTable> {
144 self.handle.routes()
145 }
146
147 pub fn install_pending(
149 &self,
150 transaction_id: TransactionId,
151 tools: ResolvedToolSet,
152 dispatcher: Arc<TransactionToolDispatcher>,
153 exchange_id: ExchangeId,
154 ) -> Result<PendingMcpBinding, McpInstallError> {
155 self.handle
156 .install_pending(transaction_id, tools, dispatcher, exchange_id)
157 }
158
159 pub fn activate(&self, token: &CapabilityToken) -> Result<(), McpInstallError> {
161 self.handle.activate(token)
162 }
163
164 pub fn revoke(&self, token: &CapabilityToken) -> bool {
166 self.handle.revoke(token)
167 }
168
169 pub async fn shutdown(self) {
171 let tokens = self.handle.routes.revoke_all();
174 for hex in tokens {
175 drop_capability_service(&hex);
176 }
177 self.cancel.cancel();
178 let _ = self.join.await;
179 }
180}
181
182async fn mcp_dispatch(
183 State(routes): State<Arc<McpRouteTable>>,
184 Path(token): Path<String>,
185 req: Request,
186) -> Response<Body> {
187 forward_mcp(routes, &token, req).await
188}
189
190async fn mcp_dispatch_rest(
191 State(routes): State<Arc<McpRouteTable>>,
192 Path((token, _rest)): Path<(String, String)>,
193 req: Request,
194) -> Response<Body> {
195 forward_mcp(routes, &token, req).await
196}
197
198struct CapabilityHttpService {
200 service: StreamableHttpService<TransactionMcpHandler, LocalSessionManager>,
201 cancel: CancellationToken,
202 permits: Arc<tokio::sync::Semaphore>,
204}
205
206static CAPABILITY_SERVICES: std::sync::OnceLock<
208 std::sync::Mutex<std::collections::HashMap<String, Arc<CapabilityHttpService>>>,
209> = std::sync::OnceLock::new();
210
211static GLOBAL_MCP_PERMITS: std::sync::OnceLock<Arc<tokio::sync::Semaphore>> =
213 std::sync::OnceLock::new();
214
215const MAX_GLOBAL_MCP_REQUESTS: usize = 64;
216const MAX_PER_CAPABILITY_MCP_REQUESTS: usize = 8;
217const MCP_REQUEST_DURATION: std::time::Duration = std::time::Duration::from_secs(30);
218
219fn capability_services(
220) -> &'static std::sync::Mutex<std::collections::HashMap<String, Arc<CapabilityHttpService>>> {
221 CAPABILITY_SERVICES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
222}
223
224fn global_mcp_permits() -> Arc<tokio::sync::Semaphore> {
225 GLOBAL_MCP_PERMITS
226 .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(MAX_GLOBAL_MCP_REQUESTS)))
227 .clone()
228}
229
230fn drop_capability_service(token_hex: &str) {
232 let key = CapabilityToken::from_hex(token_hex)
233 .map(|t| t.to_hex())
234 .unwrap_or_else(|| token_hex.to_ascii_lowercase());
235 if let Ok(mut map) = capability_services().lock() {
236 if let Some(svc) = map.remove(&key) {
237 svc.cancel.cancel();
238 }
239 }
240}
241
242async fn forward_mcp(routes: Arc<McpRouteTable>, token_hex: &str, req: Request) -> Response<Body> {
243 let Some(canonical) = CapabilityToken::from_hex(token_hex).map(|t| t.to_hex()) else {
246 return Response::builder()
247 .status(StatusCode::NOT_FOUND)
248 .body(Body::from("unknown capability"))
249 .unwrap_or_else(|_| Response::new(Body::empty()));
250 };
251 let Some(binding) = routes.get_by_hex(&canonical) else {
252 drop_capability_service(&canonical);
253 return Response::builder()
254 .status(StatusCode::NOT_FOUND)
255 .body(Body::from("unknown capability"))
256 .unwrap_or_else(|_| Response::new(Body::empty()));
257 };
258
259 let (parts, body) = req.into_parts();
261 let collected = match axum::body::to_bytes(body, 1024 * 1024).await {
262 Ok(b) => b,
263 Err(_) => {
264 return Response::builder()
265 .status(StatusCode::PAYLOAD_TOO_LARGE)
266 .body(Body::from("request body exceeds bound"))
267 .unwrap_or_else(|_| Response::new(Body::empty()));
268 }
269 };
270 let req = Request::from_parts(parts, Body::from(collected));
271
272 let service = {
273 let mut map = capability_services()
274 .lock()
275 .unwrap_or_else(|e| e.into_inner());
276 map.entry(canonical.clone())
277 .or_insert_with(|| {
278 let handler = binding.handler.clone();
279 let cancel = CancellationToken::new();
280 let mut config = StreamableHttpServerConfig::default();
281 config.cancellation_token = cancel.clone();
282 config.sse_keep_alive = None;
284 config.sse_retry = None;
285 config.json_response = true;
287 Arc::new(CapabilityHttpService {
288 service: StreamableHttpService::new(
289 move || Ok(handler.clone()),
290 Arc::new(LocalSessionManager::default()),
291 config,
292 ),
293 cancel,
294 permits: Arc::new(tokio::sync::Semaphore::new(MAX_PER_CAPABILITY_MCP_REQUESTS)),
295 })
296 })
297 .clone()
298 };
299
300 let Ok(_global) = global_mcp_permits().try_acquire_owned() else {
302 return Response::builder()
303 .status(StatusCode::TOO_MANY_REQUESTS)
304 .body(Body::from("mcp global concurrency exceeded"))
305 .unwrap_or_else(|_| Response::new(Body::empty()));
306 };
307 let Ok(_local) = service.permits.clone().try_acquire_owned() else {
308 return Response::builder()
309 .status(StatusCode::TOO_MANY_REQUESTS)
310 .body(Body::from("mcp capability concurrency exceeded"))
311 .unwrap_or_else(|_| Response::new(Body::empty()));
312 };
313
314 let req = rewrite_path(req, &canonical);
315 match tokio::time::timeout(MCP_REQUEST_DURATION, service.service.handle(req)).await {
316 Ok(response) => response.map(Body::new),
317 Err(_) => Response::builder()
318 .status(StatusCode::GATEWAY_TIMEOUT)
319 .body(Body::from("mcp request deadline exceeded"))
320 .unwrap_or_else(|_| Response::new(Body::empty())),
321 }
322}
323
324fn rewrite_path(req: Request, token_hex: &str) -> Request {
325 let (mut parts, body) = req.into_parts();
326 let path = parts.uri.path().to_string();
327 let query = parts.uri.query().map(|q| q.to_string());
328 let prefix = format!("/mcp/{token_hex}");
329 let new_path = if let Some(rest) = path.strip_prefix(&prefix) {
330 if rest.is_empty() {
331 "/".to_string()
332 } else {
333 rest.to_string()
334 }
335 } else {
336 path
337 };
338 let pq = match query {
339 Some(q) => format!("{new_path}?{q}"),
340 None => new_path,
341 };
342 if let Ok(uri) = pq.parse::<axum::http::Uri>() {
343 parts.uri = uri;
344 }
345 Request::from_parts(parts, body)
346}