Skip to main content

vtcode_mcp/
rmcp_transport.rs

1/// RMCP transport layer wrappers for VT Code
2///
3/// This module provides wrappers around rmcp's transport types to integrate
4/// with VT Code's configuration and error handling.
5use 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
15/// Type alias for HTTP transport
16pub type HttpTransport = StreamableHttpClientTransport<rmcp_reqwest::Client>;
17
18/// Create a stdio-based transport from configuration
19///
20/// # Arguments
21/// * `stdio_config` - Stdio server configuration with command and args
22/// * `env` - Environment variables to pass to the process
23///
24/// # Returns
25/// A TokioChildProcess transport ready to use with RMCP client
26pub 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    // Add arguments
33    let _command = cmd.args(&stdio_config.args);
34
35    // Set working directory if specified
36    if let Some(working_dir) = &stdio_config.working_directory {
37        let _command = cmd.current_dir(working_dir);
38    }
39
40    // Match `create_stdio_transport_with_stderr`: the child receives the
41    // caller-resolved environment only, never vtcode's full process environment.
42    let _command = cmd.env_clear();
43
44    // Configure environment variables
45    for (key, value) in env {
46        let _command = cmd.env(key, value);
47    }
48
49    // Create the child process transport
50    TokioChildProcess::new(cmd).context("Failed to create child process for MCP server")
51}
52
53/// Create a stdio transport from individual parameters (Phase 2 integration)
54///
55/// This is a convenience wrapper for use within RmcpClient where transport
56/// parameters come from different configuration sources.
57///
58/// # Arguments
59/// * `program` - Path to the executable
60/// * `args` - Command arguments
61/// * `working_dir` - Working directory (optional)
62/// * `env` - Environment variables to pass
63///
64/// # Returns
65/// A tuple of (TokioChildProcess transport, stderr reader)
66/// The stderr reader can be passed to async logging tasks
67///
68/// Borrows the executable and optional directory only while configuring the child.
69/// The child receives exactly `env`, without inheriting the parent environment.
70///
71/// # Errors
72/// Returns the spawn failure with transport context if the child cannot be started.
73pub 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    // Add all environment variables
89    for (key, value) in env {
90        let _command = cmd.env(key, value);
91    }
92
93    // Set working directory if provided
94    if let Some(dir) = working_dir {
95        let _command = cmd.current_dir(dir);
96    }
97
98    // Add command arguments
99    let _command = cmd.args(args);
100
101    // Create transport with stderr capture for logging
102    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
109/// Create an HTTP-based transport from endpoint URL (Phase 3.2)
110///
111/// # Arguments
112/// * `endpoint` - HTTP endpoint URL (e.g., `https://api.example.com/mcp`)
113/// * `bearer_token` - Optional bearer token for authentication
114/// * `headers` - Custom HTTP headers to include in requests
115///
116/// # Returns
117/// A StreamableHttpClientTransport ready to use with RMCP client
118///
119/// # Note
120/// This is a convenience wrapper for HTTP transport creation. The actual
121/// transport construction delegates to rmcp's StreamableHttpClientTransport
122/// following the pattern used in RmcpClient::new_streamable_http_client().
123///
124/// # Example
125/// ```ignore
126/// let transport = create_http_transport(
127///     "https://api.example.com/mcp",
128///     Some("auth_token"),
129///     &HeaderMap::new()
130/// )?;
131/// ```
132pub fn create_http_transport(
133    _endpoint: &str,
134    _bearer_token: Option<&str>,
135    _headers: &HeaderMap,
136) -> Result<HttpTransport> {
137    // Phase 3.2: HTTP transport wrapper
138    // NOTE: Full implementation requires direct use of rmcp APIs
139    // See RmcpClient::new_streamable_http_client() for reference implementation
140    // This function provides the interface; actual HTTP transport is created via:
141    // StreamableHttpClientTransport::with_client(http_client, config)
142
143    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    /// MCP stdio children must receive the caller-resolved environment only. Before
197    /// this was pinned, this transport inherited vtcode's whole process environment
198    /// while its `_with_stderr` sibling already cleared it.
199    #[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        // HOME is present in this process but deliberately absent from the map, so
218        // inheritance stays observable in the dump below.
219        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    /// Reap the child before reading a dump that may exist before writing finishes.
235    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}