@setoelkahfi / sigit / commits / e9a445c

Add stdio transport to the MCP client

Nearly every published MCP server is stdio-first, and sigit spoke only Streamable HTTP, so most of the ecosystem could not plug in. mcp.toml server entries now take command, args, and an env map, mutually exclusive with url; both or neither is a config error that is logged and skipped. The transport is an enum behind the existing server cache: HTTP behaves exactly as before, and a stdio server is spawned at discovery with piped stdin and stdout, stderr inherited into the log, and the same handshake and timeouts as HTTP over newline-delimited JSON-RPC. A background reader routes responses to waiters by request id, server-initiated traffic is logged and ignored, and a dead child fails its in-flight and later calls with a clear in-band error instead of restarting. /mcp lists the command line for stdio servers; namespacing and output caps are unchanged. The integration tests are hermetic: a tiny stub binary speaks the handshake, so CI needs no npm or network. The stub and its test are excluded from the published crate (verified with cargo package --list), since cargo install would otherwise ship every bin in the crate. Config changes still need a restart; /reload does not re-run discovery, now documented.

paydii committed Jul 5, 2026 at 09:17 UTC e9a445c3d39c723ff2b57ca3768f7672b3b241b5
5 files changed +1129 -104
CLAUDE.md
+18 -10
@@ -103,16 +103,24 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
103 calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
104 `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
105 and only when at least one skill exists.
106 -- **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Connects to
107 - MCP servers over the **Streamable HTTP** transport (one JSON-RPC POST endpoint; replies are
108 - `application/json` or SSE), runs the `initialize`/`tools/list` handshake, and forwards `tools/call`.
109 - Discovery is best-effort at startup (`mcp::init`, called from both branches of `main()`) and cached
110 - in a process-global so the synchronous spec builders (`mcp::tool_specs`) and the async dispatch
111 - (`mcp::call_tool`) can both read it. Tools are namespaced `mcp__<server>__<tool>`, appended in the
112 - `*_as_specs`/`build_tool_specs` layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. The
113 - official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in and authed with the
114 - cloud session token; extra servers live in `mcp.toml` (global `$SIGIT_CONFIG_DIR/mcp.toml` and
115 - project-local `.sigit/mcp.toml`). stdio transport is not supported.
106 +- **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
107 + transports: **Streamable HTTP** (one JSON-RPC POST endpoint, `url` in `mcp.toml`; replies are
108 + `application/json` or SSE) and **stdio** (`command` + optional `args`/`[server.env]` in
109 + `mcp.toml`; sigit spawns the server and speaks newline-delimited JSON-RPC over its
110 + stdin/stdout, stderr inherited into sigit's log). `url` and `command` are mutually exclusive —
111 + both or neither is a config error, logged and skipped. Both transports run the same
112 + `initialize`/`tools/list` handshake and forward `tools/call`. Discovery is best-effort at
113 + startup (`mcp::init`, called from both branches of `main()`) and cached in a process-global so
114 + the synchronous spec builders (`mcp::tool_specs`) and the async dispatch (`mcp::call_tool`) can
115 + both read it; `/reload` does *not* re-run it, so config changes need a restart. stdio children
116 + live for the process; a dead child fails calls with an in-band error string (no auto-restart).
117 + Tools are namespaced `mcp__<server>__<tool>`, appended in the `*_as_specs`/`build_tool_specs`
118 + layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. The official server
119 + (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in (always HTTP) and authed
120 + with the cloud session token; extra servers live in `mcp.toml` (global
121 + `$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). The stdio path is covered by
122 + `tests/mcp_stdio.rs`, driven by the test-only `src/bin/mcp_stdio_stub.rs` helper binary
123 + (excluded from the published crate via `exclude` in `Cargo.toml`).
124 - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
125 `decision_for` before executing: read-only tools always run; mutating tools (and all
126 `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
Cargo.toml
+6 -1
@@ -11,6 +11,11 @@ readme = "README.md"
11 keywords = ["sigit", "cli", "ai", "coding-agent", "llm"]
12 categories = ["command-line-utilities"]
13 authors = ["Seto Elkahfi <seto@ondeinference.com>"]
14 +# `src/bin/` holds test-only helper binaries (the MCP stdio stub used by the
15 +# integration tests). Excluding it keeps them out of the published crate, so
16 +# `cargo install sigit` installs exactly one binary. The integration test that
17 +# spawns the stub goes with it, or the packaged crate's tests wouldn't compile.
18 +exclude = ["src/bin/", "tests/mcp_stdio.rs"]
19
20 [[bin]]
21 name = "sigit"
@@ -25,7 +30,7 @@ agent-client-protocol = { version = "1.0", features = ["unstable_session_fork",
30 onde = "1.1.2"
31
32 # Async runtime
28 -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] }
33 +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time", "process"] }
34 tokio-util = { version = "0.7", features = ["compat"] }
35 futures = "0.3"
36
src/bin/mcp_stdio_stub.rs new
+109
@@ -0,0 +1,109 @@
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 +}
src/mcp.rs
+625 -93
@@ -6,11 +6,20 @@
6 //! When the model calls an MCP tool, the call is forwarded to the owning server
7 //! and the result fed back into the agent loop.
8 //!
9 -//! Transport: the modern **Streamable HTTP** transport — a single HTTP endpoint
10 -//! the client POSTs JSON-RPC 2.0 messages to. The server answers either with a
11 -//! single `application/json` body or a `text/event-stream` (SSE) stream that
12 -//! carries the JSON-RPC response. Both are handled here. stdio transport is not
13 -//! supported (siGit Code never spawns child processes for inference).
9 +//! Transports:
10 +//!
11 +//! - **Streamable HTTP** — a single HTTP endpoint the client POSTs JSON-RPC 2.0
12 +//! messages to. The server answers either with a single `application/json`
13 +//! body or a `text/event-stream` (SSE) stream that carries the JSON-RPC
14 +//! response. Both are handled here. Configured with `url` in `mcp.toml`.
15 +//! - **stdio** — siGit spawns the server as a child process and exchanges
16 +//! newline-delimited JSON-RPC messages over its stdin/stdout (the server's
17 +//! stderr flows into siGit's own log stream). Configured with `command`
18 +//! (plus optional `args` and `[server.env]`) in `mcp.toml`. This is how most
19 +//! published MCP servers (filesystem, Playwright, GitHub, ...) are run.
20 +//!
21 +//! `url` and `command` are mutually exclusive; an entry with both, or neither,
22 +//! is a config error that is logged and skipped.
23 //!
24 //! ## The official server
25 //!
@@ -30,6 +39,13 @@
39 //! stored in a process-global so the synchronous tool-spec builders
40 //! ([`tool_specs`]) and the async dispatch ([`call_tool`]) can both read it.
41 //!
42 +//! stdio children live for the sigit process. When a child dies (EOF or an I/O
43 +//! error on its pipes) the server is marked dead and later calls return an
44 +//! in-band error string the model can react to; there is no automatic restart.
45 +//! `/reload` does *not* re-run discovery ([`init`] is once-per-process), so a
46 +//! changed `mcp.toml` or a dead server needs a sigit restart. At process exit
47 +//! children see EOF on their stdin and exit on their own.
48 +//!
49 //! Tools are namespaced `mcp__<server>__<tool>` so they never collide with
50 //! built-in tools or with each other across servers. This mirrors the
51 //! convention used by other MCP-aware agents.
@@ -39,15 +55,18 @@
55 //! are unused, so the dead-code lint is suppressed there only.
56 #![cfg_attr(not(unix), allow(dead_code))]
57
42 -use std::collections::BTreeMap;
58 +use std::collections::{BTreeMap, HashMap};
59 use std::path::PathBuf;
44 -use std::sync::OnceLock;
60 +use std::sync::Mutex as StdMutex;
61 use std::sync::atomic::{AtomicI64, Ordering};
62 +use std::sync::{Arc, OnceLock};
63 use std::time::Duration;
64
65 use serde::Deserialize;
66 use serde_json::{Value, json};
50 -use tokio::sync::Mutex;
67 +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
68 +use tokio::process::{Child, ChildStdin, ChildStdout, Command};
69 +use tokio::sync::{Mutex, oneshot};
70
71 use crate::backend::ToolSpec;
72
@@ -91,6 +110,25 @@ struct McpTool {
110 struct ServerConn {
111 /// Sanitized server name used in tool namespacing and the `/mcp` listing.
112 name: String,
113 + /// Display endpoint for the `/mcp` listing: the URL for HTTP servers, the
114 + /// command line for stdio servers.
115 + endpoint: String,
116 + /// The live transport. `None` when a stdio server failed to even spawn.
117 + transport: Option<Transport>,
118 + /// Tools discovered at startup. Empty when the server failed to connect.
119 + tools: Vec<McpTool>,
120 + /// Connection error, if the handshake failed. Surfaced by `/mcp`.
121 + error: Option<String>,
122 +}
123 +
124 +/// How a connected server is reached.
125 +enum Transport {
126 + Http(HttpConn),
127 + Stdio(StdioConn),
128 +}
129 +
130 +/// Streamable HTTP connection state.
131 +struct HttpConn {
132 /// Streamable HTTP endpoint (the single POST URL).
133 url: String,
134 /// Extra headers sent on every request (e.g. `Authorization`).
@@ -98,10 +136,59 @@ struct ServerConn {
136 /// Session id handed back by the server on `initialize`, echoed on every
137 /// later request via the `Mcp-Session-Id` header.
138 session_id: Mutex<Option<String>>,
101 - /// Tools discovered at startup. Empty when the server failed to connect.
102 - tools: Vec<McpTool>,
103 - /// Connection error, if the handshake failed. Surfaced by `/mcp`.
104 - error: Option<String>,
139 +}
140 +
141 +/// stdio connection state: a child process speaking newline-delimited JSON-RPC
142 +/// over its stdin/stdout.
143 +struct StdioConn {
144 + /// The child's stdin. The mutex serializes writes so concurrent requests
145 + /// can't interleave bytes on the pipe; `None` once the pipe broke.
146 + writer: Mutex<Option<ChildStdin>>,
147 + /// State shared with the background reader task that owns the child's
148 + /// stdout.
149 + shared: Arc<StdioShared>,
150 + /// JSON-RPC id source. Ids are per-connection so the reader task can route
151 + /// each response to the request that carries its id.
152 + next_id: AtomicI64,
153 +}
154 +
155 +/// State shared between a [`StdioConn`] and its background reader task.
156 +struct StdioShared {
157 + /// Server name, for log lines.
158 + name: String,
159 + /// In-flight requests awaiting a response, keyed by JSON-RPC id. Dropping
160 + /// a sender (when the connection dies) wakes the waiter with an error.
161 + pending: StdMutex<HashMap<i64, oneshot::Sender<Value>>>,
162 + /// Why the connection is unusable, once it is (EOF, I/O error, kill).
163 + dead: StdMutex<Option<String>>,
164 + /// The child handle, kept so a dead/failed connection can kill and reap
165 + /// the process. Taken on death.
166 + child: StdMutex<Option<Child>>,
167 +}
168 +
169 +impl StdioShared {
170 + fn dead_reason(&self) -> Option<String> {
171 + self.dead.lock().unwrap().clone()
172 + }
173 +
174 + /// Mark the connection unusable: record the reason (first one wins), fail
175 + /// every in-flight request, and kill + reap the child, best effort.
176 + fn mark_dead(&self, reason: &str) {
177 + {
178 + let mut dead = self.dead.lock().unwrap();
179 + if dead.is_none() {
180 + *dead = Some(reason.to_string());
181 + }
182 + }
183 + // Dropping the senders wakes every waiter with a recv error.
184 + self.pending.lock().unwrap().clear();
185 + if let Some(mut child) = self.child.lock().unwrap().take() {
186 + let _ = child.start_kill();
187 + tokio::spawn(async move {
188 + let _ = child.wait().await;
189 + });
190 + }
191 + }
192 }
193
194 /// The process-global MCP state: a shared HTTP client plus every configured
@@ -125,19 +212,70 @@ fn official_url() -> String {
212 )
213 }
214
128 -/// A server entry as written in `mcp.toml`.
215 +/// A server entry as written in `mcp.toml`. Exactly one of `url` (Streamable
216 +/// HTTP) or `command` (stdio) selects the transport.
217 #[derive(Debug, Deserialize)]
218 struct ServerEntry {
219 name: String,
132 - url: String,
220 + /// Streamable HTTP endpoint. Mutually exclusive with `command`.
221 + #[serde(default)]
222 + url: Option<String>,
223 + /// stdio server executable. Mutually exclusive with `url`.
224 + #[serde(default)]
225 + command: Option<String>,
226 + /// Arguments for `command`.
227 + #[serde(default)]
228 + args: Vec<String>,
229 + /// Extra environment variables for `command`, added on top of the
230 + /// inherited environment.
231 + #[serde(default)]
232 + env: BTreeMap<String, String>,
233 /// Set `enabled = false` to keep an entry in the file but skip connecting.
234 #[serde(default)]
235 enabled: Option<bool>,
136 - /// Static headers, e.g. `Authorization = "Bearer ..."`.
236 + /// Static headers, e.g. `Authorization = "Bearer ..."`. HTTP only.
237 #[serde(default)]
238 headers: BTreeMap<String, String>,
239 }
240
241 +impl ServerEntry {
242 + /// Resolve the entry's transport. `url` and `command` are mutually
243 + /// exclusive and exactly one is required; anything else is a config error.
244 + fn transport_def(&self) -> Result<TransportDef, String> {
245 + let url = self.url.as_deref().map(str::trim).filter(|v| !v.is_empty());
246 + let command = self
247 + .command
248 + .as_deref()
249 + .map(str::trim)
250 + .filter(|v| !v.is_empty());
251 + match (url, command) {
252 + (Some(_), Some(_)) => {
253 + Err("has both `url` and `command`; a server uses exactly one transport".to_string())
254 + }
255 + (None, None) => {
256 + Err("needs either `url` (Streamable HTTP) or `command` (stdio)".to_string())
257 + }
258 + (Some(url), None) => Ok(TransportDef::Http {
259 + url: url.to_string(),
260 + headers: self
261 + .headers
262 + .iter()
263 + .map(|(k, v)| (k.clone(), v.clone()))
264 + .collect(),
265 + }),
266 + (None, Some(command)) => Ok(TransportDef::Stdio {
267 + command: command.to_string(),
268 + args: self.args.clone(),
269 + env: self
270 + .env
271 + .iter()
272 + .map(|(k, v)| (k.clone(), v.clone()))
273 + .collect(),
274 + }),
275 + }
276 + }
277 +}
278 +
279 /// The `mcp.toml` schema.
280 #[derive(Debug, Default, Deserialize)]
281 struct McpFile {
@@ -149,12 +287,43 @@ struct McpFile {
287 server: Vec<ServerEntry>,
288 }
289
290 +/// How to reach a configured server, before connecting.
291 +#[derive(Debug, Clone)]
292 +enum TransportDef {
293 + Http {
294 + url: String,
295 + headers: Vec<(String, String)>,
296 + },
297 + Stdio {
298 + command: String,
299 + args: Vec<String>,
300 + env: Vec<(String, String)>,
301 + },
302 +}
303 +
304 +impl TransportDef {
305 + /// Human-readable endpoint for logs and the `/mcp` listing: the URL for
306 + /// HTTP, the command line for stdio.
307 + fn endpoint(&self) -> String {
308 + match self {
309 + TransportDef::Http { url, .. } => url.clone(),
310 + TransportDef::Stdio { command, args, .. } => {
311 + let mut line = command.clone();
312 + for arg in args {
313 + line.push(' ');
314 + line.push_str(arg);
315 + }
316 + line
317 + }
318 + }
319 + }
320 +}
321 +
322 /// A resolved server definition, before connecting.
323 #[derive(Debug, Clone)]
324 struct ServerDef {
325 name: String,
156 - url: String,
157 - headers: Vec<(String, String)>,
326 + transport: TransportDef,
327 }
328
329 /// Config files to read, in priority order (later wins on a name clash):
@@ -220,22 +389,21 @@ fn load_configs() -> Vec<ServerDef> {
389 continue;
390 }
391 let name = sanitize(&entry.name);
223 - if name.is_empty() || entry.url.trim().is_empty() {
224 - log::warn!(
225 - "mcp: skipping server with empty name/url in {}",
226 - path.display()
227 - );
392 + if name.is_empty() {
393 + log::warn!("mcp: skipping server with empty name in {}", path.display());
394 continue;
395 }
230 - let headers = entry.headers.into_iter().collect();
231 - upsert(
232 - &mut defs,
233 - ServerDef {
234 - name,
235 - url: entry.url.trim().to_string(),
236 - headers,
237 - },
238 - );
396 + let transport = match entry.transport_def() {
397 + Ok(transport) => transport,
398 + Err(error) => {
399 + log::warn!(
400 + "mcp: skipping server '{name}' in {}: {error}",
401 + path.display()
402 + );
403 + continue;
404 + }
405 + };
406 + upsert(&mut defs, ServerDef { name, transport });
407 }
408 }
409
@@ -258,8 +426,10 @@ fn load_configs() -> Vec<ServerDef> {
426 }
427 defs.push(ServerDef {
428 name: "sigit".to_string(),
261 - url: official_url(),
262 - headers,
429 + transport: TransportDef::Http {
430 + url: official_url(),
431 + headers,
432 + },
433 });
434 }
435
@@ -341,11 +511,33 @@ pub async fn init() {
511 /// Run the handshake against one server and collect its tools. Always returns a
512 /// `ServerConn`; failures land in its `error` field rather than propagating.
513 async fn connect(http: &reqwest::Client, def: ServerDef) -> ServerConn {
514 + let endpoint = def.transport.endpoint();
515 + let transport = match &def.transport {
516 + TransportDef::Http { url, headers } => Transport::Http(HttpConn {
517 + url: url.clone(),
518 + headers: headers.clone(),
519 + session_id: Mutex::new(None),
520 + }),
521 + TransportDef::Stdio { command, args, env } => {
522 + match spawn_stdio(&def.name, command, args, env) {
523 + Ok(conn) => Transport::Stdio(conn),
524 + Err(error) => {
525 + return ServerConn {
526 + name: def.name,
527 + endpoint,
528 + transport: None,
529 + tools: Vec::new(),
530 + error: Some(error),
531 + };
532 + }
533 + }
534 + }
535 + };
536 +
537 let mut conn = ServerConn {
345 - name: def.name.clone(),
346 - url: def.url.clone(),
347 - headers: def.headers.clone(),
348 - session_id: Mutex::new(None),
538 + name: def.name,
539 + endpoint,
540 + transport: Some(transport),
541 tools: Vec::new(),
542 error: None,
543 };
@@ -364,31 +556,33 @@ async fn connect(http: &reqwest::Client, def: ServerDef) -> ServerConn {
556 Err(_) => conn.error = Some(format!("timed out after {}s", HANDSHAKE_TIMEOUT.as_secs())),
557 }
558
559 + // A stdio child that failed its handshake is useless — kill it rather than
560 + // leave it running for the rest of the process.
561 + if let Some(error) = conn.error.clone()
562 + && let Some(Transport::Stdio(stdio)) = &conn.transport
563 + {
564 + stdio.shared.mark_dead(&error);
565 + }
566 +
567 conn
568 }
569
370 -/// The `initialize` request: negotiate protocol version and capture the session
371 -/// id from the response headers (handled inside [`post_rpc`]).
570 +/// The `initialize` request: negotiate protocol version and (on HTTP) capture
571 +/// the session id from the response headers (handled inside [`post_rpc`]).
572 async fn initialize(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
373 - let body = json!({
374 - "jsonrpc": "2.0",
375 - "id": 0,
376 - "method": "initialize",
377 - "params": {
378 - "protocolVersion": PROTOCOL_VERSION,
379 - "capabilities": {},
380 - "clientInfo": { "name": "sigit", "version": env!("CARGO_PKG_VERSION") }
381 - }
573 + let params = json!({
574 + "protocolVersion": PROTOCOL_VERSION,
575 + "capabilities": {},
576 + "clientInfo": { "name": "sigit", "version": env!("CARGO_PKG_VERSION") }
577 });
383 - post_rpc(http, conn, &body, HANDSHAKE_TIMEOUT).await?;
578 + rpc_request(http, conn, "initialize", params, HANDSHAKE_TIMEOUT).await?;
579 Ok(())
580 }
581
582 /// The `notifications/initialized` notification. Servers expect it before
388 -/// fielding requests; it carries no id and yields a 202 with no body.
583 +/// fielding requests; it carries no id and no response.
584 async fn notify_initialized(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
390 - let body = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
391 - post_notification(http, conn, &body, HANDSHAKE_TIMEOUT).await
585 + rpc_notify(http, conn, "notifications/initialized", HANDSHAKE_TIMEOUT).await
586 }
587
588 /// `tools/list`, following `nextCursor` pagination, mapped into [`McpTool`]s.
@@ -401,8 +595,7 @@ async fn list_tools(http: &reqwest::Client, conn: &ServerConn) -> Result<Vec<Mcp
595 Some(c) => json!({ "cursor": c }),
596 None => json!({}),
597 };
404 - let body = json!({ "jsonrpc": "2.0", "id": 0, "method": "tools/list", "params": params });
405 - let result = post_rpc(http, conn, &body, HANDSHAKE_TIMEOUT).await?;
598 + let result = rpc_request(http, conn, "tools/list", params, HANDSHAKE_TIMEOUT).await?;
599
600 for tool in result
601 .get("tools")
@@ -521,32 +714,40 @@ pub async fn call_tool(full_name: &str, arguments: &str) -> String {
714 }
715
716 impl Mcp {
524 - /// Send a `tools/call` and render the result into text. Retries once after a
525 - /// re-`initialize` if the session was dropped (HTTP 404), which is how
526 - /// Streamable HTTP signals an expired session.
717 + /// Send a `tools/call` and render the result into text. On HTTP, retries
718 + /// once after a re-`initialize` if the session was dropped (HTTP 404),
719 + /// which is how Streamable HTTP signals an expired session.
720 async fn call(
721 &self,
722 server: &ServerConn,
723 remote_name: &str,
724 args: Value,
725 ) -> Result<String, String> {
533 - let body = json!({
534 - "jsonrpc": "2.0",
535 - "id": 0,
536 - "method": "tools/call",
537 - "params": { "name": remote_name, "arguments": args }
538 - });
539 -
540 - let result = match post_rpc(&self.http, server, &body, CALL_TIMEOUT).await {
541 - Ok(result) => result,
542 - Err(error) if error.contains("returned 404") => {
543 - // Session expired — drop it, re-handshake, and retry once.
544 - *server.session_id.lock().await = None;
545 - initialize(&self.http, server).await?;
546 - notify_initialized(&self.http, server).await?;
547 - post_rpc(&self.http, server, &body, CALL_TIMEOUT).await?
726 + let params = json!({ "name": remote_name, "arguments": args });
727 + let result = match &server.transport {
728 + None => return Err(format!("server '{}' is not connected", server.name)),
729 + Some(Transport::Stdio(stdio)) => {
730 + stdio.request("tools/call", params, CALL_TIMEOUT).await?
731 + }
732 + Some(Transport::Http(http_conn)) => {
733 + let body = json!({
734 + "jsonrpc": "2.0",
735 + "id": 0,
736 + "method": "tools/call",
737 + "params": params
738 + });
739 + match post_rpc(&self.http, &server.name, http_conn, &body, CALL_TIMEOUT).await {
740 + Ok(result) => result,
741 + Err(error) if error.contains("returned 404") => {
742 + // Session expired — drop it, re-handshake, and retry once.
743 + *http_conn.session_id.lock().await = None;
744 + initialize(&self.http, server).await?;
745 + notify_initialized(&self.http, server).await?;
746 + post_rpc(&self.http, &server.name, http_conn, &body, CALL_TIMEOUT).await?
747 + }
748 + Err(error) => return Err(error),
749 + }
750 }
549 - Err(error) => return Err(error),
751 };
752
753 Ok(render_tool_result(&result))
@@ -611,6 +812,242 @@ fn truncate(text: String) -> String {
812 format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]")
813 }
814
815 +// ── Transport-generic JSON-RPC dispatch ─────────────────────────────────────
816 +
817 +/// Send a JSON-RPC request over whichever transport the server uses and return
818 +/// its `result`.
819 +async fn rpc_request(
820 + http: &reqwest::Client,
821 + conn: &ServerConn,
822 + method: &str,
823 + params: Value,
824 + timeout: Duration,
825 +) -> Result<Value, String> {
826 + match &conn.transport {
827 + None => Err(format!("server '{}' is not connected", conn.name)),
828 + Some(Transport::Http(http_conn)) => {
829 + let body = json!({ "jsonrpc": "2.0", "id": 0, "method": method, "params": params });
830 + post_rpc(http, &conn.name, http_conn, &body, timeout).await
831 + }
832 + Some(Transport::Stdio(stdio)) => stdio.request(method, params, timeout).await,
833 + }
834 +}
835 +
836 +/// Send a JSON-RPC notification (no id, no response expected).
837 +async fn rpc_notify(
838 + http: &reqwest::Client,
839 + conn: &ServerConn,
840 + method: &str,
841 + timeout: Duration,
842 +) -> Result<(), String> {
843 + match &conn.transport {
844 + None => Err(format!("server '{}' is not connected", conn.name)),
845 + Some(Transport::Http(http_conn)) => {
846 + let body = json!({ "jsonrpc": "2.0", "method": method });
847 + post_notification(http, &conn.name, http_conn, &body, timeout).await
848 + }
849 + Some(Transport::Stdio(stdio)) => stdio.notify(method).await,
850 + }
851 +}
852 +
853 +// ── stdio JSON-RPC plumbing ─────────────────────────────────────────────────
854 +
855 +/// Spawn a stdio MCP server and start its background reader task. The child's
856 +/// stderr is inherited so it lands in sigit's own log stream; the given env
857 +/// vars are added on top of the inherited environment.
858 +fn spawn_stdio(
859 + name: &str,
860 + command: &str,
861 + args: &[String],
862 + env: &[(String, String)],
863 +) -> Result<StdioConn, String> {
864 + let mut cmd = Command::new(command);
865 + cmd.args(args)
866 + .stdin(std::process::Stdio::piped())
867 + .stdout(std::process::Stdio::piped())
868 + .stderr(std::process::Stdio::inherit())
869 + .kill_on_drop(true);
870 + for (key, value) in env {
871 + cmd.env(key, value);
872 + }
873 + let mut child = cmd
874 + .spawn()
875 + .map_err(|error| format!("failed to spawn `{command}`: {error}"))?;
876 + let stdin = child
877 + .stdin
878 + .take()
879 + .ok_or_else(|| "child stdin was not captured".to_string())?;
880 + let stdout = child
881 + .stdout
882 + .take()
883 + .ok_or_else(|| "child stdout was not captured".to_string())?;
884 +
885 + let shared = Arc::new(StdioShared {
886 + name: name.to_string(),
887 + pending: StdMutex::new(HashMap::new()),
888 + dead: StdMutex::new(None),
889 + child: StdMutex::new(Some(child)),
890 + });
891 + tokio::spawn(stdio_reader(BufReader::new(stdout), Arc::clone(&shared)));
892 +
893 + Ok(StdioConn {
894 + writer: Mutex::new(Some(stdin)),
895 + shared,
896 + next_id: AtomicI64::new(1),
897 + })
898 +}
899 +
900 +/// Background task owning a stdio child's stdout: parses one JSON-RPC message
901 +/// per line and routes each response to the pending request that carries its
902 +/// id. Server-initiated requests and notifications (anything with a `method`)
903 +/// are logged and ignored — siGit doesn't support server→client calls. On EOF
904 +/// or a read error the connection is marked dead, which fails every in-flight
905 +/// request and reaps the child.
906 +async fn stdio_reader(mut stdout: BufReader<ChildStdout>, shared: Arc<StdioShared>) {
907 + let mut line = String::new();
908 + loop {
909 + line.clear();
910 + match stdout.read_line(&mut line).await {
911 + Ok(0) => {
912 + shared.mark_dead("server closed its stdout (process exited)");
913 + return;
914 + }
915 + Ok(_) => {}
916 + Err(error) => {
917 + shared.mark_dead(&format!("read error: {error}"));
918 + return;
919 + }
920 + }
921 + let trimmed = line.trim();
922 + if trimmed.is_empty() {
923 + continue;
924 + }
925 + let message: Value = match serde_json::from_str(trimmed) {
926 + Ok(message) => message,
927 + Err(error) => {
928 + log::warn!("mcp: '{}' sent a non-JSON line: {error}", shared.name);
929 + continue;
930 + }
931 + };
932 + if let Some(method) = message.get("method").and_then(Value::as_str) {
933 + log::debug!(
934 + "mcp: ignoring server-initiated '{method}' from '{}'",
935 + shared.name
936 + );
937 + continue;
938 + }
939 + let Some(id) = message.get("id").and_then(Value::as_i64) else {
940 + log::warn!(
941 + "mcp: '{}' sent a response without a usable id; ignoring",
942 + shared.name
943 + );
944 + continue;
945 + };
946 + let waiter = shared.pending.lock().unwrap().remove(&id);
947 + match waiter {
948 + Some(sender) => {
949 + let _ = sender.send(message);
950 + }
951 + None => log::debug!(
952 + "mcp: '{}' answered unknown/expired request id {id}; ignoring",
953 + shared.name
954 + ),
955 + }
956 + }
957 +}
958 +
959 +impl StdioConn {
960 + /// Send a JSON-RPC request and await its response, correlated by id. Fails
961 + /// fast (in-band, never panicking) when the child has died.
962 + async fn request(
963 + &self,
964 + method: &str,
965 + params: Value,
966 + timeout: Duration,
967 + ) -> Result<Value, String> {
968 + let name = &self.shared.name;
969 + if let Some(reason) = self.shared.dead_reason() {
970 + return Err(format!("stdio server '{name}' is not running: {reason}"));
971 + }
972 +
973 + let id = self.next_id.fetch_add(1, Ordering::Relaxed);
974 + let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
975 + let (sender, receiver) = oneshot::channel();
976 + self.shared.pending.lock().unwrap().insert(id, sender);
977 +
978 + if let Err(error) = self.write_line(&body).await {
979 + self.shared.pending.lock().unwrap().remove(&id);
980 + self.shared.mark_dead(&error);
981 + return Err(format!("stdio server '{name}': {error}"));
982 + }
983 +
984 + let message = match tokio::time::timeout(timeout, receiver).await {
985 + Ok(Ok(message)) => message,
986 + // Our sender was dropped: the connection died mid-request.
987 + Ok(Err(_)) => {
988 + let reason = self
989 + .shared
990 + .dead_reason()
991 + .unwrap_or_else(|| "connection closed".to_string());
992 + return Err(format!("stdio server '{name}' is not running: {reason}"));
993 + }
994 + Err(_) => {
995 + self.shared.pending.lock().unwrap().remove(&id);
996 + return Err(format!(
997 + "request to stdio server '{name}' timed out after {}s",
998 + timeout.as_secs()
999 + ));
1000 + }
1001 + };
1002 +
1003 + if let Some(error) = message.get("error") {
1004 + let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
1005 + let msg = error
1006 + .get("message")
1007 + .and_then(Value::as_str)
1008 + .unwrap_or("unknown error");
1009 + return Err(format!("'{name}' JSON-RPC error {code}: {msg}"));
1010 + }
1011 + message
1012 + .get("result")
1013 + .cloned()
1014 + .ok_or_else(|| format!("response from '{name}' had no result"))
1015 + }
1016 +
1017 + /// Send a JSON-RPC notification (no id, no response).
1018 + async fn notify(&self, method: &str) -> Result<(), String> {
1019 + let body = json!({ "jsonrpc": "2.0", "method": method });
1020 + if let Err(error) = self.write_line(&body).await {
1021 + self.shared.mark_dead(&error);
1022 + return Err(format!("stdio server '{}': {error}", self.shared.name));
1023 + }
1024 + Ok(())
1025 + }
1026 +
1027 + /// Write one newline-delimited JSON-RPC message. The writer mutex keeps
1028 + /// concurrent requests from interleaving bytes on the pipe.
1029 + async fn write_line(&self, body: &Value) -> Result<(), String> {
1030 + let mut guard = self.writer.lock().await;
1031 + let Some(writer) = guard.as_mut() else {
1032 + return Err("stdin already closed".to_string());
1033 + };
1034 + let mut line = body.to_string();
1035 + line.push('\n');
1036 + let result = async {
1037 + writer.write_all(line.as_bytes()).await?;
1038 + writer.flush().await
1039 + }
1040 + .await;
1041 + if let Err(error) = result {
1042 + // A broken pipe is unrecoverable; drop the writer so later calls
1043 + // fail fast.
1044 + *guard = None;
1045 + return Err(format!("write failed: {error}"));
1046 + }
1047 + Ok(())
1048 + }
1049 +}
1050 +
1051 // ── Streamable HTTP JSON-RPC plumbing ───────────────────────────────────────
1052
1053 /// POST a JSON-RPC request and return its `result`. Handles both an
@@ -618,7 +1055,8 @@ fn truncate(text: String) -> String {
1055 /// session id from the response headers, and maps a JSON-RPC `error` to `Err`.
1056 async fn post_rpc(
1057 http: &reqwest::Client,
621 - conn: &ServerConn,
1058 + name: &str,
1059 + conn: &HttpConn,
1060 body: &Value,
1061 timeout: Duration,
1062 ) -> Result<Value, String> {
@@ -659,8 +1097,7 @@ async fn post_rpc(
1097 let detail = response.text().await.unwrap_or_default();
1098 let detail: String = detail.chars().take(500).collect();
1099 return Err(format!(
662 - "server '{}' returned {}: {detail}",
663 - conn.name,
1100 + "server '{name}' returned {}: {detail}",
1101 status.as_u16()
1102 ));
1103 }
@@ -668,14 +1105,14 @@ async fn post_rpc(
1105 let text = response
1106 .text()
1107 .await
671 - .map_err(|error| format!("reading response from '{}': {error}", conn.name))?;
1108 + .map_err(|error| format!("reading response from '{name}': {error}"))?;
1109
1110 let message = if content_type.contains("text/event-stream") {
1111 parse_sse_response(&text)
675 - .ok_or_else(|| format!("no JSON-RPC message in SSE reply from '{}'", conn.name))?
1112 + .ok_or_else(|| format!("no JSON-RPC message in SSE reply from '{name}'"))?
1113 } else {
1114 serde_json::from_str::<Value>(&text)
678 - .map_err(|error| format!("parsing response from '{}': {error}", conn.name))?
1115 + .map_err(|error| format!("parsing response from '{name}': {error}"))?
1116 };
1117
1118 if let Some(error) = message.get("error") {
@@ -684,20 +1121,21 @@ async fn post_rpc(
1121 .get("message")
1122 .and_then(Value::as_str)
1123 .unwrap_or("unknown error");
687 - return Err(format!("'{}' JSON-RPC error {code}: {msg}", conn.name));
1124 + return Err(format!("'{name}' JSON-RPC error {code}: {msg}"));
1125 }
1126
1127 message
1128 .get("result")
1129 .cloned()
693 - .ok_or_else(|| format!("response from '{}' had no result", conn.name))
1130 + .ok_or_else(|| format!("response from '{name}' had no result"))
1131 }
1132
1133 /// POST a JSON-RPC notification (no id, no response expected). A non-success
1134 /// status is an error; an empty 202 body is the normal case.
1135 async fn post_notification(
1136 http: &reqwest::Client,
700 - conn: &ServerConn,
1137 + name: &str,
1138 + conn: &HttpConn,
1139 body: &Value,
1140 timeout: Duration,
1141 ) -> Result<(), String> {
@@ -708,8 +1146,7 @@ async fn post_notification(
1146 .map_err(|error| format!("notification to {} failed: {error}", conn.url))?;
1147 if !response.status().is_success() {
1148 return Err(format!(
711 - "server '{}' rejected notification: {}",
712 - conn.name,
1149 + "server '{name}' rejected notification: {}",
1150 response.status().as_u16()
1151 ));
1152 }
@@ -721,7 +1158,7 @@ async fn post_notification(
1158 /// session id once we have one.
1159 async fn build_request(
1160 http: &reqwest::Client,
724 - conn: &ServerConn,
1161 + conn: &HttpConn,
1162 body: &Value,
1163 timeout: Duration,
1164 ) -> reqwest::RequestBuilder {
@@ -779,7 +1216,8 @@ fn parse_sse_response(body: &str) -> Option<Value> {
1216 // ── Status reporting (`/mcp`) ────────────────────────────────────────────────
1217
1218 /// Human-readable summary of configured MCP servers and their tools, for the
782 -/// `/mcp` slash command.
1219 +/// `/mcp` slash command. Shows the URL for HTTP servers and the command line
1220 +/// for stdio servers.
1221 pub fn status_summary() -> String {
1222 let Some(mcp) = MCP.get() else {
1223 return "MCP is not initialized.".to_string();
@@ -799,13 +1237,13 @@ pub fn status_summary() -> String {
1237 match &server.error {
1238 Some(error) => lines.push(format!(
1239 "- {} ({}) — unavailable: {error}",
802 - server.name, server.url
1240 + server.name, server.endpoint
1241 )),
1242 None => {
1243 lines.push(format!(
1244 "- {} ({}) — {} tool(s)",
1245 server.name,
808 - server.url,
1246 + server.endpoint,
1247 server.tools.len()
1248 ));
1249 for tool in &server.tools {
@@ -867,23 +1305,117 @@ mod tests {
1305 );
1306 }
1307
1308 + #[test]
1309 + fn parses_stdio_server_with_args_and_env() {
1310 + let toml = r#"
1311 + [[server]]
1312 + name = "fs"
1313 + command = "npx"
1314 + args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1315 +
1316 + [server.env]
1317 + LOG_LEVEL = "debug"
1318 + TOKEN = "abc"
1319 + "#;
1320 + let parsed: McpFile = toml::from_str(toml).unwrap();
1321 + assert_eq!(parsed.server.len(), 1);
1322 + let entry = &parsed.server[0];
1323 + assert_eq!(entry.command.as_deref(), Some("npx"));
1324 + assert_eq!(entry.args.len(), 3);
1325 + assert_eq!(
1326 + entry.env.get("LOG_LEVEL").map(String::as_str),
1327 + Some("debug")
1328 + );
1329 + assert_eq!(entry.env.get("TOKEN").map(String::as_str), Some("abc"));
1330 +
1331 + let def = entry.transport_def().expect("valid stdio entry");
1332 + match def {
1333 + TransportDef::Stdio { command, args, env } => {
1334 + assert_eq!(command, "npx");
1335 + assert_eq!(args[0], "-y");
1336 + assert_eq!(env.len(), 2);
1337 + }
1338 + TransportDef::Http { .. } => panic!("expected a stdio transport"),
1339 + }
1340 + }
1341 +
1342 + #[test]
1343 + fn entry_with_url_and_command_is_a_config_error() {
1344 + let toml = r#"
1345 + [[server]]
1346 + name = "confused"
1347 + url = "https://example.com/mcp"
1348 + command = "npx"
1349 + "#;
1350 + let parsed: McpFile = toml::from_str(toml).unwrap();
1351 + let error = parsed.server[0].transport_def().unwrap_err();
1352 + assert!(error.contains("both"), "unexpected error: {error}");
1353 + }
1354 +
1355 + #[test]
1356 + fn entry_with_neither_url_nor_command_is_a_config_error() {
1357 + let toml = r#"
1358 + [[server]]
1359 + name = "empty"
1360 + "#;
1361 + let parsed: McpFile = toml::from_str(toml).unwrap();
1362 + let error = parsed.server[0].transport_def().unwrap_err();
1363 + assert!(error.contains("needs"), "unexpected error: {error}");
1364 + }
1365 +
1366 + #[test]
1367 + fn blank_url_or_command_counts_as_absent() {
1368 + let toml = r#"
1369 + [[server]]
1370 + name = "blank"
1371 + url = " "
1372 + command = "server-bin"
1373 + "#;
1374 + let parsed: McpFile = toml::from_str(toml).unwrap();
1375 + // A blank url is treated as absent, so this resolves to stdio.
1376 + match parsed.server[0].transport_def().expect("stdio") {
1377 + TransportDef::Stdio { command, .. } => assert_eq!(command, "server-bin"),
1378 + TransportDef::Http { .. } => panic!("expected stdio"),
1379 + }
1380 + }
1381 +
1382 + #[test]
1383 + fn endpoint_renders_url_or_command_line() {
1384 + let http = TransportDef::Http {
1385 + url: "https://example.com/mcp".into(),
1386 + headers: vec![],
1387 + };
1388 + assert_eq!(http.endpoint(), "https://example.com/mcp");
1389 +
1390 + let stdio = TransportDef::Stdio {
1391 + command: "npx".into(),
1392 + args: vec!["-y".into(), "server-fs".into()],
1393 + env: vec![],
1394 + };
1395 + assert_eq!(stdio.endpoint(), "npx -y server-fs");
1396 + }
1397 +
1398 #[test]
1399 fn upsert_replaces_same_name() {
1400 let mut defs = vec![ServerDef {
1401 name: "a".into(),
874 - url: "u1".into(),
875 - headers: vec![],
1402 + transport: TransportDef::Http {
1403 + url: "u1".into(),
1404 + headers: vec![],
1405 + },
1406 }];
1407 upsert(
1408 &mut defs,
1409 ServerDef {
1410 name: "a".into(),
881 - url: "u2".into(),
882 - headers: vec![],
1411 + transport: TransportDef::Http {
1412 + url: "u2".into(),
1413 + headers: vec![],
1414 + },
1415 },
1416 );
1417 assert_eq!(defs.len(), 1);
886 - assert_eq!(defs[0].url, "u2");
1418 + assert_eq!(defs[0].transport.endpoint(), "u2");
1419 }
1420
1421 #[test]
tests/mcp_stdio.rs new
+371
@@ -0,0 +1,371 @@
1 +//! End-to-end stdio MCP transport test against the real binary.
2 +//!
3 +//! Spawns `sigit` in ACP mode wired to a scripted OpenAI-compatible SSE
4 +//! endpoint (the same harness as `acp_permissions.rs`) and to a temp
5 +//! `SIGIT_CONFIG_DIR` whose `mcp.toml` configures three stdio servers, all
6 +//! backed by the `mcp_stdio_stub` helper binary:
7 +//!
8 +//! - `stub` — healthy; exposes one `echo` tool and prefixes replies with the
9 +//! `STUB_PREFIX` env var from `[server.env]`, proving env propagation.
10 +//! - `dying` — completes the discovery handshake, then exits. Its tool is
11 +//! offered to the model, but calling it must fail with an in-band error.
12 +//! - `deadone` — exits(1) at spawn; discovery must record it unavailable and
13 +//! offer no tools for it.
14 +//!
15 +//! The scripted model calls `mcp__stub__echo`, then `mcp__dying__echo`, then
16 +//! finishes. The test asserts the tool round-trip, the dead-child error, and
17 +//! the `/mcp` listing (command lines shown, dead server flagged).
18 +
19 +use std::collections::VecDeque;
20 +use std::io::{BufRead, BufReader, Read, Write};
21 +use std::net::TcpListener;
22 +use std::process::{Child, ChildStdin, Command, Stdio};
23 +use std::sync::mpsc::{Receiver, channel};
24 +use std::sync::{Arc, Mutex};
25 +use std::time::{Duration, Instant};
26 +
27 +use serde_json::{Value, json};
28 +
29 +const TIMEOUT: Duration = Duration::from_secs(60);
30 +
31 +// ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
32 +
33 +fn sse_body(events: &[Value]) -> String {
34 + let mut body = String::new();
35 + for event in events {
36 + body.push_str("data: ");
37 + body.push_str(&event.to_string());
38 + body.push_str("\n\n");
39 + }
40 + body.push_str("data: [DONE]\n\n");
41 + body
42 +}
43 +
44 +fn sse_tool_call(id: &str, name: &str, arguments: &str) -> String {
45 + sse_body(&[json!({
46 + "choices": [{"delta": {"tool_calls": [{
47 + "index": 0,
48 + "id": id,
49 + "function": {"name": name, "arguments": arguments},
50 + }]}}]
51 + })])
52 +}
53 +
54 +fn sse_text(text: &str) -> String {
55 + sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
56 +}
57 +
58 +/// Serves one scripted SSE response per request and records each request body.
59 +struct FakeEndpoint {
60 + port: u16,
61 + requests: Arc<Mutex<Vec<Value>>>,
62 +}
63 +
64 +fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
65 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
66 + let port = listener.local_addr().unwrap().port();
67 + let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
68 + let recorded = Arc::clone(&requests);
69 + let queue = Mutex::new(VecDeque::from(responses));
70 +
71 + std::thread::spawn(move || {
72 + for stream in listener.incoming() {
73 + let Ok(mut stream) = stream else { continue };
74 + let mut reader = BufReader::new(match stream.try_clone() {
75 + Ok(clone) => clone,
76 + Err(_) => continue,
77 + });
78 + let mut content_length = 0usize;
79 + loop {
80 + let mut line = String::new();
81 + if reader.read_line(&mut line).unwrap_or(0) == 0 {
82 + break;
83 + }
84 + let line = line.trim();
85 + if line.is_empty() {
86 + break;
87 + }
88 + if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
89 + content_length = length.trim().parse().unwrap_or(0);
90 + }
91 + }
92 + let mut body = vec![0u8; content_length];
93 + if reader.read_exact(&mut body).is_err() {
94 + continue;
95 + }
96 + if let Ok(request) = serde_json::from_slice::<Value>(&body) {
97 + recorded.lock().unwrap().push(request);
98 + }
99 + let payload = queue
100 + .lock()
101 + .unwrap()
102 + .pop_front()
103 + .unwrap_or_else(|| sse_text("out of scripted responses"));
104 + let response = format!(
105 + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
106 + content-length: {}\r\nconnection: close\r\n\r\n{}",
107 + payload.len(),
108 + payload
109 + );
110 + let _ = stream.write_all(response.as_bytes());
111 + }
112 + });
113 +
114 + FakeEndpoint { port, requests }
115 +}
116 +
117 +// ── ACP client over the binary's stdio ──────────────────────────────────────
118 +
119 +struct AgentUnderTest {
120 + child: Child,
121 + stdin: ChildStdin,
122 + incoming: Receiver<Value>,
123 + next_id: u64,
124 +}
125 +
126 +fn spawn_agent(port: u16, config_dir: &std::path::Path, cwd: &std::path::Path) -> AgentUnderTest {
127 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
128 + .current_dir(cwd)
129 + .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
130 + .env("OPENAI_API_KEY", "test-key")
131 + .env("SIGIT_MODEL", "scripted-model")
132 + .env("SIGIT_CONFIG_DIR", config_dir)
133 + // MCP stays ON (that's what we test), but the baked-in official
134 + // server must not phone home from CI.
135 + .env("SIGIT_MCP_OFFICIAL", "off")
136 + .env_remove("SIGIT_MCP")
137 + // MCP tools are mutating and would otherwise wait at the permission
138 + // gate; permissions have their own test.
139 + .env("SIGIT_PERMISSIONS", "allow")
140 + .env_remove("SIGIT_LOCAL_INFERENCE")
141 + .stdin(Stdio::piped())
142 + .stdout(Stdio::piped())
143 + .stderr(Stdio::null())
144 + .spawn()
145 + .expect("spawn sigit in ACP mode");
146 +
147 + let stdout = child.stdout.take().unwrap();
148 + let (message_tx, incoming) = channel();
149 + std::thread::spawn(move || {
150 + for line in BufReader::new(stdout).lines() {
151 + let Ok(line) = line else { break };
152 + if let Ok(message) = serde_json::from_str::<Value>(&line)
153 + && message_tx.send(message).is_err()
154 + {
155 + break;
156 + }
157 + }
158 + });
159 +
160 + let stdin = child.stdin.take().unwrap();
161 + AgentUnderTest {
162 + child,
163 + stdin,
164 + incoming,
165 + next_id: 0,
166 + }
167 +}
168 +
169 +impl AgentUnderTest {
170 + fn send(&mut self, message: Value) {
171 + let mut line = message.to_string();
172 + line.push('\n');
173 + self.stdin
174 + .write_all(line.as_bytes())
175 + .expect("write to agent stdin");
176 + self.stdin.flush().expect("flush agent stdin");
177 + }
178 +
179 + fn request(&mut self, method: &str, params: Value) -> u64 {
180 + self.next_id += 1;
181 + let id = self.next_id;
182 + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
183 + id
184 + }
185 +
186 + /// Wait for the response to our request `id`, collecting the raw JSON of
187 + /// every `session/update` notification that arrives before it.
188 + fn wait_for_response_collecting_updates(&mut self, id: u64) -> (Value, String) {
189 + let deadline = Instant::now() + TIMEOUT;
190 + let mut updates = String::new();
191 + loop {
192 + let remaining = deadline.saturating_duration_since(Instant::now());
193 + match self.incoming.recv_timeout(remaining) {
194 + Ok(message) if message["id"] == id && message.get("method").is_none() => {
195 + assert!(
196 + message.get("error").is_none(),
197 + "request {id} failed: {message}"
198 + );
199 + return (message, updates);
200 + }
201 + Ok(message) => {
202 + if message["method"] == "session/update" {
203 + updates.push_str(&message["params"].to_string());
204 + updates.push('\n');
205 + }
206 + }
207 + Err(_) => panic!("timed out waiting for response to request {id}"),
208 + }
209 + }
210 + }
211 +
212 + fn wait_for_response(&mut self, id: u64) -> Value {
213 + self.wait_for_response_collecting_updates(id).0
214 + }
215 +}
216 +
217 +impl Drop for AgentUnderTest {
218 + fn drop(&mut self) {
219 + let _ = self.child.kill();
220 + let _ = self.child.wait();
221 + }
222 +}
223 +
224 +// ── The round-trip ──────────────────────────────────────────────────────────
225 +
226 +#[test]
227 +fn stdio_mcp_discovery_call_and_dead_child() {
228 + let stub = env!("CARGO_BIN_EXE_mcp_stdio_stub");
229 +
230 + let endpoint = start_fake_endpoint(vec![
231 + sse_tool_call("call_1", "mcp__stub__echo", r#"{"text":"hello"}"#),
232 + sse_tool_call("call_2", "mcp__dying__echo", r#"{"text":"gone"}"#),
233 + sse_text("done"),
234 + ]);
235 +
236 + let scratch = std::env::temp_dir().join(format!("sigit_mcp_stdio_{}", std::process::id()));
237 + let config_dir = scratch.join("config");
238 + let cwd = scratch.join("cwd");
239 + std::fs::create_dir_all(&config_dir).unwrap();
240 + std::fs::create_dir_all(&cwd).unwrap();
241 +
242 + // TOML literal strings (single quotes) keep Windows backslashes intact.
243 + let mcp_toml = format!(
244 + r#"official = false
245 +
246 +[[server]]
247 +name = "stub"
248 +command = '{stub}'
249 +
250 +[server.env]
251 +STUB_PREFIX = "pfx:"
252 +
253 +[[server]]
254 +name = "dying"
255 +command = '{stub}'
256 +args = ["--exit-after-list"]
257 +
258 +[[server]]
259 +name = "deadone"
260 +command = '{stub}'
261 +args = ["--fail"]
262 +"#
263 + );
264 + std::fs::write(config_dir.join("mcp.toml"), mcp_toml).unwrap();
265 +
266 + let mut agent = spawn_agent(endpoint.port, &config_dir, &cwd);
267 +
268 + let id = agent.request(
269 + "initialize",
270 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
271 + );
272 + agent.wait_for_response(id);
273 +
274 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
275 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
276 + .as_str()
277 + .expect("session id")
278 + .to_string();
279 +
280 + // ── /mcp: listing shows command lines and flags the dead server ─────
281 + let prompt_id = agent.request(
282 + "session/prompt",
283 + json!({
284 + "sessionId": session_id,
285 + "prompt": [{"type": "text", "text": "/mcp"}],
286 + }),
287 + );
288 + let (_, listing) = agent.wait_for_response_collecting_updates(prompt_id);
289 + // Raw JSON of the update notifications; escape the path the way JSON does
290 + // so Windows backslashes compare correctly.
291 + let stub_json = serde_json::to_string(stub).unwrap();
292 + let stub_escaped = stub_json.trim_matches('"');
293 + assert!(
294 + listing.contains("mcp__stub__echo"),
295 + "/mcp must list the healthy server's tool, got: {listing}"
296 + );
297 + assert!(
298 + listing.contains(stub_escaped),
299 + "/mcp must show the stdio server's command line, got: {listing}"
300 + );
301 + assert!(
302 + listing.contains("--exit-after-list"),
303 + "/mcp must include the args in the command line, got: {listing}"
304 + );
305 + assert!(
306 + listing.contains("unavailable"),
307 + "/mcp must flag the server that died at spawn, got: {listing}"
308 + );
309 +
310 + // ── One prompt: echo round-trip, then the dead-child call ───────────
311 + let prompt_id = agent.request(
312 + "session/prompt",
313 + json!({
314 + "sessionId": session_id,
315 + "prompt": [{"type": "text", "text": "use the stub tools"}],
316 + }),
317 + );
318 + let response = agent.wait_for_response(prompt_id);
319 + assert_eq!(response["result"]["stopReason"], "end_turn");
320 +
321 + // ── What the endpoint saw ────────────────────────────────────────────
322 + let requests = endpoint.requests.lock().unwrap();
323 + // Slash commands never reach the model, so all three completions belong
324 + // to the tool-calling prompt.
325 + assert_eq!(requests.len(), 3, "expected exactly three completions");
326 +
327 + // The offered tool specs must include both live servers' echo tools and
328 + // nothing from the server that failed discovery.
329 + let tools = requests[0]["tools"].to_string();
330 + assert!(
331 + tools.contains("mcp__stub__echo"),
332 + "stub tool missing from specs: {tools}"
333 + );
334 + assert!(
335 + tools.contains("mcp__dying__echo"),
336 + "dying server's tool missing from specs: {tools}"
337 + );
338 + assert!(
339 + !tools.contains("mcp__deadone__"),
340 + "a server that failed discovery must contribute no tools: {tools}"
341 + );
342 +
343 + // The echo call's result must round-trip, carrying the [server.env]
344 + // prefix (proving env vars reached the child).
345 + let messages = requests[1]["messages"].as_array().expect("messages");
346 + let result = messages
347 + .iter()
348 + .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
349 + .expect("tool result for the echo call");
350 + assert_eq!(
351 + result["content"].as_str().unwrap_or_default(),
352 + "pfx:hello",
353 + "echo result should carry the env-var prefix"
354 + );
355 +
356 + // The call to the server that died after discovery must come back as an
357 + // in-band error string, not hang or crash the agent.
358 + let messages = requests[2]["messages"].as_array().expect("messages");
359 + let result = messages
360 + .iter()
361 + .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_2")
362 + .expect("tool result for the dead server's call");
363 + let content = result["content"].as_str().unwrap_or_default();
364 + assert!(
365 + content.contains("Error") && content.contains("stdio server 'dying'"),
366 + "dead-child call must fail in-band, got: {content}"
367 + );
368 +
369 + drop(agent);
370 + let _ = std::fs::remove_dir_all(&scratch);
371 +}