rmcp_actix_web/transport/streamable_http_server.rs
1//! Streamable HTTP transport implementation for MCP.
2//!
3//! This module provides a bidirectional HTTP transport with session management,
4//! supporting both request/response and streaming patterns for MCP communication.
5//!
6//! ## Architecture
7//!
8//! The transport uses three HTTP methods on a single endpoint:
9//! - **GET**: Resume or open SSE stream to receive server-to-client messages
10//! - **POST**: Send JSON-RPC requests (returns SSE stream with responses)
11//! - **DELETE**: Close session and cleanup resources
12//!
13//! ## Features
14//!
15//! - Full bidirectional communication
16//! - Session management with pluggable backends
17//! - Support for both streaming and request/response patterns
18//! - Efficient message routing
19//! - Graceful connection handling
20//!
21//! ## Session Management
22//!
23//! The transport supports different session managers:
24//! - `LocalSessionManager`: In-memory session storage (default)
25//! - Custom implementations via the `SessionManager` trait
26//!
27//! ## Example
28//!
29//! ```rust,no_run
30//! use rmcp_actix_web::transport::StreamableHttpService;
31//! use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
32//! use actix_web::{App, HttpServer};
33//! use std::sync::Arc;
34//!
35//! # use rmcp::{ServerHandler, model::ServerInfo};
36//! # #[derive(Clone)]
37//! # struct MyService;
38//! # impl ServerHandler for MyService {
39//! # fn get_info(&self) -> ServerInfo { ServerInfo::default() }
40//! # }
41//! # impl MyService { fn new() -> Self { Self } }
42//! #[actix_web::main]
43//! async fn main() -> std::io::Result<()> {
44//! // Create service OUTSIDE HttpServer::new() to share across workers
45//! let service = StreamableHttpService::builder()
46//! .service_factory(Arc::new(|| Ok(MyService::new())))
47//! .session_manager(Arc::new(LocalSessionManager::default()))
48//! .stateful_mode(true)
49//! .build();
50//!
51//! HttpServer::new(move || {
52//! App::new()
53//! // Clone service for each worker (shares the same LocalSessionManager)
54//! .service(service.clone().scope())
55//! })
56//! .bind("127.0.0.1:8080")?
57//! .run()
58//! .await
59//! }
60//! ```
61
62use std::{sync::Arc, time::Duration};
63
64use actix_web::{
65 HttpRequest, HttpResponse, Result, Scope,
66 error::InternalError,
67 http::{
68 StatusCode,
69 header::{self, CACHE_CONTROL},
70 },
71 middleware,
72 web::{self, Bytes, Data},
73};
74use futures::{Stream, StreamExt};
75use tokio_stream::wrappers::ReceiverStream;
76
77/// Type alias for the on_request hook function.
78///
79/// This hook is called for each incoming request, allowing users to propagate
80/// typed extensions from the actix-web `HttpRequest` to rmcp's `RequestContext::extensions`.
81pub type OnRequestHook = dyn Fn(&HttpRequest, &mut rmcp::model::Extensions) + Send + Sync + 'static;
82
83use rmcp::{
84 RoleServer,
85 model::{ClientJsonRpcMessage, ClientRequest},
86 serve_server,
87 service::serve_directly,
88 transport::{
89 OneshotTransport, TransportAdapterIdentity,
90 common::http_header::{HEADER_LAST_EVENT_ID, HEADER_SESSION_ID},
91 streamable_http_server::session::SessionManager,
92 },
93};
94
95use rmcp::model::GetExtensions;
96
97#[cfg(feature = "authorization-token-passthrough")]
98use super::AuthorizationHeader;
99
100// Local constants
101const HEADER_X_ACCEL_BUFFERING: &str = "X-Accel-Buffering";
102const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream";
103const JSON_MIME_TYPE: &str = "application/json";
104const MISSING_SESSION_ID_BODY: &str = "Bad Request: Mcp-Session-Id header is required";
105const SESSION_NOT_FOUND_BODY: &str = "Session not found";
106
107/// Configuration for the streamable HTTP server transport.
108///
109/// Contains settings for session management and connection behavior.
110#[derive(Debug, Clone)]
111pub struct StreamableHttpServerConfig {
112 /// Whether to enable stateful session management
113 pub stateful_mode: bool,
114 /// Optional keep-alive interval for SSE connections
115 pub sse_keep_alive: Option<Duration>,
116}
117
118impl Default for StreamableHttpServerConfig {
119 fn default() -> Self {
120 Self {
121 stateful_mode: true,
122 sse_keep_alive: None,
123 }
124 }
125}
126
127/// Streamable HTTP transport service for actix-web integration.
128///
129/// Provides bidirectional MCP communication over HTTP with session management.
130/// This service can be integrated into existing actix-web applications.
131/// Uses a builder pattern for configuration.
132///
133/// # Type Parameters
134///
135/// * `S` - The MCP service type that handles protocol messages
136/// * `M` - The session manager type (defaults to `LocalSessionManager`)
137///
138/// # Architecture
139///
140/// The service manages endpoints with multiple HTTP methods:
141/// - GET: For streaming event connections
142/// - POST: For sending messages and creating sessions
143/// - DELETE: For closing sessions
144///
145/// Each client is identified by a session ID that must be provided in request headers.
146///
147/// # Example
148///
149/// ```rust,no_run
150/// use rmcp_actix_web::transport::StreamableHttpService;
151/// use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
152/// use actix_web::{App, HttpServer, web};
153/// use std::{sync::Arc, time::Duration};
154///
155/// # use rmcp::{ServerHandler, model::ServerInfo};
156/// # #[derive(Clone)]
157/// # struct MyService;
158/// # impl ServerHandler for MyService {
159/// # fn get_info(&self) -> ServerInfo { ServerInfo::default() }
160/// # }
161/// # impl MyService { fn new() -> Self { Self } }
162/// #[actix_web::main]
163/// async fn main() -> std::io::Result<()> {
164/// // Create service OUTSIDE HttpServer::new() to share across workers
165/// let service = StreamableHttpService::builder()
166/// .service_factory(Arc::new(|| Ok(MyService::new())))
167/// .session_manager(Arc::new(LocalSessionManager::default()))
168/// .stateful_mode(true)
169/// .sse_keep_alive(Duration::from_secs(30))
170/// .build();
171///
172/// HttpServer::new(move || {
173/// App::new()
174/// // Clone service for each worker (shares the same LocalSessionManager)
175/// .service(web::scope("/mcp").service(service.clone().scope()))
176/// })
177/// .bind("127.0.0.1:8080")?
178/// .run()
179/// .await
180/// }
181/// ```
182#[derive(bon::Builder)]
183pub struct StreamableHttpService<
184 S,
185 M = rmcp::transport::streamable_http_server::session::local::LocalSessionManager,
186> {
187 /// The service factory function that creates new MCP service instances
188 service_factory: Arc<dyn Fn() -> Result<S, std::io::Error> + Send + Sync>,
189
190 /// The session manager for tracking client connections
191 session_manager: Arc<M>,
192
193 /// Whether to enable stateful session management
194 #[builder(default = true)]
195 stateful_mode: bool,
196
197 /// Optional keep-alive interval for SSE connections
198 sse_keep_alive: Option<Duration>,
199
200 /// Optional hook called for each request to propagate extensions from HttpRequest to RequestContext.
201 ///
202 /// This allows middleware-populated data (e.g., JWT claims) to be accessed in MCP handlers.
203 ///
204 /// # Example
205 ///
206 /// ```rust,ignore
207 /// use std::sync::Arc;
208 /// use actix_web::HttpMessage;
209 ///
210 /// StreamableHttpService::builder()
211 /// .on_request(Arc::new(|http_req, ext| {
212 /// if let Some(claims) = http_req.extensions().get::<MyClaims>() {
213 /// ext.insert(claims.clone());
214 /// }
215 /// }))
216 /// .build()
217 /// ```
218 on_request: Option<Arc<OnRequestHook>>,
219}
220
221impl<S, M> Clone for StreamableHttpService<S, M> {
222 fn clone(&self) -> Self {
223 Self {
224 service_factory: self.service_factory.clone(),
225 session_manager: self.session_manager.clone(),
226 stateful_mode: self.stateful_mode,
227 sse_keep_alive: self.sse_keep_alive,
228 on_request: self.on_request.clone(),
229 }
230 }
231}
232
233// Convenience methods for StreamableHttpServiceBuilder
234impl<S, M, State: streamable_http_service_builder::State> StreamableHttpServiceBuilder<S, M, State>
235where
236 State::OnRequest: streamable_http_service_builder::IsUnset,
237{
238 /// Sets the on_request hook using a closure.
239 ///
240 /// This is a convenience method that automatically wraps the closure in an `Arc`,
241 /// making it easier to use without manual Arc wrapping.
242 ///
243 /// # Example
244 ///
245 /// ```rust,ignore
246 /// use actix_web::HttpMessage;
247 ///
248 /// StreamableHttpService::builder()
249 /// .on_request_fn(|http_req, ext| {
250 /// if let Some(claims) = http_req.extensions().get::<MyClaims>() {
251 /// ext.insert(claims.clone());
252 /// }
253 /// })
254 /// .build()
255 /// ```
256 pub fn on_request_fn(
257 self,
258 hook: impl Fn(&HttpRequest, &mut rmcp::model::Extensions) + Send + Sync + 'static,
259 ) -> StreamableHttpServiceBuilder<S, M, streamable_http_service_builder::SetOnRequest<State>>
260 {
261 self.on_request(Arc::new(hook))
262 }
263}
264
265/// Internal data structure used by handlers to store service configuration
266/// with Arc-wrapped session manager for thread safety.
267#[derive(Clone)]
268struct AppData<S, M> {
269 /// The service factory function that creates new MCP service instances
270 service_factory: Arc<dyn Fn() -> Result<S, std::io::Error> + Send + Sync>,
271 /// The session manager wrapped in Arc for thread safety
272 session_manager: Arc<M>,
273 /// Whether the service operates in stateful mode
274 stateful_mode: bool,
275 /// Optional keep-alive interval for SSE connections
276 sse_keep_alive: Option<Duration>,
277 /// Optional hook for propagating extensions from HttpRequest to RequestContext
278 on_request: Option<Arc<OnRequestHook>>,
279}
280
281impl<S, M> AppData<S, M> {
282 fn get_service(&self) -> Result<S, std::io::Error> {
283 (self.service_factory)()
284 }
285}
286
287// SSE Stream Helper Functions
288//
289// These functions provide reusable SSE keep-alive functionality to avoid code duplication.
290
291/// Serialize a `ServerSseMessage` as a single SSE event.
292///
293/// Priming events ([SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699))
294/// carry no JSON-RPC payload (`message == None`) and MUST be emitted with an empty `data` field
295/// (`data:\n\n`), not the JSON literal `null`.
296fn format_sse_event(
297 event_id: Option<&str>,
298 message: Option<&rmcp::model::ServerJsonRpcMessage>,
299) -> Bytes {
300 let mut output = String::new();
301 if let Some(id) = event_id {
302 output.push_str(&format!("id: {id}\n"));
303 }
304 match message {
305 Some(message) => {
306 let data = serde_json::to_string(message).unwrap_or_else(|_| "{}".to_string());
307 output.push_str(&format!("data: {data}\n\n"));
308 }
309 None => output.push_str("data:\n\n"),
310 }
311 Bytes::from(output)
312}
313
314/// Wraps any SSE-formatted stream with keep-alive ping support.
315///
316/// Adds periodic `:ping\n\n` messages during silent periods to prevent connection timeouts.
317/// The wrapper automatically stops when the underlying stream ends, allowing POST responses
318/// to close properly per MCP spec.
319///
320/// # Arguments
321///
322/// * `stream` - A stream of SSE-formatted bytes (already formatted as `data: ...\n\n`)
323/// * `keep_alive` - Optional keep-alive interval. If `Some`, sends `:ping\n\n` at this interval
324/// during silent periods. If `None`, no pings are sent.
325///
326/// # Returns
327///
328/// A stream that multiplexes the input stream with keep-alive pings, ending when the input ends.
329fn wrap_with_sse_keepalive<S>(
330 stream: S,
331 keep_alive: Option<Duration>,
332) -> impl Stream<Item = Result<Bytes, actix_web::Error>>
333where
334 S: Stream<Item = Result<Bytes, actix_web::Error>> + Send + 'static,
335{
336 async_stream::stream! {
337 let mut stream = Box::pin(stream);
338 let mut keep_alive_timer = keep_alive.map(|duration| tokio::time::interval(duration));
339
340 // Consume the immediate first tick if keep-alive is enabled
341 if let Some(ref mut timer) = keep_alive_timer {
342 timer.tick().await;
343 }
344
345 loop {
346 tokio::select! {
347 result = stream.next() => {
348 match result {
349 Some(msg) => yield msg,
350 None => break, // Stream ended, stop sending pings
351 }
352 }
353 _ = async {
354 match keep_alive_timer.as_mut() {
355 Some(timer) => {
356 timer.tick().await;
357 }
358 None => {
359 std::future::pending::<()>().await;
360 }
361 }
362 } => {
363 yield Ok(Bytes::from(":ping\n\n"));
364 }
365 }
366 }
367 }
368}
369
370impl<S, M> StreamableHttpService<S, M>
371where
372 S: Clone + rmcp::ServerHandler + Send + 'static,
373 M: SessionManager + 'static,
374{
375 /// Creates a new scope configured with this service for framework-level composition.
376 ///
377 /// This method provides framework-level composition aligned with RMCP patterns,
378 /// similar to how `SseService::scope()` works. This allows mounting the
379 /// streamable HTTP service at custom paths using actix-web's routing.
380 ///
381 /// The method consumes `self`, so you can call it directly on the service instance.
382 /// If you need to use the service multiple times, wrap it in an `Arc` and clone it.
383 ///
384 /// This method is equivalent to `scope_with_path("")`.
385 ///
386 /// # Returns
387 ///
388 /// Returns an actix-web `Scope` configured with the streamable HTTP routes
389 ///
390 /// # Example
391 ///
392 /// ```rust,no_run
393 /// use rmcp_actix_web::transport::StreamableHttpService;
394 /// use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
395 /// use actix_web::{App, HttpServer, web};
396 /// use std::sync::Arc;
397 ///
398 /// # use rmcp::{ServerHandler, model::ServerInfo};
399 /// # #[derive(Clone)]
400 /// # struct MyService;
401 /// # impl ServerHandler for MyService {
402 /// # fn get_info(&self) -> ServerInfo { ServerInfo::default() }
403 /// # }
404 /// # impl MyService { fn new() -> Self { Self } }
405 /// #[actix_web::main]
406 /// async fn main() -> std::io::Result<()> {
407 /// // Create service OUTSIDE HttpServer::new() to share across workers
408 /// let service = StreamableHttpService::builder()
409 /// .service_factory(Arc::new(|| Ok(MyService::new())))
410 /// .session_manager(Arc::new(LocalSessionManager::default()))
411 /// .build();
412 ///
413 /// HttpServer::new(move || {
414 /// App::new()
415 /// // Clone service for each worker (shares the same LocalSessionManager)
416 /// .service(web::scope("/api/v1/mcp").service(service.clone().scope()))
417 /// })
418 /// .bind("127.0.0.1:8080")?
419 /// .run();
420 ///
421 /// Ok(())
422 /// }
423 /// ```
424 pub fn scope(
425 self,
426 ) -> Scope<
427 impl actix_web::dev::ServiceFactory<
428 actix_web::dev::ServiceRequest,
429 Config = (),
430 Response = actix_web::dev::ServiceResponse,
431 Error = actix_web::Error,
432 InitError = (),
433 >,
434 > {
435 self.scope_with_path("")
436 }
437
438 /// Creates a new scope configured with this service for framework-level composition.
439 ///
440 /// This method provides framework-level composition aligned with RMCP patterns,
441 /// similar to how `SseService::scope()` works. This allows mounting the
442 /// streamable HTTP service at custom paths using actix-web's routing.
443 ///
444 /// The method consumes `self`, so you can call it directly on the service instance.
445 /// If you need to use the service multiple times, wrap it in an `Arc` and clone it.
446 ///
447 /// This method is similar to `scope` except that it allows specifying a custom path.
448 ///
449 /// # Returns
450 ///
451 /// Returns an actix-web `Scope` configured with the streamable HTTP routes
452 ///
453 /// # Example
454 ///
455 /// ```rust,no_run
456 /// use rmcp_actix_web::transport::{StreamableHttpService, AuthorizationHeader};
457 /// use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
458 /// use actix_web::{App, HttpServer, web};
459 /// use std::sync::Arc;
460 ///
461 /// # use rmcp::{ServerHandler, model::ServerInfo};
462 /// # #[derive(Clone)]
463 /// # struct MyService;
464 /// # impl ServerHandler for MyService {
465 /// # fn get_info(&self) -> ServerInfo { ServerInfo::default() }
466 /// # }
467 /// # impl MyService { fn new() -> Self { Self } }
468 /// #[actix_web::main]
469 /// async fn main() -> std::io::Result<()> {
470 /// // Create service OUTSIDE HttpServer::new() to share across workers
471 /// let service = StreamableHttpService::builder()
472 /// .service_factory(Arc::new(|| Ok(MyService::new())))
473 /// .session_manager(Arc::new(LocalSessionManager::default()))
474 /// .build();
475 ///
476 /// HttpServer::new(move || {
477 /// App::new()
478 /// // Clone service for each worker (shares the same LocalSessionManager)
479 /// .service(service.clone().scope_with_path("/api/v1/mcp"))
480 /// })
481 /// .bind("127.0.0.1:8080")?
482 /// .run();
483 ///
484 /// Ok(())
485 /// }
486 /// ```
487 pub fn scope_with_path(
488 self,
489 path: &str,
490 ) -> Scope<
491 impl actix_web::dev::ServiceFactory<
492 actix_web::dev::ServiceRequest,
493 Config = (),
494 Response = actix_web::dev::ServiceResponse,
495 Error = actix_web::Error,
496 InitError = (),
497 >,
498 > {
499 let app_data = AppData {
500 service_factory: self.service_factory,
501 session_manager: self.session_manager,
502 stateful_mode: self.stateful_mode,
503 sse_keep_alive: self.sse_keep_alive,
504 on_request: self.on_request,
505 };
506
507 web::scope(path)
508 .app_data(Data::new(app_data))
509 .wrap(middleware::NormalizePath::trim())
510 .route("", web::get().to(Self::handle_get))
511 .route("", web::post().to(Self::handle_post))
512 .route("", web::delete().to(Self::handle_delete))
513 }
514
515 async fn handle_get(req: HttpRequest, service: Data<AppData<S, M>>) -> Result<HttpResponse> {
516 // Check accept header
517 let accept = req
518 .headers()
519 .get(header::ACCEPT)
520 .and_then(|h| h.to_str().ok());
521
522 if !accept.is_some_and(|header| header.contains(EVENT_STREAM_MIME_TYPE)) {
523 return Ok(HttpResponse::NotAcceptable()
524 .body("Not Acceptable: Client must accept text/event-stream"));
525 }
526
527 // Check session id
528 let session_id = req
529 .headers()
530 .get(HEADER_SESSION_ID)
531 .and_then(|v| v.to_str().ok())
532 .filter(|s| !s.is_empty())
533 .map(|s| s.to_owned().into());
534
535 let Some(session_id) = session_id else {
536 return Ok(HttpResponse::BadRequest().body(MISSING_SESSION_ID_BODY));
537 };
538
539 tracing::debug!(%session_id, "GET request for SSE stream");
540
541 // Check if session exists
542 let has_session = service
543 .session_manager
544 .has_session(&session_id)
545 .await
546 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
547
548 if !has_session {
549 tracing::warn!(%session_id, "Session not found");
550 return Ok(HttpResponse::NotFound().body(SESSION_NOT_FOUND_BODY));
551 }
552
553 // Check if last event id is provided
554 let last_event_id = req
555 .headers()
556 .get(HEADER_LAST_EVENT_ID)
557 .and_then(|v| v.to_str().ok())
558 .map(|s| s.to_owned());
559
560 // Get the appropriate stream
561 let sse_stream: std::pin::Pin<Box<dyn Stream<Item = _> + Send>> =
562 if let Some(last_event_id) = last_event_id {
563 tracing::debug!(%session_id, %last_event_id, "Resuming stream from last event");
564 Box::pin(
565 service
566 .session_manager
567 .resume(&session_id, last_event_id)
568 .await
569 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?,
570 )
571 } else {
572 tracing::debug!(%session_id, "Creating standalone stream");
573 Box::pin(
574 service
575 .session_manager
576 .create_standalone_stream(&session_id)
577 .await
578 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?,
579 )
580 };
581
582 // Convert to SSE format and add keep-alive
583 let formatted_stream = sse_stream.map(|msg| {
584 Ok::<_, actix_web::Error>(format_sse_event(
585 msg.event_id.as_deref(),
586 msg.message.as_deref(),
587 ))
588 });
589 let sse_stream = wrap_with_sse_keepalive(formatted_stream, service.sse_keep_alive);
590
591 Ok(HttpResponse::Ok()
592 .content_type(EVENT_STREAM_MIME_TYPE)
593 .append_header((CACHE_CONTROL, "no-cache"))
594 .append_header((HEADER_X_ACCEL_BUFFERING, "no"))
595 .streaming(sse_stream))
596 }
597
598 async fn handle_post(
599 req: HttpRequest,
600 body: Bytes,
601 service: Data<AppData<S, M>>,
602 ) -> Result<HttpResponse> {
603 // Check accept header
604 let accept = req
605 .headers()
606 .get(header::ACCEPT)
607 .and_then(|h| h.to_str().ok());
608
609 if !accept.is_some_and(|header| {
610 header.contains(JSON_MIME_TYPE) && header.contains(EVENT_STREAM_MIME_TYPE)
611 }) {
612 return Ok(HttpResponse::NotAcceptable().body(
613 "Not Acceptable: Client must accept both application/json and text/event-stream",
614 ));
615 }
616
617 // Check content type
618 let content_type = req
619 .headers()
620 .get(header::CONTENT_TYPE)
621 .and_then(|h| h.to_str().ok());
622
623 if !content_type.is_some_and(|header| header.starts_with(JSON_MIME_TYPE)) {
624 return Ok(HttpResponse::UnsupportedMediaType()
625 .body("Unsupported Media Type: Content-Type must be application/json"));
626 }
627
628 // Deserialize the message
629 let mut message: ClientJsonRpcMessage = serde_json::from_slice(&body)
630 .map_err(|e| InternalError::new(e, StatusCode::BAD_REQUEST))?;
631
632 tracing::debug!(?message, "POST request with message");
633
634 if service.stateful_mode {
635 // Check session id
636 let session_id = req
637 .headers()
638 .get(HEADER_SESSION_ID)
639 .and_then(|v| v.to_str().ok())
640 .filter(|s| !s.is_empty());
641
642 if let Some(session_id) = session_id {
643 let session_id = session_id.to_owned().into();
644 tracing::debug!(%session_id, "POST request with existing session");
645
646 let has_session = service
647 .session_manager
648 .has_session(&session_id)
649 .await
650 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
651
652 if !has_session {
653 tracing::warn!(%session_id, "Session not found");
654 return Ok(HttpResponse::NotFound().body(SESSION_NOT_FOUND_BODY));
655 }
656
657 // Note: In actix-web we can't inject request parts like in tower,
658 // but session_id is already available through headers
659
660 match message {
661 #[allow(unused_mut)]
662 ClientJsonRpcMessage::Request(mut request_msg) => {
663 // Call on_request hook to propagate extensions from HttpRequest
664 if let Some(ref hook) = service.on_request {
665 hook(&req, request_msg.request.extensions_mut());
666 }
667
668 // Extract and inject Authorization header for existing sessions.
669 //
670 // SECURITY: This transport forwards Authorization headers to MCP services.
671 //
672 // MCP-COMPLIANT USAGE: MCP services MUST validate these tokens as intended for themselves
673 // and MUST NOT forward them to upstream APIs (per MCP specification).
674 //
675 // NON-COMPLIANT USAGE: Some implementations (e.g., rmcp-openapi-server) use these tokens
676 // for upstream API authentication. This violates MCP specifications but may be necessary
677 // for certain proxy architectures. Use with caution and ensure proper token audience validation.
678 // See SECURITY.md for details.
679 //
680 // Supports OAuth 2.1 token rotation patterns by forwarding each request's
681 // Authorization independently. This enables:
682 // - Token rotation within sessions (security best practice)
683 // - Token refresh when access tokens expire
684 // - Scope changes for different operations within the same session
685 //
686 // The proxy does NOT cache or reuse tokens from session initialization.
687 // Each request must provide its own valid Authorization header.
688 #[cfg(feature = "authorization-token-passthrough")]
689 if let Some(auth_value) = req.headers().get(header::AUTHORIZATION) {
690 match auth_value.to_str() {
691 Ok(auth_str)
692 if auth_str.starts_with("Bearer ") && auth_str.len() > 7 =>
693 {
694 tracing::debug!(
695 "Forwarding Authorization header to MCP service for existing session. \
696 Note: MCP services must not pass this token to upstream APIs per MCP spec. \
697 See SECURITY.md for details."
698 );
699 request_msg
700 .request
701 .extensions_mut()
702 .insert(AuthorizationHeader(auth_str.to_string()));
703 }
704 Ok(auth_str) if auth_str == "Bearer" || auth_str == "Bearer " => {
705 tracing::debug!(
706 "Malformed Bearer token in existing session: missing token value"
707 );
708 }
709 Ok(auth_str) if !auth_str.starts_with("Bearer ") => {
710 let auth_type =
711 auth_str.split_whitespace().next().unwrap_or("unknown");
712 tracing::warn!(
713 "Non-Bearer authorization header ignored for existing session: {}",
714 auth_type
715 );
716 }
717 Err(e) => {
718 tracing::debug!(
719 "Invalid Authorization header encoding in existing session: {}",
720 e
721 );
722 }
723 _ => {}
724 }
725 }
726
727 #[cfg(not(feature = "authorization-token-passthrough"))]
728 if req.headers().get(header::AUTHORIZATION).is_some() {
729 tracing::warn!(
730 "Authorization header present but not forwarded. \
731 Enable 'authorization-token-passthrough' feature to forward tokens to MCP services. \
732 Note: Token passthrough violates MCP specifications. See SECURITY.md for details."
733 );
734 }
735
736 let stream = service
737 .session_manager
738 .create_stream(&session_id, ClientJsonRpcMessage::Request(request_msg))
739 .await
740 .map_err(|e| {
741 InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR)
742 })?;
743
744 // Convert to SSE format with keep-alive
745 // Keep-alive prevents timeouts during long tool execution with no progress updates
746 // Stream closes automatically after final response (keep-alive stops when stream ends)
747 let formatted_stream = stream.map(|msg| {
748 Ok::<_, actix_web::Error>(format_sse_event(
749 msg.event_id.as_deref(),
750 msg.message.as_deref(),
751 ))
752 });
753 let sse_stream =
754 wrap_with_sse_keepalive(formatted_stream, service.sse_keep_alive);
755
756 Ok(HttpResponse::Ok()
757 .content_type(EVENT_STREAM_MIME_TYPE)
758 .append_header((CACHE_CONTROL, "no-cache"))
759 .append_header((HEADER_X_ACCEL_BUFFERING, "no"))
760 .streaming(sse_stream))
761 }
762 ClientJsonRpcMessage::Notification(_)
763 | ClientJsonRpcMessage::Response(_)
764 | ClientJsonRpcMessage::Error(_) => {
765 // Handle notification
766 service
767 .session_manager
768 .accept_message(&session_id, message)
769 .await
770 .map_err(|e| {
771 InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR)
772 })?;
773
774 Ok(HttpResponse::Accepted().finish())
775 }
776 }
777 } else {
778 // No session id in stateful mode. A non-initialize request without
779 // a session id is a 400 Bad Request per MCP 2025-03-26 Streamable
780 // HTTP Session Management. The check happens before create_session
781 // so a rejected request never leaves a stranded session behind.
782 let is_initialize_request = matches!(
783 &message,
784 ClientJsonRpcMessage::Request(request_msg)
785 if matches!(request_msg.request, ClientRequest::InitializeRequest(_))
786 );
787
788 if !is_initialize_request {
789 tracing::warn!("Mcp-Session-Id missing for non-initialize request");
790 return Ok(HttpResponse::BadRequest().body(MISSING_SESSION_ID_BODY));
791 }
792
793 tracing::debug!("POST request without session, creating new session");
794
795 let (session_id, transport) = service
796 .session_manager
797 .create_session()
798 .await
799 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
800
801 tracing::info!(%session_id, "Created new session");
802
803 if let ClientJsonRpcMessage::Request(request_msg) = &mut message {
804 // Call on_request hook to propagate extensions from HttpRequest
805 if let Some(ref hook) = service.on_request {
806 hook(&req, request_msg.request.extensions_mut());
807 }
808
809 // Extract and inject Authorization header if present
810 //
811 // SECURITY: This transport forwards Authorization headers to MCP services.
812 //
813 // MCP-COMPLIANT USAGE: MCP services MUST validate these tokens as intended for themselves
814 // and MUST NOT forward them to upstream APIs (per MCP specification).
815 //
816 // NON-COMPLIANT USAGE: Some implementations (e.g., rmcp-openapi-server) use these tokens
817 // for upstream API authentication. This violates MCP specifications but may be necessary
818 // for certain proxy architectures. Use with caution and ensure proper token audience validation.
819 // See SECURITY.md for details.
820 #[cfg(feature = "authorization-token-passthrough")]
821 if let Some(auth_value) = req.headers().get(header::AUTHORIZATION) {
822 match auth_value.to_str() {
823 Ok(auth_str)
824 if auth_str.starts_with("Bearer ") && auth_str.len() > 7 =>
825 {
826 tracing::debug!(
827 "Forwarding Authorization header to MCP service for new session. \
828 Note: MCP services must not pass this token to upstream APIs per MCP spec. \
829 See SECURITY.md for details."
830 );
831 request_msg
832 .request
833 .extensions_mut()
834 .insert(AuthorizationHeader(auth_str.to_string()));
835 }
836 Ok(auth_str) if auth_str == "Bearer" || auth_str == "Bearer " => {
837 tracing::debug!(
838 "Malformed Bearer token in new session: missing token value"
839 );
840 }
841 Ok(auth_str) if !auth_str.starts_with("Bearer ") => {
842 let auth_type =
843 auth_str.split_whitespace().next().unwrap_or("unknown");
844 tracing::warn!(
845 "Non-Bearer authorization header ignored for new session: {}",
846 auth_type
847 );
848 }
849 Err(e) => {
850 tracing::debug!(
851 "Invalid Authorization header encoding in new session: {}",
852 e
853 );
854 }
855 _ => {}
856 }
857 }
858
859 #[cfg(not(feature = "authorization-token-passthrough"))]
860 if req.headers().get(header::AUTHORIZATION).is_some() {
861 tracing::warn!(
862 "Authorization header present but not forwarded for new session. \
863 Enable 'authorization-token-passthrough' feature to forward tokens to MCP services. \
864 Note: Token passthrough violates MCP specifications. See SECURITY.md for details."
865 );
866 }
867 }
868
869 let service_instance = service
870 .get_service()
871 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
872
873 // Spawn a task to serve the session
874 tokio::spawn({
875 let session_manager = service.session_manager.clone();
876 let session_id = session_id.clone();
877 async move {
878 let service = serve_server::<S, M::Transport, _, TransportAdapterIdentity>(
879 service_instance,
880 transport,
881 )
882 .await;
883 match service {
884 Ok(service) => {
885 let _ = service.waiting().await;
886 }
887 Err(e) => {
888 tracing::error!("Failed to create service: {e}");
889 }
890 }
891 let _ = session_manager
892 .close_session(&session_id)
893 .await
894 .inspect_err(|e| {
895 tracing::error!("Failed to close session {session_id}: {e}");
896 });
897 }
898 });
899
900 // Get initialize response
901 let response = service
902 .session_manager
903 .initialize_session(&session_id, message)
904 .await
905 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
906
907 tracing::debug!(?response, "Initialization complete, creating SSE stream");
908
909 // Return SSE stream with initialization response (no keep-alive)
910 // Per MCP spec: "After the JSON-RPC response has been sent, the server SHOULD close the SSE stream"
911 // Initialization completes with a single response, so no keep-alive needed
912 let sse_stream = async_stream::stream! {
913 yield Ok::<_, actix_web::Error>(Bytes::from(format!(
914 "data: {}\n\n",
915 serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string())
916 )));
917 };
918 tracing::debug!("Created initialization response stream (closes after response)");
919
920 tracing::info!(
921 ?session_id,
922 "Returning SSE streaming response for initialization"
923 );
924 Ok(HttpResponse::Ok()
925 .content_type(EVENT_STREAM_MIME_TYPE)
926 .append_header((CACHE_CONTROL, "no-cache"))
927 .append_header((HEADER_X_ACCEL_BUFFERING, "no"))
928 .append_header((HEADER_SESSION_ID, session_id.as_ref()))
929 .streaming(sse_stream))
930 }
931 } else {
932 // Stateless mode: MCP 2025-03-26 Streamable HTTP Session Management
933 // scopes its session-id rules to "servers that require a session ID",
934 // which a stateless deployment does not. Any Mcp-Session-Id value is
935 // accepted, logged for observability, and otherwise ignored. The
936 // Python and TypeScript reference SDKs make the same interpretation.
937 tracing::debug!("POST request in stateless mode");
938 if req
939 .headers()
940 .get(HEADER_SESSION_ID)
941 .and_then(|v| v.to_str().ok())
942 .filter(|s| !s.is_empty())
943 .is_some()
944 {
945 tracing::debug!("Mcp-Session-Id header ignored in stateless mode");
946 }
947
948 match message {
949 #[allow(unused_mut)]
950 ClientJsonRpcMessage::Request(mut request) => {
951 tracing::debug!(?request, "Processing request in stateless mode");
952
953 // Call on_request hook to propagate extensions from HttpRequest
954 if let Some(ref hook) = service.on_request {
955 hook(&req, request.request.extensions_mut());
956 }
957
958 // Extract and inject Authorization header if present
959 //
960 // SECURITY: This transport forwards Authorization headers to MCP services.
961 //
962 // MCP-COMPLIANT USAGE: MCP services MUST validate these tokens as intended for themselves
963 // and MUST NOT forward them to upstream APIs (per MCP specification).
964 //
965 // NON-COMPLIANT USAGE: Some implementations (e.g., rmcp-openapi-server) use these tokens
966 // for upstream API authentication. This violates MCP specifications but may be necessary
967 // for certain proxy architectures. Use with caution and ensure proper token audience validation.
968 // See SECURITY.md for details.
969 #[cfg(feature = "authorization-token-passthrough")]
970 if let Some(auth_value) = req.headers().get(header::AUTHORIZATION) {
971 match auth_value.to_str() {
972 Ok(auth_str)
973 if auth_str.starts_with("Bearer ") && auth_str.len() > 7 =>
974 {
975 tracing::debug!(
976 "Forwarding Authorization header to MCP service in stateless mode. \
977 Note: MCP services must not pass this token to upstream APIs per MCP spec. \
978 See SECURITY.md for details."
979 );
980 request
981 .request
982 .extensions_mut()
983 .insert(AuthorizationHeader(auth_str.to_string()));
984 }
985 Ok(auth_str) if auth_str == "Bearer" || auth_str == "Bearer " => {
986 tracing::debug!(
987 "Malformed Bearer token in stateless mode: missing token value"
988 );
989 }
990 Ok(auth_str) if !auth_str.starts_with("Bearer ") => {
991 let auth_type =
992 auth_str.split_whitespace().next().unwrap_or("unknown");
993 tracing::warn!(
994 "Non-Bearer authorization header ignored in stateless mode: {}",
995 auth_type
996 );
997 }
998 Err(e) => {
999 tracing::debug!(
1000 "Invalid Authorization header encoding in stateless mode: {}",
1001 e
1002 );
1003 }
1004 _ => {}
1005 }
1006 }
1007
1008 #[cfg(not(feature = "authorization-token-passthrough"))]
1009 if req.headers().get(header::AUTHORIZATION).is_some() {
1010 tracing::warn!(
1011 "Authorization header present but not forwarded in stateless mode. \
1012 Enable 'authorization-token-passthrough' feature to forward tokens to MCP services. \
1013 Note: Token passthrough violates MCP specifications. See SECURITY.md for details."
1014 );
1015 }
1016
1017 // In stateless mode, handle the request directly
1018 let service_instance = service
1019 .get_service()
1020 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
1021
1022 let (transport, receiver) =
1023 OneshotTransport::<RoleServer>::new(ClientJsonRpcMessage::Request(request));
1024 let service_handle = serve_directly(service_instance, transport, None);
1025
1026 tokio::spawn(async move {
1027 // Let the service process the request
1028 let _ = service_handle.waiting().await;
1029 });
1030
1031 // Convert receiver stream to SSE format with keep-alive
1032 // Keep-alive prevents timeouts during long tool execution with no progress updates
1033 // Stream closes automatically after final response (keep-alive stops when stream ends)
1034 let formatted_stream = ReceiverStream::new(receiver).map(|message| {
1035 tracing::info!(?message);
1036 let data =
1037 serde_json::to_string(&message).unwrap_or_else(|_| "{}".to_string());
1038 Ok::<_, actix_web::Error>(Bytes::from(format!("data: {data}\n\n")))
1039 });
1040 let sse_stream =
1041 wrap_with_sse_keepalive(formatted_stream, service.sse_keep_alive);
1042
1043 Ok(HttpResponse::Ok()
1044 .content_type(EVENT_STREAM_MIME_TYPE)
1045 .append_header((CACHE_CONTROL, "no-cache"))
1046 .append_header((HEADER_X_ACCEL_BUFFERING, "no"))
1047 .streaming(sse_stream))
1048 }
1049 _ => Ok(HttpResponse::UnprocessableEntity().body("Unexpected message type")),
1050 }
1051 }
1052 }
1053
1054 async fn handle_delete(req: HttpRequest, service: Data<AppData<S, M>>) -> Result<HttpResponse> {
1055 // Check session id
1056 let session_id = req
1057 .headers()
1058 .get(HEADER_SESSION_ID)
1059 .and_then(|v| v.to_str().ok())
1060 .filter(|s| !s.is_empty())
1061 .map(|s| s.to_owned().into());
1062
1063 let Some(session_id) = session_id else {
1064 return Ok(HttpResponse::BadRequest().body(MISSING_SESSION_ID_BODY));
1065 };
1066
1067 tracing::debug!(%session_id, "DELETE request to close session");
1068
1069 let has_session = service
1070 .session_manager
1071 .has_session(&session_id)
1072 .await
1073 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
1074
1075 if !has_session {
1076 tracing::warn!(%session_id, "Session not found");
1077 return Ok(HttpResponse::NotFound().body(SESSION_NOT_FOUND_BODY));
1078 }
1079
1080 // Close session
1081 service
1082 .session_manager
1083 .close_session(&session_id)
1084 .await
1085 .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
1086
1087 tracing::info!(%session_id, "Session closed");
1088
1089 Ok(HttpResponse::NoContent().finish())
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use rmcp::model::{
1096 EmptyResult, JsonRpcResponse, JsonRpcVersion2_0, RequestId, ServerJsonRpcMessage,
1097 ServerResult,
1098 };
1099
1100 use super::format_sse_event;
1101
1102 fn dummy_message() -> ServerJsonRpcMessage {
1103 ServerJsonRpcMessage::Response(JsonRpcResponse {
1104 jsonrpc: JsonRpcVersion2_0,
1105 id: RequestId::Number(1),
1106 result: ServerResult::EmptyResult(EmptyResult {}),
1107 })
1108 }
1109
1110 /// Regression test for the SEP-1699 priming-event serialization bug.
1111 ///
1112 /// Prior to the fix, `serde_json::to_string(&msg.message)` was applied to
1113 /// `Option<Arc<ServerJsonRpcMessage>>` directly, producing the literal
1114 /// `null` on the wire when the message was `None`. SEP-1699 mandates an
1115 /// empty `data` field instead.
1116 #[test]
1117 fn priming_event_emits_empty_data_not_null() {
1118 let bytes = format_sse_event(Some("0/0"), None);
1119 let wire = std::str::from_utf8(&bytes).expect("utf-8");
1120
1121 assert_eq!(wire, "id: 0/0\ndata:\n\n");
1122 assert!(
1123 !wire.contains("data: null"),
1124 "priming event must not serialize the message as JSON null, got: {wire:?}"
1125 );
1126 }
1127
1128 #[test]
1129 fn message_event_serializes_payload_as_json() {
1130 let message = dummy_message();
1131 let bytes = format_sse_event(Some("1/0"), Some(&message));
1132 let wire = std::str::from_utf8(&bytes).expect("utf-8");
1133
1134 assert_eq!(
1135 wire,
1136 "id: 1/0\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n"
1137 );
1138 }
1139
1140 #[test]
1141 fn message_event_without_event_id_omits_id_line() {
1142 let message = dummy_message();
1143 let bytes = format_sse_event(None, Some(&message));
1144 let wire = std::str::from_utf8(&bytes).expect("utf-8");
1145
1146 assert_eq!(
1147 wire,
1148 "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n"
1149 );
1150 }
1151}