Skip to main content

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}