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 = Some(value.strip_prefix("Bearer ").unwrap_or(value).to_string());
108 continue;
109 }
110 req_headers.insert(
111 reqwest::header::HeaderName::try_from(key)
112 .with_context(|| format!("Invalid header name '{key}'"))?,
113 value
114 .parse()
115 .with_context(|| format!("Invalid header value for '{key}'"))?,
116 );
117 }
118 }
119
120 let http_client = reqwest::Client::builder()
121 .default_headers(req_headers)
122 .connect_timeout(self.connect_timeout)
123 .build()
124 .context("Failed to create HTTP client")?;
125
126 let mut config = StreamableHttpClientTransportConfig::with_uri(self.url.clone());
127 config.auth_header = auth_header;
128
129 let transport = StreamableHttpClientTransport::with_client(http_client, config);
130 let client = bridge
131 .serve(transport)
132 .await
133 .context("Failed to connect URL backend")?;
134 info!(url = %self.url, "URL backend connected");
135 Ok(client)
136 }
137}
138
139#[async_trait]
140impl BackendConnector for UrlBackendConnector {
141 async fn connect_shared(
142 &self,
143 registry: Arc<UpstreamPeerRegistry>,
144 ) -> Result<BackendRunningService> {
145 self.serve_bridge(BackendNotificationBridge::with_default_info(registry))
146 .await
147 }
148
149 async fn connect_session(&self, slot: UpstreamPeerSlot) -> Result<BackendRunningService> {
150 self.serve_bridge(BackendNotificationBridge::for_session(slot))
151 .await
152 }
153
154 fn connect_timeout(&self) -> Duration {
155 self.connect_timeout
156 }
157}
158
159pub struct StdioBackendConnector {
161 command: String,
162 args: Option<Vec<String>>,
163 env: Option<HashMap<String, String>>,
164 mcp_id: String,
165}
166
167impl StdioBackendConnector {
168 pub fn new(
170 command: impl Into<String>,
171 args: Option<Vec<String>>,
172 env: Option<HashMap<String, String>>,
173 mcp_id: impl Into<String>,
174 ) -> Self {
175 Self {
176 command: command.into(),
177 args,
178 env,
179 mcp_id: mcp_id.into(),
180 }
181 }
182
183 async fn spawn(&self, registry: Arc<UpstreamPeerRegistry>) -> Result<BackendRunningService> {
184 let args = self.args.clone();
185 let mut wrapped_cmd = CommandWrap::with_new(&self.command, |cmd| {
186 if let Some(cmd_args) = &args {
187 cmd.args(cmd_args);
188 }
189 if let Some(env_vars) = &self.env {
190 for (k, v) in env_vars {
191 cmd.env(k, v);
192 }
193 }
194 });
195
196 #[cfg(unix)]
197 wrapped_cmd.wrap(ProcessGroup::leader());
198
199 #[cfg(windows)]
200 {
201 use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
202 wrapped_cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
203 wrapped_cmd.wrap(JobObject);
204 }
205
206 wrapped_cmd.wrap(KillOnDrop);
207
208 info!(
209 command = %self.command,
210 args = ?args.as_ref().unwrap_or(&vec![]),
211 "Starting stdio backend child process"
212 );
213
214 mcp_common::diagnostic::log_stdio_spawn_context(
215 "StdioBackendConnector",
216 &self.mcp_id,
217 &self.env,
218 );
219
220 let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
221 .stderr(Stdio::piped())
222 .spawn()
223 .map_err(|e| {
224 anyhow::anyhow!(
225 "{}",
226 mcp_common::diagnostic::format_spawn_error(
227 &self.mcp_id,
228 &self.command,
229 &self.args,
230 e
231 )
232 )
233 })?;
234
235 if let Some(stderr_pipe) = child_stderr {
236 mcp_common::spawn_stderr_reader(stderr_pipe, self.mcp_id.clone());
237 }
238
239 let bridge = BackendNotificationBridge::with_default_info(registry);
240 let client = bridge
241 .serve(tokio_process)
242 .await
243 .context("Failed to connect stdio backend")?;
244 info!(mcp_id = %self.mcp_id, "Stdio backend connected");
245 Ok(client)
246 }
247}
248
249#[async_trait]
250impl BackendConnector for StdioBackendConnector {
251 async fn connect_shared(
252 &self,
253 registry: Arc<UpstreamPeerRegistry>,
254 ) -> Result<BackendRunningService> {
255 self.spawn(registry).await
256 }
257
258 async fn connect_session(&self, _slot: UpstreamPeerSlot) -> Result<BackendRunningService> {
259 anyhow::bail!(
260 "Stdio backend does not support per-session isolation in this release; \
261 use BackendIsolation::Shared"
262 )
263 }
264}