Skip to main content

vcon_server/mcp/
stdio.rs

1//! `vcon-server mcp`: MCP on stdin and stdout, for an agent that starts the
2//! server as a command. Each request is forwarded to a running server's
3//! `/mcp` with the one key the client file names, so stdio passes the same
4//! authentication, gate and audit as HTTP. The command opens no data
5//! directory and no master key.
6
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10use rmcp::model::{
11    CallToolRequestParams, CallToolResponse, ListToolsResult, PaginatedRequestParams, ServerConfig,
12};
13use rmcp::service::{
14    ClientInitializeError, Peer, RequestContext, RoleClient, RoleServer, RunningService,
15    ServiceError,
16};
17use rmcp::transport::streamable_http_client::{
18    StreamableHttpClientTransport, StreamableHttpClientTransportConfig, StreamableHttpError,
19};
20use rmcp::{ErrorData, ServerHandler, ServiceExt};
21use serde::Deserialize;
22
23use crate::config::ConfigError;
24
25/// The client file `vcon-server mcp` reads.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ClientConfig {
28    /// The server's MCP address, `https://host:port/mcp`.
29    pub url: String,
30    /// A file holding one API token and nothing else.
31    pub key_file: PathBuf,
32    /// PEM certificates to trust for the server; the system's roots when
33    /// absent.
34    pub ca: Option<PathBuf>,
35}
36
37#[derive(Debug, Deserialize)]
38#[serde(deny_unknown_fields)]
39struct Raw {
40    url: String,
41    key_file: PathBuf,
42    ca: Option<PathBuf>,
43}
44
45/// Reads the client file at `path`.
46///
47/// # Errors
48///
49/// [`ConfigError`] when the file cannot be read or does not parse as a
50/// client file.
51pub fn client_config(path: &Path) -> Result<ClientConfig, ConfigError> {
52    let text = std::fs::read_to_string(path)
53        .map_err(|e| ConfigError(format!("{}: {e}", path.display())))?;
54    parse_client(&text)
55}
56
57/// Reads a client file from its TOML text. The URL must be `https`: the
58/// server speaks TLS only.
59///
60/// # Errors
61///
62/// [`ConfigError`] when the text is not TOML, has a key a client file
63/// does not take, or names a URL that is not `https`.
64pub fn parse_client(text: &str) -> Result<ClientConfig, ConfigError> {
65    let raw: Raw = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
66    if !raw.url.starts_with("https://") {
67        return Err(ConfigError(format!(
68            "url: {:?} is not an https:// URL",
69            raw.url
70        )));
71    }
72    Ok(ClientConfig {
73        url: raw.url,
74        key_file: raw.key_file,
75        ca: raw.ca,
76    })
77}
78
79/// Why the command stopped. None of these holds the token.
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[must_use]
82pub enum StdioError {
83    /// The key file could not be read, or holds no single token.
84    KeyFile(String),
85    /// The CA file could not be read, or the TLS client not made.
86    Tls(String),
87    /// The server refused the key with this status.
88    Refused(u16),
89    /// The server could not be reached, or answered something other than
90    /// MCP.
91    Server(String),
92    /// stdin or stdout failed.
93    Io(String),
94}
95
96impl std::error::Error for StdioError {}
97
98impl fmt::Display for StdioError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            StdioError::KeyFile(why) => write!(f, "key_file: {why}"),
102            StdioError::Tls(why) => write!(f, "ca: {why}"),
103            StdioError::Refused(status) => {
104                write!(f, "the server refused the key ({status})")
105            }
106            StdioError::Server(why) => write!(f, "the server: {why}"),
107            StdioError::Io(why) => write!(f, "stdio: {why}"),
108        }
109    }
110}
111
112/// The one token a key file holds, with white space at either end removed.
113fn token(key_file: &Path) -> Result<String, StdioError> {
114    let text = std::fs::read_to_string(key_file)
115        .map_err(|e| StdioError::KeyFile(format!("{}: {e}", key_file.display())))?;
116    let token = text.trim();
117    if token.is_empty() || token.contains(char::is_whitespace) {
118        return Err(StdioError::KeyFile(format!(
119            "{}: holds no single token",
120            key_file.display()
121        )));
122    }
123    Ok(token.to_owned())
124}
125
126/// An HTTPS client trusting `ca`, or the system's roots without it. It
127/// connects directly, through no proxy.
128fn https_client(ca: Option<&Path>) -> Result<reqwest::Client, StdioError> {
129    let mut builder = reqwest::Client::builder().no_proxy();
130    if let Some(ca) = ca {
131        let pem =
132            std::fs::read(ca).map_err(|e| StdioError::Tls(format!("{}: {e}", ca.display())))?;
133        let roots = reqwest::Certificate::from_pem_bundle(&pem)
134            .map_err(|e| StdioError::Tls(format!("{}: {e}", ca.display())))?;
135        if roots.is_empty() {
136            return Err(StdioError::Tls(format!(
137                "{}: holds no certificate",
138                ca.display()
139            )));
140        }
141        builder = builder.tls_certs_only(roots);
142    }
143    builder.build().map_err(|e| StdioError::Tls(e.to_string()))
144}
145
146/// Why the handshake with the server failed: the 401 when the server
147/// refused the key, else the error's text.
148fn refusal(error: &ClientInitializeError) -> StdioError {
149    if let ClientInitializeError::TransportError { error, .. } = error
150        && let Some(StreamableHttpError::AuthRequired(_)) = error
151            .error
152            .downcast_ref::<StreamableHttpError<reqwest::Error>>()
153    {
154        return StdioError::Refused(401);
155    }
156    StdioError::Server(error.to_string())
157}
158
159/// The handshake the stdio side answers: the server's own name,
160/// capabilities and instructions.
161fn identity(peer: &rmcp::model::ServerPeerInfo) -> Result<ServerConfig, StdioError> {
162    let Some(name) = peer.server_info.clone() else {
163        return Err(StdioError::Server("no server name".to_owned()));
164    };
165    let mut info = ServerConfig::new(peer.capabilities.clone());
166    info.server_info = name;
167    info.instructions = peer.instructions.clone();
168    Ok(info)
169}
170
171/// Runs the command until stdin ends.
172///
173/// # Errors
174///
175/// [`StdioError`] when the key file or the CA cannot be read, when the
176/// server cannot be reached or refuses the key, or when stdin or stdout
177/// fails.
178pub async fn stdio(config: &ClientConfig) -> Result<(), StdioError> {
179    let token = token(&config.key_file)?;
180    let client = https_client(config.ca.as_deref())?;
181    let transport = StreamableHttpClientTransport::with_client(
182        client,
183        StreamableHttpClientTransportConfig::with_uri(config.url.as_str()).auth_header(token),
184    );
185    let upstream: RunningService<RoleClient, rmcp::model::ClientConfig> =
186        rmcp::model::ClientConfig::default()
187            .serve(transport)
188            .await
189            .map_err(|e| refusal(&e))?;
190    // The server's own identity and capabilities, as it gave them.
191    let peer = upstream
192        .peer_info()
193        .ok_or_else(|| StdioError::Server("no server information".to_owned()))?;
194    let info = identity(&peer)?;
195    let forward = Forward {
196        upstream: upstream.peer().clone(),
197        info,
198    };
199    let served = forward
200        .serve(rmcp::transport::io::stdio())
201        .await
202        .map_err(|e| StdioError::Io(e.to_string()))?;
203    served
204        .waiting()
205        .await
206        .map_err(|e| StdioError::Io(e.to_string()))?;
207    upstream
208        .cancel()
209        .await
210        .map_err(|e| StdioError::Server(e.to_string()))?;
211    Ok(())
212}
213
214/// The stdio side: answers the handshake as the server answered it, and
215/// sends each tool listing and tool call to the server.
216#[derive(Clone)]
217struct Forward {
218    upstream: Peer<RoleClient>,
219    info: ServerConfig,
220}
221
222/// The server's own refusal as it sent it; any other failure as an
223/// internal error that says what failed.
224fn forwarded(error: ServiceError) -> ErrorData {
225    match error {
226        ServiceError::McpError(error) => error,
227        other => ErrorData::internal_error(format!("the server: {other}"), None),
228    }
229}
230
231impl ServerHandler for Forward {
232    fn get_info(&self) -> ServerConfig {
233        self.info.clone()
234    }
235
236    async fn list_tools(
237        &self,
238        request: Option<PaginatedRequestParams>,
239        _context: RequestContext<RoleServer>,
240    ) -> Result<ListToolsResult, ErrorData> {
241        self.upstream.list_tools(request).await.map_err(forwarded)
242    }
243
244    async fn call_tool(
245        &self,
246        request: CallToolRequestParams,
247        _context: RequestContext<RoleServer>,
248    ) -> Result<CallToolResponse, ErrorData> {
249        self.upstream
250            .call_tool_once(request)
251            .await
252            .map_err(forwarded)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn a_client_file_is_read_and_its_url_must_be_https() {
262        assert_eq!(
263            parse_client(
264                "url = \"https://vcon.example.com:8443/mcp\"\nkey_file = \"/etc/agent.token\"\nca = \"/etc/ca.pem\"\n"
265            ),
266            Ok(ClientConfig {
267                url: "https://vcon.example.com:8443/mcp".to_owned(),
268                key_file: PathBuf::from("/etc/agent.token"),
269                ca: Some(PathBuf::from("/etc/ca.pem")),
270            })
271        );
272        let without_ca =
273            parse_client("url = \"https://vcon.example.com/mcp\"\nkey_file = \"k\"\n").expect("ok");
274        assert_eq!(without_ca.ca, None);
275        for (text, named) in [
276            (
277                "url = \"http://vcon.example.com/mcp\"\nkey_file = \"k\"\n",
278                "url",
279            ),
280            ("key_file = \"k\"\n", "url"),
281            ("url = \"https://vcon.example.com/mcp\"\n", "key_file"),
282            (
283                "url = \"https://vcon.example.com/mcp\"\nkey_file = \"k\"\ntoken = \"t\"\n",
284                "token",
285            ),
286        ] {
287            let error = parse_client(text).expect_err(text);
288            assert!(error.0.contains(named), "{text}: {}", error.0);
289        }
290    }
291
292    /// The client file the website's command-line page shows reads.
293    #[test]
294    fn the_client_file_on_the_command_line_page_reads() {
295        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
296            .join("../../website/content/docs/command-line.md");
297        let text = std::fs::read_to_string(&path).expect("the page");
298        let section = text
299            .split("## MCP for an AI agent\n")
300            .nth(1)
301            .expect("an MCP section")
302            .split("\n## ")
303            .next()
304            .expect("its text");
305        let examples: Vec<&str> = section
306            .split("```toml\n")
307            .skip(1)
308            .map(|block| block.split("```").next().expect("a block"))
309            .collect();
310        assert_eq!(examples.len(), 1);
311        let read = parse_client(examples[0]).expect("reads");
312        assert_eq!(read.url, "https://vcon.example.com:8443/mcp");
313        assert!(read.ca.is_some());
314    }
315
316    #[test]
317    fn each_error_names_what_failed() {
318        for (error, text) in [
319            (
320                StdioError::KeyFile("k: gone".to_owned()),
321                "key_file: k: gone",
322            ),
323            (StdioError::Tls("c: bad".to_owned()), "ca: c: bad"),
324            (StdioError::Refused(401), "the server refused the key (401)"),
325            (StdioError::Server("down".to_owned()), "the server: down"),
326            (StdioError::Io("closed".to_owned()), "stdio: closed"),
327        ] {
328            assert_eq!(error.to_string(), text);
329        }
330    }
331
332    #[test]
333    fn the_handshake_answers_with_the_servers_name_or_not_at_all() {
334        use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerPeerInfo};
335        let capabilities = ServerCapabilities::builder().enable_tools().build();
336        let peer = ServerPeerInfo::new(ProtocolVersion::LATEST, capabilities.clone())
337            .with_server_info(Implementation::new("vcon-server", "9.9.9"))
338            .with_instructions("held");
339        let info = identity(&peer).expect("named");
340        assert_eq!(info.server_info.name, "vcon-server");
341        assert_eq!(info.server_info.version, "9.9.9");
342        assert_eq!(info.capabilities, capabilities);
343        assert_eq!(info.instructions.as_deref(), Some("held"));
344        let nameless = ServerPeerInfo::new(ProtocolVersion::LATEST, capabilities);
345        assert!(matches!(identity(&nameless), Err(StdioError::Server(_))));
346    }
347
348    #[test]
349    fn a_key_file_holds_exactly_one_token() {
350        let dir = tempfile::tempdir().expect("temp dir");
351        let file = dir.path().join("agent.token");
352        std::fs::write(&file, "  vcs_0011223344556677_secret \n").expect("write");
353        assert_eq!(token(&file).expect("read"), "vcs_0011223344556677_secret");
354        for text in ["", " \n", "vcs_a b"] {
355            std::fs::write(&file, text).expect("write");
356            assert!(
357                matches!(token(&file), Err(StdioError::KeyFile(_))),
358                "{text:?}"
359            );
360        }
361        assert!(matches!(
362            token(&dir.path().join("missing")),
363            Err(StdioError::KeyFile(_))
364        ));
365    }
366}