1 //! Test-only MCP stdio server stub, used by `tests/mcp_stdio.rs`.
2 //!
3 //! Speaks the MCP stdio transport: newline-delimited JSON-RPC 2.0, one message
4 //! per line on stdin/stdout. It answers `initialize`, `tools/list` (a single
5 //! `echo` tool) and `tools/call` (echoes `text` back, prefixed with the
6 //! `STUB_PREFIX` env var so tests can verify env propagation).
7 //!
8 //! Flags that script failure modes:
9 //! - `--fail`: exit(1) immediately, before reading anything (a server whose
10 //! process dies at spawn).
11 //! - `--exit-after-list`: exit(0) right after answering `tools/list` (a server
12 //! that dies after discovery, exercising the dead-child call path).
13 //!
14 //! After the `initialize` response it also emits a server-initiated
15 //! notification the client must log and ignore.
16 //!
17 //! This binary is excluded from the published crate (see `exclude` in
18 //! `Cargo.toml`); it exists only for the integration tests.
19
20 use std::io::{BufRead, Write};
21
22 use serde_json::{Value, json};
23
24 fn main() {
25 let args: Vec<String> = std::env::args().skip(1).collect();
26 if args.iter().any(|a| a == "--fail") {
27 std::process::exit(1);
28 }
29 let exit_after_list = args.iter().any(|a| a == "--exit-after-list");
30
31 let stdin = std::io::stdin();
32 let stdout = std::io::stdout();
33
34 for line in stdin.lock().lines() {
35 let Ok(line) = line else { break };
36 if line.trim().is_empty() {
37 continue;
38 }
39 let Ok(message) = serde_json::from_str::<Value>(&line) else {
40 continue;
41 };
42 let id = message.get("id").cloned();
43 let method = message
44 .get("method")
45 .and_then(Value::as_str)
46 .unwrap_or_default()
47 .to_string();
48
49 let result = match method.as_str() {
50 "initialize" => Some(json!({
51 "protocolVersion": "2025-06-18",
52 "capabilities": { "tools": {} },
53 "serverInfo": { "name": "mcp-stdio-stub", "version": "0.0.0" }
54 })),
55 "tools/list" => Some(json!({
56 "tools": [{
57 "name": "echo",
58 "description": "Echo the text back.",
59 "inputSchema": {
60 "type": "object",
61 "properties": { "text": { "type": "string" } },
62 "required": ["text"]
63 }
64 }]
65 })),
66 "tools/call" => {
67 let text = message
68 .pointer("/params/arguments/text")
69 .and_then(Value::as_str)
70 .unwrap_or_default();
71 let prefix = std::env::var("STUB_PREFIX").unwrap_or_default();
72 Some(json!({
73 "content": [{ "type": "text", "text": format!("{prefix}{text}") }]
74 }))
75 }
76 // Notifications (`notifications/initialized`) and anything else
77 // without a scripted answer fall through.
78 _ => None,
79 };
80
81 let mut out = stdout.lock();
82 match (id, result) {
83 (Some(id), Some(result)) => {
84 let response = json!({ "jsonrpc": "2.0", "id": id, "result": result });
85 writeln!(out, "{response}").ok();
86 }
87 (Some(id), None) => {
88 let response = json!({
89 "jsonrpc": "2.0",
90 "id": id,
91 "error": { "code": -32601, "message": "method not found" }
92 });
93 writeln!(out, "{response}").ok();
94 }
95 // A notification: nothing to answer.
96 (None, _) => {}
97 }
98 if method == "initialize" {
99 // Server-initiated traffic the client must ignore.
100 let notification = json!({ "jsonrpc": "2.0", "method": "notifications/stub" });
101 writeln!(out, "{notification}").ok();
102 }
103 out.flush().ok();
104
105 if exit_after_list && method == "tools/list" {
106 std::process::exit(0);
107 }
108 }
109 }