1use std::{
2 io::{BufRead as _, BufReader, Read as _, Write as _},
3 process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio},
4 sync::{Arc, Mutex},
5 thread,
6 time::{Duration, Instant},
7};
8
9use serde_json::{Value, json};
10use thiserror::Error;
11
12use crate::MCP_PROTOCOL_VERSION;
13
14const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
15
16#[derive(Debug, Error)]
18pub enum McpStdioSmokeError {
19 #[error("failed to spawn MCP stdio server: {source}")]
20 Spawn {
21 #[source]
22 source: std::io::Error,
23 },
24 #[error("spawned MCP stdio server has no {0}")]
25 MissingPipe(&'static str),
26 #[error("failed to write MCP request: {source}")]
27 Write {
28 #[source]
29 source: std::io::Error,
30 },
31 #[error("failed to flush MCP request: {source}")]
32 Flush {
33 #[source]
34 source: std::io::Error,
35 },
36 #[error("failed to read MCP response: {source}")]
37 Read {
38 #[source]
39 source: std::io::Error,
40 },
41 #[error("MCP stdio server closed stdout before responding to `{method}`{status}{stderr}")]
42 Eof {
43 method: String,
44 status: ProcessStatus,
45 stderr: StderrSnapshot,
46 },
47 #[error("MCP stdio server returned invalid JSON for `{method}`: {source}; line: {line}")]
48 InvalidJson {
49 method: String,
50 line: String,
51 #[source]
52 source: serde_json::Error,
53 },
54 #[error("MCP stdio server returned JSON-RPC error for `{method}`: {error}")]
55 Rpc { method: String, error: Value },
56 #[error("MCP stdio server response for `{method}` did not contain `result`: {response}")]
57 MissingResult { method: String, response: Value },
58 #[error(
59 "MCP stdio server response for `{method}` did not contain the expected id `{id}`: {response}"
60 )]
61 UnexpectedResponse {
62 method: String,
63 id: u64,
64 response: Value,
65 },
66 #[error("failed to wait for MCP stdio server shutdown: {source}")]
67 Wait {
68 #[source]
69 source: std::io::Error,
70 },
71 #[error("failed to kill MCP stdio server after shutdown timeout: {source}")]
72 Kill {
73 #[source]
74 source: std::io::Error,
75 },
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct ProcessStatus(Option<String>);
81
82impl ProcessStatus {
83 fn running() -> Self {
84 Self(None)
85 }
86
87 fn exited(status: ExitStatus) -> Self {
88 Self(Some(status.to_string()))
89 }
90}
91
92impl std::fmt::Display for ProcessStatus {
93 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match &self.0 {
95 Some(status) => write!(formatter, " (process status: {status})"),
96 None => Ok(()),
97 }
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct StderrSnapshot(String);
104
105impl StderrSnapshot {
106 fn empty() -> Self {
107 Self(String::new())
108 }
109
110 fn from_stderr(stderr: &Arc<Mutex<String>>) -> Self {
111 Self(
112 stderr
113 .lock()
114 .map(|stderr| stderr.clone())
115 .unwrap_or_else(|error| format!("stderr capture lock failed: {error}")),
116 )
117 }
118
119 pub fn as_str(&self) -> &str {
120 &self.0
121 }
122}
123
124impl std::fmt::Display for StderrSnapshot {
125 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 if self.0.trim().is_empty() {
127 return Ok(());
128 }
129 write!(formatter, "\nstderr:\n{}", self.0.trim_end())
130 }
131}
132
133pub struct McpStdioSmokeClient {
138 child: Child,
139 stdin: Option<ChildStdin>,
140 stdout: BufReader<ChildStdout>,
141 stderr: Arc<Mutex<String>>,
142 next_request_id: u64,
143}
144
145impl McpStdioSmokeClient {
146 pub fn spawn(command: &mut Command) -> Result<Self, McpStdioSmokeError> {
148 let mut child = command
149 .stdin(Stdio::piped())
150 .stdout(Stdio::piped())
151 .stderr(Stdio::piped())
152 .spawn()
153 .map_err(|source| McpStdioSmokeError::Spawn { source })?;
154
155 let stdin = child
156 .stdin
157 .take()
158 .ok_or(McpStdioSmokeError::MissingPipe("stdin"))?;
159 let stdout = child
160 .stdout
161 .take()
162 .ok_or(McpStdioSmokeError::MissingPipe("stdout"))?;
163 let stderr = child
164 .stderr
165 .take()
166 .ok_or(McpStdioSmokeError::MissingPipe("stderr"))?;
167 let stderr = capture_stderr(stderr);
168
169 Ok(Self {
170 child,
171 stdin: Some(stdin),
172 stdout: BufReader::new(stdout),
173 stderr,
174 next_request_id: 1,
175 })
176 }
177
178 pub fn discover(&mut self) -> Result<Value, McpStdioSmokeError> {
180 self.request("server/discover", json!({}))
181 }
182
183 pub fn list_tools(&mut self) -> Result<Value, McpStdioSmokeError> {
185 self.request("tools/list", json!({}))
186 }
187
188 pub fn list_resources(&mut self) -> Result<Value, McpStdioSmokeError> {
190 self.request("resources/list", json!({}))
191 }
192
193 pub fn list_resource_templates(&mut self) -> Result<Value, McpStdioSmokeError> {
195 self.request("resources/templates/list", json!({}))
196 }
197
198 pub fn read_resource(&mut self, uri: &str) -> Result<Value, McpStdioSmokeError> {
200 self.request("resources/read", json!({ "uri": uri }))
201 }
202
203 pub fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, McpStdioSmokeError> {
205 self.request(
206 "tools/call",
207 json!({
208 "name": name,
209 "arguments": arguments,
210 }),
211 )
212 }
213
214 pub fn request(
216 &mut self,
217 method: &str,
218 mut params: Value,
219 ) -> Result<Value, McpStdioSmokeError> {
220 let id = self.next_request_id;
221 self.next_request_id = self.next_request_id.saturating_add(1);
222 if let Some(params) = params.as_object_mut() {
223 params.insert(
224 "_meta".to_string(),
225 json!({
226 "io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL_VERSION,
227 "io.modelcontextprotocol/clientCapabilities": {},
228 "io.modelcontextprotocol/clientInfo": {
229 "name": "component-shape-mcp-stdio-smoke",
230 "version": env!("CARGO_PKG_VERSION"),
231 },
232 }),
233 );
234 }
235 self.write_message(json!({
236 "jsonrpc": "2.0",
237 "id": id,
238 "method": method,
239 "params": params,
240 }))?;
241 self.read_response(method, id)
242 }
243
244 pub fn shutdown(
246 &mut self,
247 timeout: Duration,
248 ) -> Result<Option<ExitStatus>, McpStdioSmokeError> {
249 self.stdin.take();
250 let deadline = Instant::now() + timeout;
251 loop {
252 match self
253 .child
254 .try_wait()
255 .map_err(|source| McpStdioSmokeError::Wait { source })?
256 {
257 Some(status) => return Ok(Some(status)),
258 None if Instant::now() >= deadline => {
259 self.child
260 .kill()
261 .map_err(|source| McpStdioSmokeError::Kill { source })?;
262 return self
263 .child
264 .wait()
265 .map(Some)
266 .map_err(|source| McpStdioSmokeError::Wait { source });
267 },
268 None => thread::sleep(Duration::from_millis(20)),
269 }
270 }
271 }
272
273 pub fn stderr(&self) -> StderrSnapshot {
275 StderrSnapshot::from_stderr(&self.stderr)
276 }
277
278 fn write_message(&mut self, message: Value) -> Result<(), McpStdioSmokeError> {
279 let stdin = self
280 .stdin
281 .as_mut()
282 .ok_or(McpStdioSmokeError::MissingPipe("stdin"))?;
283 serde_json::to_writer(&mut *stdin, &message).map_err(|source| {
284 McpStdioSmokeError::Write {
285 source: std::io::Error::other(source),
286 }
287 })?;
288 stdin
289 .write_all(b"\n")
290 .map_err(|source| McpStdioSmokeError::Write { source })?;
291 stdin
292 .flush()
293 .map_err(|source| McpStdioSmokeError::Flush { source })
294 }
295
296 fn read_response(&mut self, method: &str, id: u64) -> Result<Value, McpStdioSmokeError> {
297 let method = method.to_string();
298 loop {
299 let mut line = String::new();
300 let read = self
301 .stdout
302 .read_line(&mut line)
303 .map_err(|source| McpStdioSmokeError::Read { source })?;
304 if read == 0 {
305 let status = match self.child.try_wait() {
306 Ok(Some(status)) => ProcessStatus::exited(status),
307 Ok(None) | Err(_) => ProcessStatus::running(),
308 };
309 return Err(McpStdioSmokeError::Eof {
310 method,
311 status,
312 stderr: self.stderr(),
313 });
314 }
315
316 let response = serde_json::from_str::<Value>(&line).map_err(|source| {
317 McpStdioSmokeError::InvalidJson {
318 method: method.clone(),
319 line: line.trim_end().to_string(),
320 source,
321 }
322 })?;
323
324 if response.get("id").and_then(Value::as_u64) != Some(id) {
325 if response.get("id").is_none() {
326 continue;
327 }
328 return Err(McpStdioSmokeError::UnexpectedResponse {
329 method,
330 id,
331 response,
332 });
333 }
334
335 if let Some(error) = response.get("error") {
336 return Err(McpStdioSmokeError::Rpc {
337 method,
338 error: error.clone(),
339 });
340 }
341
342 return response
343 .get("result")
344 .cloned()
345 .ok_or(McpStdioSmokeError::MissingResult { method, response });
346 }
347 }
348}
349
350impl Drop for McpStdioSmokeClient {
351 fn drop(&mut self) {
352 let _ = self.shutdown(DEFAULT_SHUTDOWN_TIMEOUT);
353 }
354}
355
356pub fn tool_call_structured_content(result: &Value) -> Option<&Value> {
358 result
359 .get("structuredContent")
360 .or_else(|| result.get("structured_content"))
361}
362
363fn capture_stderr(stderr: impl std::io::Read + Send + 'static) -> Arc<Mutex<String>> {
364 let output = Arc::new(Mutex::new(String::new()));
365 let output_for_thread = Arc::clone(&output);
366 thread::spawn(move || {
367 let mut stderr = BufReader::new(stderr);
368 let mut captured = String::new();
369 if stderr.read_to_string(&mut captured).is_ok()
370 && let Ok(mut output) = output_for_thread.lock()
371 {
372 *output = captured;
373 }
374 });
375 output
376}
377
378impl Default for StderrSnapshot {
379 fn default() -> Self {
380 Self::empty()
381 }
382}
383
384#[cfg(all(test, unix))]
385mod tests {
386 use std::{process::Command, time::Duration};
387
388 use serde_json::json;
389
390 use super::{
391 McpStdioSmokeClient, McpStdioSmokeError, ProcessStatus, StderrSnapshot,
392 tool_call_structured_content,
393 };
394
395 fn spawn_shell(script: &str) -> McpStdioSmokeClient {
396 McpStdioSmokeClient::spawn(Command::new("sh").arg("-c").arg(script))
397 .expect("shell smoke server should spawn")
398 }
399
400 #[test]
401 fn stdio_client_exercises_the_public_protocol_helpers() {
402 let mut client = spawn_shell(
403 r#"
404read discover
405printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{},"ttlMs":0,"cacheScope":"private","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"example","version":"0.0.0"}}}}'
406read tools
407printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","tools":[],"ttlMs":0,"cacheScope":"private"}}'
408read resources
409printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","resources":[],"ttlMs":0,"cacheScope":"private"}}'
410read templates
411printf '%s\n' '{"jsonrpc":"2.0","id":4,"result":{"resultType":"complete","resourceTemplates":[],"ttlMs":0,"cacheScope":"private"}}'
412read resource
413printf '%s\n' '{"jsonrpc":"2.0","id":5,"result":{"resultType":"complete","contents":[],"ttlMs":0,"cacheScope":"private"}}'
414read tool
415printf '%s\n' '{"jsonrpc":"2.0","id":6,"result":{"resultType":"complete","structuredContent":{"ok":true}}}'
416"#,
417 );
418
419 assert_eq!(
420 client.discover().expect("discovery should succeed"),
421 json!({
422 "resultType": "complete",
423 "supportedVersions": ["2026-07-28"],
424 "capabilities": {},
425 "ttlMs": 0,
426 "cacheScope": "private",
427 "_meta": {
428 "io.modelcontextprotocol/serverInfo": {
429 "name": "example",
430 "version": "0.0.0"
431 }
432 }
433 })
434 );
435 assert_eq!(
436 client.list_tools().expect("tools/list should succeed"),
437 json!({
438 "resultType": "complete",
439 "tools": [],
440 "ttlMs": 0,
441 "cacheScope": "private"
442 })
443 );
444 assert_eq!(
445 client
446 .list_resources()
447 .expect("resources/list should succeed"),
448 json!({
449 "resultType": "complete",
450 "resources": [],
451 "ttlMs": 0,
452 "cacheScope": "private"
453 })
454 );
455 assert_eq!(
456 client
457 .list_resource_templates()
458 .expect("resources/templates/list should succeed"),
459 json!({
460 "resultType": "complete",
461 "resourceTemplates": [],
462 "ttlMs": 0,
463 "cacheScope": "private"
464 })
465 );
466 assert_eq!(
467 client
468 .read_resource("shape://example")
469 .expect("resources/read should succeed"),
470 json!({
471 "resultType": "complete",
472 "contents": [],
473 "ttlMs": 0,
474 "cacheScope": "private"
475 })
476 );
477 let result = client
478 .call_tool("shape_example", json!({ "value": 1 }))
479 .expect("tools/call should succeed");
480 assert_eq!(
481 tool_call_structured_content(&result),
482 Some(&json!({ "ok": true }))
483 );
484 assert!(
485 client
486 .shutdown(Duration::from_secs(1))
487 .expect("server should shut down")
488 .is_some()
489 );
490 }
491
492 #[test]
493 fn response_reader_skips_notifications_and_reports_protocol_errors() {
494 let cases = [
495 (
496 "read request; printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"method\":\"notice\"}' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":7}'",
497 None,
498 ),
499 (
500 "read request; printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":9,\"result\":7}'",
501 Some("expected id `1`"),
502 ),
503 (
504 "read request; printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-1}}'",
505 Some("JSON-RPC error"),
506 ),
507 (
508 "read request; printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1}'",
509 Some("did not contain `result`"),
510 ),
511 (
512 "read request; printf '%s\\n' 'not-json'",
513 Some("invalid JSON"),
514 ),
515 ];
516
517 for (script, expected_error) in cases {
518 let mut client = spawn_shell(script);
519 let result = client.request("example", json!({}));
520 match expected_error {
521 Some(expected_error) => assert!(
522 result
523 .expect_err("response should fail")
524 .to_string()
525 .contains(expected_error),
526 "expected error containing `{expected_error}`"
527 ),
528 None => assert_eq!(result.expect("response should succeed"), json!(7)),
529 }
530 }
531 }
532
533 #[test]
534 fn client_reports_spawn_eof_and_closed_stdin_failures() {
535 let spawn_error =
536 match McpStdioSmokeClient::spawn(&mut Command::new("/definitely/not/a/program")) {
537 Ok(_) => panic!("invalid executable should fail"),
538 Err(error) => error,
539 };
540 assert!(matches!(spawn_error, McpStdioSmokeError::Spawn { .. }));
541
542 let mut exited = spawn_shell("read request; printf 'server detail\\n' >&2");
543 let eof = exited
544 .request("exited", json!({}))
545 .expect_err("closed stdout should fail");
546 assert!(matches!(eof, McpStdioSmokeError::Eof { .. }));
547 assert!(eof.to_string().contains("closed stdout"));
548
549 let mut closed = spawn_shell("read ignored");
550 closed
551 .shutdown(Duration::from_secs(1))
552 .expect("server should shut down when stdin closes");
553 assert!(matches!(
554 closed.request("example", json!({})),
555 Err(McpStdioSmokeError::MissingPipe("stdin"))
556 ));
557 }
558
559 #[test]
560 fn shutdown_kills_a_server_that_does_not_exit_after_stdin_closes() {
561 let mut client = spawn_shell("while :; do :; done");
562 let status = client
563 .shutdown(Duration::ZERO)
564 .expect("timed-out server should be killed")
565 .expect("killed server should return a status");
566
567 assert!(!status.success());
568 }
569
570 #[test]
571 fn diagnostics_format_status_stderr_and_structured_content() {
572 assert_eq!(ProcessStatus::running().to_string(), "");
573 assert_eq!(StderrSnapshot::default().to_string(), "");
574
575 let stderr = StderrSnapshot(" detail \n".to_string());
576 assert_eq!(stderr.as_str(), " detail \n");
577 assert_eq!(stderr.to_string(), "\nstderr:\n detail");
578
579 assert_eq!(
580 tool_call_structured_content(&json!({ "structured_content": 3 })),
581 Some(&json!(3))
582 );
583 assert_eq!(tool_call_structured_content(&json!({})), None);
584 }
585}