Skip to main content

monoloop_loop/transaction/mcp/
gateway.rs

1//! Loopback MCP Streamable HTTP gateway with per-capability routing.
2
3use 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/// Cloneable handle for install/activate/revoke without owning the listener.
22#[derive(Clone)]
23pub struct McpGatewayHandle {
24    routes: Arc<McpRouteTable>,
25    base_url: String,
26    local_addr: SocketAddr,
27}
28
29impl McpGatewayHandle {
30    /// Bound loopback address.
31    pub fn local_addr(&self) -> SocketAddr {
32        self.local_addr
33    }
34
35    /// Base URL `http://127.0.0.1:port` (no path).
36    pub fn base_url(&self) -> &str {
37        &self.base_url
38    }
39
40    /// Shared route table.
41    pub fn routes(&self) -> &Arc<McpRouteTable> {
42        &self.routes
43    }
44
45    /// Install a pending capability for a transaction.
46    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    /// Activate a pending capability.
63    pub fn activate(&self, token: &CapabilityToken) -> Result<(), McpInstallError> {
64        self.routes.activate(token)
65    }
66
67    /// Revoke one capability (idempotent).
68    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
77/// Production MCP gateway: one loopback listener, many capability routes.
78pub struct McpGateway {
79    handle: McpGatewayHandle,
80    cancel: CancellationToken,
81    join: JoinHandle<()>,
82}
83
84impl McpGateway {
85    /// Bind `127.0.0.1:0`, serve Streamable HTTP, fail closed if not loopback.
86    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    /// Cloneable handle for actors and admission.
128    pub fn handle(&self) -> McpGatewayHandle {
129        self.handle.clone()
130    }
131
132    /// Bound loopback address.
133    pub fn local_addr(&self) -> SocketAddr {
134        self.handle.local_addr()
135    }
136
137    /// Base URL.
138    pub fn base_url(&self) -> &str {
139        self.handle.base_url()
140    }
141
142    /// Shared route table.
143    pub fn routes(&self) -> &Arc<McpRouteTable> {
144        self.handle.routes()
145    }
146
147    /// Install a pending capability for a transaction.
148    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    /// Activate a pending capability.
160    pub fn activate(&self, token: &CapabilityToken) -> Result<(), McpInstallError> {
161        self.handle.activate(token)
162    }
163
164    /// Revoke one capability (idempotent).
165    pub fn revoke(&self, token: &CapabilityToken) -> bool {
166        self.handle.revoke(token)
167    }
168
169    /// Shutdown: revoke this gateway's routes, cancel their MCP services, stop listener.
170    pub async fn shutdown(self) {
171        // Only drop services owned by this gateway (tokens in its route table).
172        // A process-wide drain would cancel concurrent tests/runtimes (D-018).
173        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
198/// Per-capability Streamable HTTP service (shared across requests for one token).
199struct CapabilityHttpService {
200    service: StreamableHttpService<TransactionMcpHandler, LocalSessionManager>,
201    cancel: CancellationToken,
202    /// Per-capability concurrent request bound (D-034).
203    permits: Arc<tokio::sync::Semaphore>,
204}
205
206/// Process-wide map: capability token hex → durable MCP session manager (D-018).
207static CAPABILITY_SERVICES: std::sync::OnceLock<
208    std::sync::Mutex<std::collections::HashMap<String, Arc<CapabilityHttpService>>>,
209> = std::sync::OnceLock::new();
210
211/// Global MCP request concurrency across all capability tokens (D-034).
212static 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
230/// Drop and cancel the per-token Streamable HTTP service (D-018 session cleanup).
231fn 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    // D-034: canonicalize hex spelling before route/service-map access so
244    // uppercase/lowercase equivalents share one service and revoke key.
245    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    // Bound body size before protocol dispatch (D-018).
260    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                // No long-lived SSE keep-alive; request streams complete with the response.
283                config.sse_keep_alive = None;
284                config.sse_retry = None;
285                // Prefer JSON when possible for simpler clients; SSE still used when needed.
286                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    // D-034: global + per-capability concurrency, plus request duration bound.
301    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}