vtcode_mcp/
rmcp_transport.rs1use anyhow::{Context, Result};
6use rmcp::transport::StreamableHttpClientTransport;
7use rmcp::transport::TokioChildProcess;
8use rmcp_reqwest::header::HeaderMap;
9use std::ffi::{OsStr, OsString};
10use std::path::Path;
11use tokio::process::Command;
12
13use vtcode_config::mcp::McpStdioServerConfig;
14
15pub type HttpTransport = StreamableHttpClientTransport<rmcp_reqwest::Client>;
17
18pub fn create_stdio_transport(
27 stdio_config: &McpStdioServerConfig,
28 env: &hashbrown::HashMap<OsString, OsString>,
29) -> Result<TokioChildProcess> {
30 let mut cmd = Command::new(&stdio_config.command);
31
32 let _command = cmd.args(&stdio_config.args);
34
35 if let Some(working_dir) = &stdio_config.working_directory {
37 let _command = cmd.current_dir(working_dir);
38 }
39
40 let _command = cmd.env_clear();
43
44 for (key, value) in env {
46 let _command = cmd.env(key, value);
47 }
48
49 TokioChildProcess::new(cmd).context("Failed to create child process for MCP server")
51}
52
53pub fn create_stdio_transport_with_stderr(
74 program: &OsStr,
75 args: &[OsString],
76 working_dir: Option<&Path>,
77 env: &hashbrown::HashMap<OsString, OsString>,
78) -> Result<(TokioChildProcess, Option<tokio::process::ChildStderr>)> {
79 let mut cmd = Command::new(program);
80
81 let _command = cmd
82 .kill_on_drop(true)
83 .stdin(std::process::Stdio::piped())
84 .stdout(std::process::Stdio::piped())
85 .stderr(std::process::Stdio::piped())
86 .env_clear();
87
88 for (key, value) in env {
90 let _command = cmd.env(key, value);
91 }
92
93 if let Some(dir) = working_dir {
95 let _command = cmd.current_dir(dir);
96 }
97
98 let _command = cmd.args(args);
100
101 let builder = TokioChildProcess::builder(cmd);
103 builder
104 .stderr(std::process::Stdio::piped())
105 .spawn()
106 .context("Failed to create stdio transport with stderr capture")
107}
108
109pub fn create_http_transport(
133 _endpoint: &str,
134 _bearer_token: Option<&str>,
135 _headers: &HeaderMap,
136) -> Result<HttpTransport> {
137 anyhow::bail!(
144 "HTTP transport creation requires rmcp's StreamableHttpClientTransport. \
145 Use RmcpClient::new_streamable_http_client() for full implementation."
146 )
147}
148
149#[cfg(all(test, unix))]
150mod tests {
151 use super::*;
152
153 #[tokio::test]
154 async fn child_dump_waits_for_completed_output() {
155 let dir = tempfile::tempdir().unwrap();
156 let dump = dir.path().join("delayed-dump.txt");
157 tokio::fs::write(&dump, "partial").await.unwrap();
158 let args = [
159 OsString::from("-c"),
160 OsString::from("/bin/sleep 0.2; printf '%s' 'complete-output' > \"$MCP_DUMP\""),
161 ];
162 let env = hashbrown::HashMap::from_iter([(OsString::from("MCP_DUMP"), dump.as_os_str().to_owned())]);
163 let (transport, _stderr) =
164 create_stdio_transport_with_stderr(OsStr::new("/bin/sh"), &args, None, &env).unwrap();
165 let contents = child_env_dump(transport, &dump).await.unwrap();
166 assert_eq!(contents, "complete-output");
167 }
168
169 #[tokio::test]
170 async fn stdio_transport_accepts_borrowed_executable_and_optional_directory() {
171 let dir = tempfile::tempdir().unwrap();
172 let args = [
173 OsString::from("-c"),
174 OsString::from("printf '%s' \"$PWD\" > \"$MCP_DUMP\""),
175 ];
176 for working_dir in [None, Some(dir.path())] {
177 let dump = dir.path().join(if working_dir.is_some() {
178 "explicit-cwd.txt"
179 } else {
180 "default-cwd.txt"
181 });
182 let env = hashbrown::HashMap::from_iter([(OsString::from("MCP_DUMP"), dump.as_os_str().to_owned())]);
183 let (transport, _stderr) =
184 create_stdio_transport_with_stderr(OsStr::new("/bin/sh"), &args, working_dir, &env).unwrap();
185 let contents = child_env_dump(transport, &dump).await.unwrap();
186 let expected = working_dir
187 .map(Path::to_path_buf)
188 .unwrap_or_else(|| std::env::current_dir().unwrap());
189 assert_eq!(
190 vtcode_commons::canonicalize(contents).unwrap(),
191 vtcode_commons::canonicalize(expected).unwrap()
192 );
193 }
194 }
195
196 #[tokio::test]
200 async fn stdio_transport_child_receives_only_the_resolved_environment() {
201 let dir = tempfile::tempdir().unwrap();
202 let dump = dir.path().join("child-env.txt");
203 let process_has_home = std::env::var_os("HOME").is_some();
204
205 let config = McpStdioServerConfig {
206 command: "/bin/sh".to_string(),
207 args: vec!["-c".to_string(), "env > \"$MCP_DUMP\"".to_string()],
208 working_directory: None,
209 };
210 let mut env = hashbrown::HashMap::new();
211 drop(env.insert(
212 OsString::from("PATH"),
213 std::env::var_os("PATH").unwrap_or_else(|| OsString::from("/usr/bin:/bin")),
214 ));
215 drop(env.insert(OsString::from("MCP_MARKER"), OsString::from("marker-value")));
216 drop(env.insert(OsString::from("MCP_DUMP"), dump.as_os_str().to_os_string()));
217 assert!(!env.contains_key(&OsString::from("HOME")));
220
221 let transport = create_stdio_transport(&config, &env).unwrap();
222 let contents = child_env_dump(transport, &dump).await.unwrap();
223
224 assert!(contents.contains("MCP_MARKER=marker-value"), "resolved map not applied: {contents}");
225 assert!(contents.lines().any(|line| line.starts_with("PATH=")), "resolved PATH not applied: {contents}");
226 if process_has_home {
227 assert!(
228 !contents.lines().any(|line| line.starts_with("HOME=")),
229 "MCP stdio child inherited the process environment: {contents}"
230 );
231 }
232 }
233
234 async fn child_env_dump(transport: TokioChildProcess, path: &Path) -> Result<String> {
236 let timeout = std::time::Duration::from_secs(5);
237 let mut child = transport.into_inner().context("transport must own its child")?;
238 let status = match tokio::time::timeout(timeout, child.wait()).await {
239 Ok(status) => status.context("wait for dump child")?,
240 Err(error) => {
241 tokio::time::timeout(timeout, Box::into_pin(child.kill()))
242 .await
243 .context("dump child cleanup timed out")?
244 .context("kill and reap dump child")?;
245 return Err(error).context("MCP dump child did not exit before its deadline");
246 }
247 };
248 anyhow::ensure!(status.success(), "MCP dump child failed: {status}");
249 tokio::fs::read_to_string(path)
250 .await
251 .with_context(|| format!("read completed child dump {}", path.display()))
252 }
253}