mcp_streamable_proxy/
backend_connector.rs1use std::collections::HashMap;
4use std::process::Stdio;
5use std::sync::Arc;
6use std::time::Duration;
7
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use process_wrap::tokio::{CommandWrap, KillOnDrop};
11use rmcp::{
12 ServiceExt,
13 transport::{
14 TokioChildProcess,
15 streamable_http_client::{
16 StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
17 },
18 },
19};
20use tracing::info;
21
22#[cfg(unix)]
23use process_wrap::tokio::ProcessGroup;
24
25#[cfg(windows)]
26use process_wrap::tokio::{CreationFlags, JobObject};
27
28use crate::backend_client::{BackendNotificationBridge, UpstreamPeerRegistry, UpstreamPeerSlot};
29use crate::proxy_handler::BackendRunningService;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum BackendIsolation {
34 #[default]
36 Shared,
37 PerSession,
39}
40
41impl BackendIsolation {
42 pub fn for_url() -> Self {
44 Self::PerSession
45 }
46
47 pub fn for_stdio() -> Self {
49 Self::Shared
50 }
51}
52
53#[async_trait]
55pub trait BackendConnector: Send + Sync {
56 async fn connect_shared(
60 &self,
61 registry: Arc<UpstreamPeerRegistry>,
62 ) -> Result<BackendRunningService>;
63
64 async fn connect_session(&self, slot: UpstreamPeerSlot) -> Result<BackendRunningService>;
66
67 fn connect_timeout(&self) -> Duration {
69 Duration::from_secs(30)
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct UrlBackendConnector {
76 pub url: String,
77 pub headers: Option<HashMap<String, String>>,
78 pub connect_timeout: Duration,
79}
80
81impl UrlBackendConnector {
82 pub fn new(url: impl Into<String>, headers: Option<HashMap<String, String>>) -> Self {
84 Self {
85 url: url.into(),
86 headers,
87 connect_timeout: Duration::from_secs(30),
88 }
89 }
90
91 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
93 self.connect_timeout = timeout;
94 self
95 }
96
97 async fn serve_bridge(
98 &self,
99 bridge: BackendNotificationBridge,
100 ) -> Result<BackendRunningService> {
101 let mut req_headers = reqwest::header::HeaderMap::new();
102 let mut auth_header: Option<String> = None;
103
104 if let Some(config_headers) = &self.headers {
105 for (key, value) in config_headers {
106 if key.eq_ignore_ascii_case("Authorization") {
107 auth_header =
108 Some(value.strip_prefix("Bearer ").unwrap_or(value).to_string());
109 continue;
110 }
111 req_headers.insert(
112 reqwest::header::HeaderName::try_from(key)
113 .with_context(|| format!("Invalid header name '{key}'"))?,
114 value
115 .parse()
116 .with_context(|| format!("Invalid header value for '{key}'"))?,
117 );
118 }
119 }
120
121 let http_client = reqwest::Client::builder()
122 .default_headers(req_headers)
123 .connect_timeout(self.connect_timeout)
124 .build()
125 .context("Failed to create HTTP client")?;
126
127 let mut config = StreamableHttpClientTransportConfig::with_uri(self.url.clone());
128 config.auth_header = auth_header;
129
130 let transport = StreamableHttpClientTransport::with_client(http_client, config);
131 let client = bridge
132 .serve(transport)
133 .await
134 .context("Failed to connect URL backend")?;
135 info!(url = %self.url, "URL backend connected");
136 Ok(client)
137 }
138}
139
140#[async_trait]
141impl BackendConnector for UrlBackendConnector {
142 async fn connect_shared(
143 &self,
144 registry: Arc<UpstreamPeerRegistry>,
145 ) -> Result<BackendRunningService> {
146 self.serve_bridge(BackendNotificationBridge::with_default_info(registry))
147 .await
148 }
149
150 async fn connect_session(&self, slot: UpstreamPeerSlot) -> Result<BackendRunningService> {
151 self.serve_bridge(BackendNotificationBridge::for_session(slot))
152 .await
153 }
154
155 fn connect_timeout(&self) -> Duration {
156 self.connect_timeout
157 }
158}
159
160pub struct StdioBackendConnector {
162 command: String,
163 args: Option<Vec<String>>,
164 env: Option<HashMap<String, String>>,
165 mcp_id: String,
166}
167
168impl StdioBackendConnector {
169 pub fn new(
171 command: impl Into<String>,
172 args: Option<Vec<String>>,
173 env: Option<HashMap<String, String>>,
174 mcp_id: impl Into<String>,
175 ) -> Self {
176 Self {
177 command: command.into(),
178 args,
179 env,
180 mcp_id: mcp_id.into(),
181 }
182 }
183
184 async fn spawn(
185 &self,
186 registry: Arc<UpstreamPeerRegistry>,
187 ) -> Result<BackendRunningService> {
188 let args = self.args.clone();
189 let mut wrapped_cmd = CommandWrap::with_new(&self.command, |cmd| {
190 if let Some(cmd_args) = &args {
191 cmd.args(cmd_args);
192 }
193 if let Some(env_vars) = &self.env {
194 for (k, v) in env_vars {
195 cmd.env(k, v);
196 }
197 }
198 });
199
200 #[cfg(unix)]
201 wrapped_cmd.wrap(ProcessGroup::leader());
202
203 #[cfg(windows)]
204 {
205 use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
206 wrapped_cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
207 wrapped_cmd.wrap(JobObject);
208 }
209
210 wrapped_cmd.wrap(KillOnDrop);
211
212 info!(
213 command = %self.command,
214 args = ?args.as_ref().unwrap_or(&vec![]),
215 "Starting stdio backend child process"
216 );
217
218 mcp_common::diagnostic::log_stdio_spawn_context(
219 "StdioBackendConnector",
220 &self.mcp_id,
221 &self.env,
222 );
223
224 let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
225 .stderr(Stdio::piped())
226 .spawn()
227 .map_err(|e| {
228 anyhow::anyhow!(
229 "{}",
230 mcp_common::diagnostic::format_spawn_error(
231 &self.mcp_id,
232 &self.command,
233 &self.args,
234 e
235 )
236 )
237 })?;
238
239 if let Some(stderr_pipe) = child_stderr {
240 mcp_common::spawn_stderr_reader(stderr_pipe, self.mcp_id.clone());
241 }
242
243 let bridge = BackendNotificationBridge::with_default_info(registry);
244 let client = bridge
245 .serve(tokio_process)
246 .await
247 .context("Failed to connect stdio backend")?;
248 info!(mcp_id = %self.mcp_id, "Stdio backend connected");
249 Ok(client)
250 }
251}
252
253#[async_trait]
254impl BackendConnector for StdioBackendConnector {
255 async fn connect_shared(
256 &self,
257 registry: Arc<UpstreamPeerRegistry>,
258 ) -> Result<BackendRunningService> {
259 self.spawn(registry).await
260 }
261
262 async fn connect_session(&self, _slot: UpstreamPeerSlot) -> Result<BackendRunningService> {
263 anyhow::bail!(
264 "Stdio backend does not support per-session isolation in this release; \
265 use BackendIsolation::Shared"
266 )
267 }
268}