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
//!
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.
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
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`).
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
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 {
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):
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
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
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
};
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.
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")
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))
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
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> {
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
}
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") {
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> {
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
}
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 {
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();
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 {
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]