1use 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ClientConfig {
28 pub url: String,
30 pub key_file: PathBuf,
32 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
45pub 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
57pub 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#[derive(Debug, Clone, PartialEq, Eq)]
81#[must_use]
82pub enum StdioError {
83 KeyFile(String),
85 Tls(String),
87 Refused(u16),
89 Server(String),
92 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
112fn 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
126fn 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
146fn 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
159fn 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
171pub 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 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#[derive(Clone)]
217struct Forward {
218 upstream: Peer<RoleClient>,
219 info: ServerConfig,
220}
221
222fn 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 #[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}