Skip to main content

mcp_sse_proxy/
server_builder.rs

1//! SSE Server Builder
2//!
3//! This module provides a high-level Builder API for creating SSE MCP servers.
4//! It encapsulates all rmcp-specific types and provides a simple interface for mcp-proxy.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::Duration;
9
10use anyhow::Result;
11use tokio_util::sync::CancellationToken;
12use tracing::{debug, info, warn};
13
14// 进程组管理(跨平台子进程清理)
15use process_wrap::tokio::{KillOnDrop, TokioCommandWrap};
16
17#[cfg(unix)]
18use process_wrap::tokio::ProcessGroup;
19
20#[cfg(windows)]
21use process_wrap::tokio::JobObject;
22
23use rmcp::{
24    ServiceExt,
25    model::{ClientCapabilities, ClientInfo, ProtocolVersion},
26    transport::{
27        SseClientTransport, TokioChildProcess,
28        sse_client::SseClientConfig,
29        sse_server::{SseServer, SseServerConfig},
30    },
31};
32
33use crate::{
34    sse_handler::{BackendSessionHandler, SseServerHandler},
35    SseHandler, ToolFilter,
36};
37
38/// Performance warning threshold for stdio (child process) backend connections
39const STDIO_SLOW_THRESHOLD_SECS: u64 = 30;
40
41/// Performance warning threshold for HTTP-based backend connections (SSE/Stream)
42const HTTP_SLOW_THRESHOLD_SECS: u64 = 10;
43
44/// Backend configuration for the MCP server
45///
46/// Defines how the proxy connects to the upstream MCP service.
47#[derive(Clone)]
48pub enum BackendConfig {
49    /// Connect to a local command via stdio
50    Stdio {
51        /// Command to execute (e.g., "npx", "python", etc.)
52        command: String,
53        /// Arguments for the command
54        args: Option<Vec<String>>,
55        /// Environment variables
56        env: Option<HashMap<String, String>>,
57    },
58    /// Connect to a remote URL using SSE protocol
59    SseUrl {
60        /// URL of the MCP SSE service
61        url: String,
62        /// Custom HTTP headers (including Authorization)
63        headers: Option<HashMap<String, String>>,
64    },
65    /// Use a pre-connected backend via BackendBridge trait
66    ///
67    /// The caller is responsible for establishing the backend connection and
68    /// passing it as an `Arc<dyn BackendBridge>`. This enables protocol conversion
69    /// (e.g., Streamable HTTP backend → SSE frontend) without this module
70    /// depending on the specific backend implementation.
71    BackendBridge(Arc<dyn mcp_common::BackendBridge>),
72}
73
74impl std::fmt::Debug for BackendConfig {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            BackendConfig::Stdio { command, args, .. } => f
78                .debug_struct("Stdio")
79                .field("command", command)
80                .field("args", args)
81                .finish(),
82            BackendConfig::SseUrl { url, .. } => {
83                f.debug_struct("SseUrl").field("url", url).finish()
84            }
85            BackendConfig::BackendBridge(bridge) => f
86                .debug_struct("BackendBridge")
87                .field("mcp_id", &bridge.mcp_id())
88                .finish(),
89        }
90    }
91}
92
93/// Configuration for the SSE server
94#[derive(Debug, Clone)]
95pub struct SseServerBuilderConfig {
96    /// SSE endpoint path (default: "/sse")
97    pub sse_path: String,
98    /// Message endpoint path (default: "/message")
99    pub post_path: String,
100    /// MCP service identifier for logging
101    pub mcp_id: Option<String>,
102    /// Tool filter configuration
103    pub tool_filter: Option<ToolFilter>,
104    /// Keep-alive interval in seconds (default: 15)
105    pub keep_alive_secs: u64,
106    /// Enable stateful mode with full MCP initialization (default: true)
107    /// When false, uses `with_service_directly` which skips initialization for faster responses
108    pub stateful: bool,
109}
110
111impl Default for SseServerBuilderConfig {
112    fn default() -> Self {
113        Self {
114            sse_path: "/sse".into(),
115            post_path: "/message".into(),
116            mcp_id: None,
117            tool_filter: None,
118            keep_alive_secs: 15,
119            stateful: true,
120        }
121    }
122}
123
124/// Log connection timing with optional performance warning
125///
126/// # Arguments
127///
128/// * `mcp_id` - MCP service identifier
129/// * `backend_type` - Type of backend (e.g., "stdio", "SSE", "Streamable HTTP")
130/// * `total_duration` - Total connection time
131/// * `breakdown` - Optional breakdown of timing components
132/// * `warn_threshold_secs` - Threshold for performance warning
133/// * `warn_message` - Message to show if threshold exceeded
134fn log_connection_timing(
135    mcp_id: &str,
136    backend_type: &str,
137    total_duration: Duration,
138    breakdown: &[(&str, Duration)],
139    warn_threshold_secs: u64,
140    warn_message: &str,
141) {
142    let breakdown_str: Vec<String> = breakdown
143        .iter()
144        .map(|(name, dur)| format!("{}: {:?}", name, dur))
145        .collect();
146
147    info!(
148        "[SseServerBuilder] {} backend connected successfully - MCP ID: {}, total: {:?} ({})",
149        backend_type,
150        mcp_id,
151        total_duration,
152        breakdown_str.join(", ")
153    );
154
155    if total_duration.as_secs() >= warn_threshold_secs {
156        warn!(
157            "[SseServerBuilder] {} backend connection takes a long time - MCP ID: {}, time: {:?}, {}",
158            backend_type, mcp_id, total_duration, warn_message
159        );
160    }
161}
162
163/// Builder for creating SSE MCP servers
164///
165/// Provides a fluent API for configuring and building MCP proxy servers.
166///
167/// # Example
168///
169/// ```rust,ignore
170/// use mcp_sse_proxy::server_builder::{SseServerBuilder, BackendConfig};
171///
172/// // Create a server with stdio backend
173/// let (router, ct) = SseServerBuilder::new(BackendConfig::Stdio {
174///     command: "npx".into(),
175///     args: Some(vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into()]),
176///     env: None,
177/// })
178/// .mcp_id("my-server")
179/// .sse_path("/custom/sse")
180/// .post_path("/custom/message")
181/// .stateful(false)  // Disable stateful mode for OneShot services (faster responses)
182/// .build()
183/// .await?;
184/// ```
185pub struct SseServerBuilder {
186    backend_config: BackendConfig,
187    server_config: SseServerBuilderConfig,
188}
189
190impl SseServerBuilder {
191    /// Create a new builder with the given backend configuration
192    pub fn new(backend: BackendConfig) -> Self {
193        Self {
194            backend_config: backend,
195            server_config: SseServerBuilderConfig::default(),
196        }
197    }
198
199    /// Set the SSE endpoint path
200    pub fn sse_path(mut self, path: impl Into<String>) -> Self {
201        self.server_config.sse_path = path.into();
202        self
203    }
204
205    /// Set the message endpoint path
206    pub fn post_path(mut self, path: impl Into<String>) -> Self {
207        self.server_config.post_path = path.into();
208        self
209    }
210
211    /// Set the MCP service identifier
212    ///
213    /// Used for logging and service identification.
214    pub fn mcp_id(mut self, id: impl Into<String>) -> Self {
215        self.server_config.mcp_id = Some(id.into());
216        self
217    }
218
219    /// Set the tool filter configuration
220    pub fn tool_filter(mut self, filter: ToolFilter) -> Self {
221        self.server_config.tool_filter = Some(filter);
222        self
223    }
224
225    /// Set the keep-alive interval in seconds
226    pub fn keep_alive(mut self, secs: u64) -> Self {
227        self.server_config.keep_alive_secs = secs;
228        self
229    }
230
231    /// Set stateful mode (default: true)
232    ///
233    /// When false, uses `with_service_directly` which skips MCP initialization
234    /// for faster responses. This is recommended for OneShot services.
235    pub fn stateful(mut self, stateful: bool) -> Self {
236        self.server_config.stateful = stateful;
237        self
238    }
239
240    /// Build the server and return an axum Router, CancellationToken, and handler
241    ///
242    /// The router can be merged with other axum routers or served directly.
243    /// The CancellationToken can be used to gracefully shut down the service.
244    /// The handler is a unified type that can be either SseHandler or BackendSessionHandler.
245    pub async fn build(self) -> Result<(axum::Router, CancellationToken, SseServerHandler)> {
246        let mcp_id = self
247            .server_config
248            .mcp_id
249            .clone()
250            .unwrap_or_else(|| "sse-proxy".into());
251
252        // Create client info for connecting to backend
253        let client_info = ClientInfo {
254            protocol_version: ProtocolVersion::V_2024_11_05,
255            capabilities: ClientCapabilities::builder()
256                .enable_experimental()
257                .enable_roots()
258                .enable_roots_list_changed()
259                .enable_sampling()
260                .build(),
261            ..Default::default()
262        };
263
264        // Connect to backend based on configuration
265        let client = match &self.backend_config {
266            BackendConfig::Stdio { command, args, env } => {
267                self.connect_stdio(command, args, env, &client_info).await?
268            }
269            BackendConfig::SseUrl { url, headers } => {
270                self.connect_sse_url(url, headers, &client_info).await?
271            }
272            BackendConfig::BackendBridge(bridge) => {
273                let handler = BackendSessionHandler::new(bridge.clone(), mcp_id.clone());
274                let (router, ct, handler_for_return) =
275                    self.create_server_with_backend_session(handler).await?;
276
277                info!(
278                    "[SseServerBuilder] Server created with backend bridge \
279                     - mcp_id: {}, sse_path: {}, post_path: {}",
280                    mcp_id, self.server_config.sse_path, self.server_config.post_path
281                );
282
283                return Ok((
284                    router,
285                    ct,
286                    SseServerHandler::BackendSession(handler_for_return),
287                ));
288            }
289        };
290
291        // Create SSE handler
292        let sse_handler = if let Some(ref tool_filter) = self.server_config.tool_filter {
293            SseHandler::with_tool_filter(client, mcp_id.clone(), tool_filter.clone())
294        } else {
295            SseHandler::with_mcp_id(client, mcp_id.clone())
296        };
297
298        // Clone handler before creating server (create_server uses sse_handler.clone() internally)
299        let handler_for_return = sse_handler.clone();
300
301        // Create SSE server
302        let (router, ct) = self.create_server(sse_handler)?;
303
304        info!(
305            "[SseServerBuilder] Server created - mcp_id: {}, sse_path: {}, post_path: {}",
306            mcp_id, self.server_config.sse_path, self.server_config.post_path
307        );
308
309        Ok((router, ct, SseServerHandler::Sse(handler_for_return)))
310    }
311
312    /// Connect to a stdio backend (child process)
313    async fn connect_stdio(
314        &self,
315        command: &str,
316        args: &Option<Vec<String>>,
317        env: &Option<HashMap<String, String>>,
318        client_info: &ClientInfo,
319    ) -> Result<rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>> {
320        use std::time::Instant;
321
322        let start_time = Instant::now();
323        let mcp_id = self
324            .server_config
325            .mcp_id
326            .clone()
327            .unwrap_or_else(|| "unknown".into());
328
329        // 使用 process-wrap 创建子进程命令(跨平台进程清理)
330        // process-wrap 会自动处理进程组(Unix)或 Job Object(Windows)
331        // 并且在 Drop 时自动清理子进程树
332        // 子进程默认继承父进程的所有环境变量
333        let mut wrapped_cmd = TokioCommandWrap::with_new(command, |cmd| {
334            if let Some(cmd_args) = args {
335                cmd.args(cmd_args);
336            }
337            // 设置 MCP JSON 配置中的环境变量(会覆盖继承的同名变量)
338            if let Some(env_vars) = env {
339                for (k, v) in env_vars {
340                    cmd.env(k, v);
341                }
342            }
343        });
344
345        // Unix: 创建进程组,支持 killpg 清理整个进程树
346        #[cfg(unix)]
347        wrapped_cmd.wrap(ProcessGroup::leader());
348        // Windows: 使用 CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP 隐藏控制台窗口
349        #[cfg(windows)]
350        {
351            use process_wrap::tokio::CreationFlags;
352            use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
353            wrapped_cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
354            wrapped_cmd.wrap(JobObject);
355        }
356
357        // 所有平台: Drop 时自动清理进程
358        wrapped_cmd.wrap(KillOnDrop);
359
360        info!(
361            "[SseServerBuilder] Starting child process - MCP ID: {}, command: {}, args: {:?}",
362            mcp_id,
363            command,
364            args.as_ref().unwrap_or(&vec![])
365        );
366
367        // 诊断日志:子进程关键环境变量
368        mcp_common::diagnostic::log_stdio_spawn_context("SseServerBuilder", &mcp_id, env);
369
370        let process_start = Instant::now();
371        // MCP 服务通过 stdin/stdout 进行 JSON-RPC 通信,必须使用 piped(默认行为)
372        // 使用 builder 模式捕获 stderr,便于诊断子 MCP 服务初始化失败
373        let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
374            .stderr(std::process::Stdio::piped())
375            .spawn()
376            .map_err(|e| {
377                anyhow::anyhow!(
378                    "{}",
379                    mcp_common::diagnostic::format_spawn_error(&mcp_id, command, args, e)
380                )
381            })?;
382
383        // 启动 stderr 日志读取任务
384        if let Some(stderr_pipe) = child_stderr {
385            mcp_common::spawn_stderr_reader(stderr_pipe, mcp_id.clone());
386        }
387
388        let process_duration = process_start.elapsed();
389
390        debug!(
391            "[SseServerBuilder] Child process spawned - MCP ID: {}, spawn time: {:?}",
392            mcp_id, process_duration
393        );
394
395        let serve_start = Instant::now();
396        let client = client_info.clone().serve(tokio_process).await?;
397        let serve_duration = serve_start.elapsed();
398        let total_duration = start_time.elapsed();
399
400        let warn_msg = "建议的优化方案: \
401            1) 检查网络连接速度 (npm 包下载) \
402            2) 配置国内 npm 镜像 (如淘宝镜像: npm config set registry https://registry.npmmirror.com) \
403            3) 预热服务 (启动 mcp-proxy 时预先加载常用服务) \
404            4) 检查命令参数是否正确";
405
406        log_connection_timing(
407            &mcp_id,
408            "Stdio",
409            total_duration,
410            &[("spawn", process_duration), ("serve", serve_duration)],
411            STDIO_SLOW_THRESHOLD_SECS,
412            warn_msg,
413        );
414
415        Ok(client)
416    }
417
418    /// Connect to an SSE URL backend
419    async fn connect_sse_url(
420        &self,
421        url: &str,
422        headers: &Option<HashMap<String, String>>,
423        client_info: &ClientInfo,
424    ) -> Result<rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>> {
425        use std::time::Instant;
426
427        let start_time = Instant::now();
428        let mcp_id = self
429            .server_config
430            .mcp_id
431            .clone()
432            .unwrap_or_else(|| "unknown".into());
433
434        info!(
435            "[SseServerBuilder] Connecting to SSE URL backend - MCP ID: {}, URL: {}",
436            mcp_id, url
437        );
438
439        // Build HTTP client with custom headers
440        let mut req_headers = reqwest::header::HeaderMap::new();
441
442        if let Some(config_headers) = headers {
443            for (key, value) in config_headers {
444                req_headers.insert(
445                    reqwest::header::HeaderName::try_from(key)
446                        .map_err(|e| anyhow::anyhow!("Invalid header name '{}': {}", key, e))?,
447                    value.parse().map_err(|e| {
448                        anyhow::anyhow!("Invalid header value for '{}': {}", key, e)
449                    })?,
450                );
451            }
452        }
453
454        let http_client = reqwest::Client::builder()
455            .default_headers(req_headers)
456            .build()
457            .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?;
458
459        // Create SSE client configuration
460        let sse_config = SseClientConfig {
461            sse_endpoint: url.to_string().into(),
462            ..Default::default()
463        };
464
465        let transport_start = Instant::now();
466        let sse_transport = SseClientTransport::start_with_client(http_client, sse_config).await?;
467        let transport_duration = transport_start.elapsed();
468
469        let serve_start = Instant::now();
470        let client = client_info.clone().serve(sse_transport).await?;
471        let serve_duration = serve_start.elapsed();
472        let total_duration = start_time.elapsed();
473
474        log_connection_timing(
475            &mcp_id,
476            "SSE",
477            total_duration,
478            &[("transport", transport_duration), ("serve", serve_duration)],
479            HTTP_SLOW_THRESHOLD_SECS,
480            "建议: 检查网络连接和后端服务状态",
481        );
482
483        Ok(client)
484    }
485
486    /// Create the SSE server
487    fn create_server(&self, sse_handler: SseHandler) -> Result<(axum::Router, CancellationToken)> {
488        // SSE server uses bind address 0.0.0.0:0 since we're returning a router
489        // The actual binding will be done by the caller
490        let config = SseServerConfig {
491            bind: "0.0.0.0:0".parse()?,
492            sse_path: self.server_config.sse_path.clone(),
493            post_path: self.server_config.post_path.clone(),
494            ct: CancellationToken::new(),
495            sse_keep_alive: Some(std::time::Duration::from_secs(
496                self.server_config.keep_alive_secs,
497            )),
498        };
499
500        let (sse_server, router) = SseServer::new(config);
501
502        // Use with_service_directly for non-stateful mode (OneShot services)
503        // This skips MCP initialization for faster responses
504        let ct = if self.server_config.stateful {
505            sse_server.with_service(move || sse_handler.clone())
506        } else {
507            sse_server.with_service_directly(move || sse_handler.clone())
508        };
509
510        Ok((router, ct))
511    }
512
513    /// Create SSE server with BackendSessionHandler (for StreamUrl backend)
514    async fn create_server_with_backend_session(
515        &self,
516        handler: BackendSessionHandler,
517    ) -> Result<(axum::Router, CancellationToken, BackendSessionHandler)> {
518        let config = SseServerConfig {
519            bind: "0.0.0.0:0".parse()?,
520            sse_path: self.server_config.sse_path.clone(),
521            post_path: self.server_config.post_path.clone(),
522            ct: CancellationToken::new(),
523            sse_keep_alive: Some(std::time::Duration::from_secs(
524                self.server_config.keep_alive_secs,
525            )),
526        };
527
528        let (sse_server, router) = SseServer::new(config);
529
530        // Clone handler before passing to closure since we need to return it
531        let handler_for_return = handler.clone();
532        let ct = if self.server_config.stateful {
533            sse_server.with_service(move || handler.clone())
534        } else {
535            sse_server.with_service_directly(move || handler.clone())
536        };
537
538        Ok((router, ct, handler_for_return))
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_builder_creation() {
548        let builder = SseServerBuilder::new(BackendConfig::Stdio {
549            command: "echo".into(),
550            args: Some(vec!["hello".into()]),
551            env: None,
552        })
553        .mcp_id("test")
554        .sse_path("/custom/sse")
555        .post_path("/custom/message");
556
557        assert!(builder.server_config.mcp_id.is_some());
558        assert_eq!(builder.server_config.mcp_id.as_deref(), Some("test"));
559        assert_eq!(builder.server_config.sse_path, "/custom/sse");
560        assert_eq!(builder.server_config.post_path, "/custom/message");
561    }
562
563    #[test]
564    fn test_default_config() {
565        let config = SseServerBuilderConfig::default();
566        assert_eq!(config.sse_path, "/sse");
567        assert_eq!(config.post_path, "/message");
568        assert_eq!(config.keep_alive_secs, 15);
569        assert!(
570            config.stateful,
571            "default stateful should be true for backward compatibility"
572        );
573    }
574
575    #[test]
576    fn test_stateful_flag_default() {
577        let builder = SseServerBuilder::new(BackendConfig::Stdio {
578            command: "echo".into(),
579            args: None,
580            env: None,
581        });
582        assert!(
583            builder.server_config.stateful,
584            "stateful should default to true"
585        );
586    }
587
588    #[test]
589    fn test_stateful_flag_disabled() {
590        let builder = SseServerBuilder::new(BackendConfig::Stdio {
591            command: "echo".into(),
592            args: None,
593            env: None,
594        })
595        .stateful(false);
596        assert!(
597            !builder.server_config.stateful,
598            "stateful should be false when set"
599        );
600    }
601
602    #[test]
603    fn test_stateful_flag_enabled() {
604        let builder = SseServerBuilder::new(BackendConfig::Stdio {
605            command: "echo".into(),
606            args: None,
607            env: None,
608        })
609        .stateful(true);
610        assert!(
611            builder.server_config.stateful,
612            "stateful should be true when set"
613        );
614    }
615
616    #[test]
617    fn test_timing_constants() {
618        assert_eq!(STDIO_SLOW_THRESHOLD_SECS, 30);
619        assert_eq!(HTTP_SLOW_THRESHOLD_SECS, 10);
620    }
621
622    #[test]
623    fn test_log_connection_timing_format() {
624        use std::time::Duration;
625        // Test that the function doesn't panic and formats correctly
626        log_connection_timing(
627            "test-mcp",
628            "TestBackend",
629            Duration::from_millis(1500),
630            &[
631                ("step1", Duration::from_millis(500)),
632                ("step2", Duration::from_millis(1000)),
633            ],
634            10,
635            "Test warning message",
636        );
637        // If we get here, the function works correctly
638    }
639
640    #[test]
641    fn test_log_connection_timing_no_breakdown() {
642        use std::time::Duration;
643        // Test with empty breakdown
644        log_connection_timing(
645            "test-mcp",
646            "TestBackend",
647            Duration::from_millis(500),
648            &[],
649            10,
650            "Test warning message",
651        );
652    }
653}