Run MCP on ed-mcp instead of a hand-rolled JSON-RPC client

The stdio and Streamable HTTP plumbing in src/mcp.rs (child process reader, request ids, SSE parsing, session headers) is replaced by ed_mcp::Connection, which runs rmcp. What siGit decides stays here: mcp.toml, the baked-in sigit and smbcloud servers, tool names, the result text and /mcp. An HTTP server whose call fails is reconnected and the call retried once unless it timed out, where before only a 404 did that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.3 <noreply@sigit.si>

Seto Elkahfi committed Oct 9, 2026 at 13:48 UTC 78d1f8af6feff2b0ac4a2a45b5a45f75b2956ab7
3 files changed +152 -739
CHANGELOG.md
+9
@@ -11,6 +11,15 @@
11 parsing the title, and a replayed thread from `session/load` carries the same
12 name (#196).
13
14 +### Changed
15 +
16 +- **MCP runs on `rmcp`, the official Rust SDK, through `ed-mcp`.** siGit Code's
17 + own JSON-RPC client for stdio and Streamable HTTP servers (about 600 lines)
18 + is gone; `ed-mcp` is the layer every Onde agent shares. `mcp.toml`, the
19 + baked-in `sigit` and `smbcloud` servers, tool names and `/mcp` are unchanged.
20 + An HTTP server that stops answering is now reconnected and the call retried
21 + once for any failure but a timeout, not only on a 404.
22 +
23 ### Fixed
24
25 - **A reply that degenerates into a repetition loop is cut off.** After a tool
Cargo.toml
+3
@@ -25,6 +25,9 @@ path = "src/main.rs"
25 # ACP protocol SDK
26 agent-client-protocol = { version = "2.2", features = ["unstable_session_fork"] }
27
28 +# MCP client (rmcp underneath), shared with the other Onde agents
29 +ed-mcp = "1.2.0"
30 +
31 # Onde Inference engine (local LLM)
32 # onde = { path = "../onde" }
33 onde = "1.3.1"
src/mcp.rs
+140 -739
@@ -1,22 +1,20 @@
1 //! Model Context Protocol (MCP) client for siGit Code.
2 //!
3 -//! Implements the client half of the [Model Context Protocol](https://modelcontextprotocol.io):
4 -//! siGit Code connects to one or more MCP servers, discovers the tools they
5 -//! expose, and surfaces those tools to the model alongside its built-in ones.
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.
3 +//! siGit Code connects to one or more [MCP](https://modelcontextprotocol.io)
4 +//! servers, discovers the tools they expose, and surfaces those tools to the
5 +//! model alongside its built-in ones. When the model calls an MCP tool, the
6 +//! call is forwarded to the owning server and the result fed back into the
7 +//! agent loop.
8 //!
9 -//! Transports:
9 +//! The protocol itself is [`ed_mcp`]'s, which runs `rmcp`, the official Rust
10 +//! SDK. This module decides *which* servers to connect and how their tools are
11 +//! named and shown. Transports:
12 //!
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.
13 +//! - **Streamable HTTP**, configured with `url` in `mcp.toml`.
14 +//! - **stdio**: siGit spawns the server as a child process (its stderr flows
15 +//! into siGit's own log stream). Configured with `command` (plus optional
16 +//! `args` and `[server.env]`) in `mcp.toml`. This is how most published MCP
17 +//! servers (filesystem, Playwright, GitHub, ...) are run.
18 //!
19 //! `url` and `command` are mutually exclusive; an entry with both, or neither,
20 //! is a config error that is logged and skipped.
@@ -47,9 +45,10 @@
45 //! stored in a process-global so the synchronous tool-spec builders
46 //! ([`tool_specs`]) and the async dispatch ([`call_tool`]) can both read it.
47 //!
50 -//! stdio children live for the sigit process. When a child dies (EOF or an I/O
51 -//! error on its pipes) the server is marked dead and later calls return an
52 -//! in-band error string the model can react to; there is no automatic restart.
48 +//! stdio children live for the sigit process. When a child dies, later calls
49 +//! return an in-band error string the model can react to; there is no
50 +//! automatic restart. An HTTP server that stops answering (a restart, an
51 +//! expired session) is reconnected once and the call retried.
52 //! `/reload` does *not* re-run discovery ([`init`] is once-per-process), so a
53 //! changed `mcp.toml` or a dead server needs a sigit restart. At process exit
54 //! children see EOF on their stdin and exit on their own.
@@ -63,18 +62,16 @@
62 //! are unused, so the dead-code lint is suppressed there only.
63 #![cfg_attr(not(unix), allow(dead_code))]
64
66 -use std::collections::{BTreeMap, HashMap};
65 +use std::collections::BTreeMap;
66 use std::path::PathBuf;
67 use std::sync::Mutex as StdMutex;
69 -use std::sync::atomic::{AtomicI64, Ordering};
68 use std::sync::{Arc, OnceLock};
69 use std::time::Duration;
70
71 +use ed_mcp::{ClientInfo, Connection, ServerSpec};
72 use serde::Deserialize;
73 use serde_json::{Value, json};
75 -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
76 -use tokio::process::{Child, ChildStdin, ChildStdout, Command};
77 -use tokio::sync::{Mutex, oneshot};
74 +use tokio::sync::RwLock;
75
76 use crate::backend::ToolSpec;
77
@@ -118,9 +115,6 @@ pub fn smbcloud_tool_suffix(name: &str) -> Option<&str> {
115 .strip_prefix("__")
116 }
117
121 -/// JSON-RPC / MCP protocol version we advertise in the handshake.
122 -const PROTOCOL_VERSION: &str = "2025-06-18";
123 -
118 /// Per-server budget for the connect + `initialize` + `tools/list` handshake at
119 /// startup. Bounds how long an unreachable server can delay startup; servers are
120 /// contacted concurrently, so this is the worst case for the whole set, not the
@@ -160,90 +154,19 @@ struct ServerConn {
154 /// Display endpoint for the `/mcp` listing: the URL for HTTP servers, the
155 /// command line for stdio servers.
156 endpoint: String,
163 - /// The live transport. `None` when a stdio server failed to even spawn.
164 - transport: Option<Transport>,
157 + /// How it was reached, kept so an HTTP server can be reconnected.
158 + def: ServerDef,
159 + /// The live connection. `None` when the server failed to connect.
160 + conn: RwLock<Option<Arc<Connection>>>,
161 /// Tools discovered at startup. Empty when the server failed to connect.
162 tools: Vec<McpTool>,
163 /// Connection error, if the handshake failed. Surfaced by `/mcp`.
164 error: Option<String>,
165 }
166
171 -/// How a connected server is reached.
172 -enum Transport {
173 - Http(HttpConn),
174 - Stdio(StdioConn),
175 -}
176 -
177 -/// Streamable HTTP connection state.
178 -struct HttpConn {
179 - /// Streamable HTTP endpoint (the single POST URL).
180 - url: String,
181 - /// Extra headers sent on every request (e.g. `Authorization`).
182 - headers: Vec<(String, String)>,
183 - /// Session id handed back by the server on `initialize`, echoed on every
184 - /// later request via the `Mcp-Session-Id` header.
185 - session_id: Mutex<Option<String>>,
186 -}
187 -
188 -/// stdio connection state: a child process speaking newline-delimited JSON-RPC
189 -/// over its stdin/stdout.
190 -struct StdioConn {
191 - /// The child's stdin. The mutex serializes writes so concurrent requests
192 - /// can't interleave bytes on the pipe; `None` once the pipe broke.
193 - writer: Mutex<Option<ChildStdin>>,
194 - /// State shared with the background reader task that owns the child's
195 - /// stdout.
196 - shared: Arc<StdioShared>,
197 - /// JSON-RPC id source. Ids are per-connection so the reader task can route
198 - /// each response to the request that carries its id.
199 - next_id: AtomicI64,
200 -}
201 -
202 -/// State shared between a [`StdioConn`] and its background reader task.
203 -struct StdioShared {
204 - /// Server name, for log lines.
205 - name: String,
206 - /// In-flight requests awaiting a response, keyed by JSON-RPC id. Dropping
207 - /// a sender (when the connection dies) wakes the waiter with an error.
208 - pending: StdMutex<HashMap<i64, oneshot::Sender<Value>>>,
209 - /// Why the connection is unusable, once it is (EOF, I/O error, kill).
210 - dead: StdMutex<Option<String>>,
211 - /// The child handle, kept so a dead/failed connection can kill and reap
212 - /// the process. Taken on death.
213 - child: StdMutex<Option<Child>>,
214 -}
215 -
216 -impl StdioShared {
217 - fn dead_reason(&self) -> Option<String> {
218 - self.dead.lock().unwrap().clone()
219 - }
220 -
221 - /// Mark the connection unusable: record the reason (first one wins), fail
222 - /// every in-flight request, and kill + reap the child, best effort.
223 - fn mark_dead(&self, reason: &str) {
224 - {
225 - let mut dead = self.dead.lock().unwrap();
226 - if dead.is_none() {
227 - *dead = Some(reason.to_string());
228 - }
229 - }
230 - // Dropping the senders wakes every waiter with a recv error.
231 - self.pending.lock().unwrap().clear();
232 - if let Some(mut child) = self.child.lock().unwrap().take() {
233 - let _ = child.start_kill();
234 - tokio::spawn(async move {
235 - let _ = child.wait().await;
236 - });
237 - }
238 - }
239 -}
240 -
241 -/// The process-global MCP state: a shared HTTP client plus every configured
242 -/// server.
167 +/// The process-global MCP state: every configured server.
168 struct Mcp {
244 - http: reqwest::Client,
169 servers: Vec<ServerConn>,
246 - next_id: AtomicI64,
170 }
171
172 static MCP: OnceLock<Mcp> = OnceLock::new();
@@ -377,6 +300,35 @@ struct ServerDef {
300 transport: TransportDef,
301 }
302
303 +impl ServerDef {
304 + /// The `ed-mcp` spec for this server. An `Authorization: Bearer` header is
305 + /// passed as the bearer token, which is how `rmcp` wants it.
306 + fn spec(&self) -> ServerSpec {
307 + match &self.transport {
308 + TransportDef::Http { url, headers } => {
309 + headers
310 + .iter()
311 + .fold(
312 + ServerSpec::http(&self.name, url),
313 + |spec, (key, value)| match value.strip_prefix("Bearer ") {
314 + Some(token) if key.eq_ignore_ascii_case("authorization") => {
315 + spec.with_bearer(token)
316 + }
317 + _ => spec.with_header(key, value),
318 + },
319 + )
320 + }
321 + TransportDef::Stdio { command, args, env } => {
322 + ServerSpec::stdio(&self.name, command, args.clone(), env.clone())
323 + }
324 + }
325 + }
326 +
327 + fn is_stdio(&self) -> bool {
328 + matches!(self.transport, TransportDef::Stdio { .. })
329 + }
330 +}
331 +
332 /// Config files to read, in priority order (later wins on a name clash):
333 /// global `$SIGIT_CONFIG_DIR/mcp.toml`, then project-local `<cwd>/.sigit/mcp.toml`.
334 fn config_paths() -> Vec<PathBuf> {
@@ -570,24 +522,9 @@ pub async fn init() {
522 return;
523 }
524
573 - let defs = load_configs();
574 - let http = reqwest::Client::builder()
575 - .timeout(CALL_TIMEOUT)
576 - .user_agent(concat!(
577 - "sigit/",
578 - env!("CARGO_PKG_VERSION"),
579 - " (mcp-client)"
580 - ))
581 - .build()
582 - .unwrap_or_default();
583 -
525 // Contact servers concurrently so one slow/unreachable host doesn't serialize
526 // the rest. Each handshake is bounded by HANDSHAKE_TIMEOUT.
586 - let connects = defs.into_iter().map(|def| {
587 - let http = http.clone();
588 - async move { connect(&http, def).await }
589 - });
590 - let servers = futures::future::join_all(connects).await;
527 + let servers = futures::future::join_all(load_configs().into_iter().map(connect)).await;
528
529 for server in &servers {
530 match &server.error {
@@ -600,154 +537,66 @@ pub async fn init() {
537 }
538 }
539
603 - let _ = MCP.set(Mcp {
604 - http,
605 - servers,
606 - next_id: AtomicI64::new(1),
607 - });
540 + let _ = MCP.set(Mcp { servers });
541 +}
542 +
543 +/// Sent to every server as `clientInfo`.
544 +fn client_info() -> ClientInfo {
545 + ClientInfo::new("sigit", env!("CARGO_PKG_VERSION"))
546 }
547
548 /// Run the handshake against one server and collect its tools. Always returns a
549 /// `ServerConn`; failures land in its `error` field rather than propagating.
612 -async fn connect(http: &reqwest::Client, def: ServerDef) -> ServerConn {
550 +async fn connect(def: ServerDef) -> ServerConn {
551 let endpoint = def.transport.endpoint();
614 - let transport = match &def.transport {
615 - TransportDef::Http { url, headers } => Transport::Http(HttpConn {
616 - url: url.clone(),
617 - headers: headers.clone(),
618 - session_id: Mutex::new(None),
619 - }),
620 - TransportDef::Stdio { command, args, env } => {
621 - match spawn_stdio(&def.name, command, args, env) {
622 - Ok(conn) => Transport::Stdio(conn),
623 - Err(error) => {
624 - return ServerConn {
625 - name: def.name,
626 - endpoint,
627 - transport: None,
628 - tools: Vec::new(),
629 - error: Some(error),
630 - };
631 - }
552 + let (conn, tools, error) =
553 + match Connection::connect(&def.spec(), &client_info(), HANDSHAKE_TIMEOUT).await {
554 + Ok(conn) => {
555 + let tools = server_tools(&def.name, &conn);
556 + (Some(Arc::new(conn)), tools, None)
557 }
633 - }
634 - };
635 -
636 - let mut conn = ServerConn {
637 - name: def.name,
558 + Err(error) => (None, Vec::new(), Some(format!("{error:#}"))),
559 + };
560 + ServerConn {
561 + name: def.name.clone(),
562 endpoint,
639 - transport: Some(transport),
640 - tools: Vec::new(),
641 - error: None,
642 - };
643 -
644 - let handshake = tokio::time::timeout(HANDSHAKE_TIMEOUT, async {
645 - // initialize → notifications/initialized → tools/list
646 - initialize(http, &conn).await?;
647 - notify_initialized(http, &conn).await?;
648 - list_tools(http, &conn).await
649 - })
650 - .await;
651 -
652 - match handshake {
653 - Ok(Ok(tools)) => conn.tools = tools,
654 - Ok(Err(error)) => conn.error = Some(error),
655 - Err(_) => conn.error = Some(format!("timed out after {}s", HANDSHAKE_TIMEOUT.as_secs())),
656 - }
657 -
658 - // A stdio child that failed its handshake is useless — kill it rather than
659 - // leave it running for the rest of the process.
660 - if let Some(error) = conn.error.clone()
661 - && let Some(Transport::Stdio(stdio)) = &conn.transport
662 - {
663 - stdio.shared.mark_dead(&error);
563 + def,
564 + conn: RwLock::new(conn),
565 + tools,
566 + error,
567 }
665 -
666 - conn
667 -}
668 -
669 -/// The `initialize` request: negotiate protocol version and (on HTTP) capture
670 -/// the session id from the response headers (handled inside [`post_rpc`]).
671 -async fn initialize(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
672 - let params = json!({
673 - "protocolVersion": PROTOCOL_VERSION,
674 - "capabilities": {},
675 - "clientInfo": { "name": "sigit", "version": env!("CARGO_PKG_VERSION") }
676 - });
677 - rpc_request(http, conn, "initialize", params, HANDSHAKE_TIMEOUT).await?;
678 - Ok(())
568 }
569
681 -/// The `notifications/initialized` notification. Servers expect it before
682 -/// fielding requests; it carries no id and no response.
683 -async fn notify_initialized(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
684 - rpc_notify(http, conn, "notifications/initialized", HANDSHAKE_TIMEOUT).await
685 -}
686 -
687 -/// `tools/list`, following `nextCursor` pagination, mapped into [`McpTool`]s.
688 -async fn list_tools(http: &reqwest::Client, conn: &ServerConn) -> Result<Vec<McpTool>, String> {
689 - let mut tools = Vec::new();
690 - let mut cursor: Option<String> = None;
691 -
692 - loop {
693 - let params = match &cursor {
694 - Some(c) => json!({ "cursor": c }),
695 - None => json!({}),
696 - };
697 - let result = rpc_request(http, conn, "tools/list", params, HANDSHAKE_TIMEOUT).await?;
698 -
699 - for tool in result
700 - .get("tools")
701 - .and_then(Value::as_array)
702 - .into_iter()
703 - .flatten()
704 - {
705 - let Some(remote_name) = tool.get("name").and_then(Value::as_str) else {
706 - continue;
707 - };
708 - let full_name = format!("{MCP_PREFIX}{}__{}", conn.name, sanitize(remote_name));
570 +/// The tools `conn` listed, in siGit's flattened form.
571 +fn server_tools(server: &str, conn: &Connection) -> Vec<McpTool> {
572 + conn.tools()
573 + .iter()
574 + .map(|tool| {
575 + let remote_name = tool.name.to_string();
576 + let full_name = format!("{MCP_PREFIX}{server}__{}", sanitize(&remote_name));
577 if full_name.chars().count() > 64 {
578 log::warn!(
579 "mcp: tool name '{full_name}' exceeds 64 chars; some backends may reject it"
580 );
581 }
714 - let remote_desc = tool
715 - .get("description")
716 - .and_then(Value::as_str)
717 - .unwrap_or("")
718 - .trim();
582 + let remote_desc = tool.description.as_deref().unwrap_or("").trim();
583 let description = if remote_desc.is_empty() {
720 - format!("[MCP server '{}'] {remote_name}", conn.name)
584 + format!("[MCP server '{server}'] {remote_name}")
585 } else {
722 - format!("[MCP server '{}'] {remote_desc}", conn.name)
586 + format!("[MCP server '{server}'] {remote_desc}")
587 };
724 - // `inputSchema` is a JSON Schema object; default to a permissive
725 - // object schema when a server omits it.
726 - let parameters_schema = tool
727 - .get("inputSchema")
728 - .filter(|schema| schema.is_object())
729 - .cloned()
730 - .unwrap_or_else(|| json!({ "type": "object" }))
731 - .to_string();
732 -
733 - tools.push(McpTool {
588 + let mut schema = Value::Object((*tool.input_schema).clone());
589 + if schema.get("type").is_none() {
590 + schema["type"] = json!("object");
591 + }
592 + McpTool {
593 full_name,
735 - remote_name: remote_name.to_string(),
594 + remote_name,
595 description,
737 - parameters_schema,
738 - });
739 - }
740 -
741 - cursor = result
742 - .get("nextCursor")
743 - .and_then(Value::as_str)
744 - .map(str::to_string);
745 - if cursor.is_none() {
746 - break;
747 - }
748 - }
749 -
750 - Ok(tools)
596 + parameters_schema: schema.to_string(),
597 + }
598 + })
599 + .collect()
600 }
601
602 // ── Client-supplied servers (per session) ───────────────────────────────────
@@ -780,8 +629,7 @@ pub enum ClientTransport {
629 /// Unlike the startup servers in [`MCP`], these belong to a session: the
630 /// client names them on `session/new` (or load/fork) and they are offered to
631 /// that session only. Cheap to clone; the connections are shared. When the
783 -/// last clone goes away the stdio children's stdin closes and they are killed;
784 -/// an HTTP server holds nothing open between calls.
632 +/// last clone goes away the connections close, which stops stdio children.
633 #[derive(Clone, Default)]
634 pub struct SessionServers(Arc<Vec<ServerConn>>);
635
@@ -817,7 +665,6 @@ pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServe
665 return SessionServers::default();
666 }
667
820 - let http = MCP.get().map(|mcp| mcp.http.clone()).unwrap_or_default();
668 let mut taken: Vec<String> = MCP
669 .get()
670 .map(|mcp| mcp.servers.iter().map(|s| s.name.clone()).collect())
@@ -836,13 +683,17 @@ pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServe
683 if name.is_empty() || taken.contains(&name) {
684 clashes.push(ServerConn {
685 endpoint: transport.endpoint(),
839 - transport: None,
840 - tools: Vec::new(),
686 error: Some(if name.is_empty() {
687 "the client sent a server with no name".to_string()
688 } else {
689 format!("another MCP server is already named '{name}'")
690 }),
691 + def: ServerDef {
692 + name: name.clone(),
693 + transport,
694 + },
695 + conn: RwLock::new(None),
696 + tools: Vec::new(),
697 name,
698 });
699 continue;
@@ -851,11 +702,7 @@ pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServe
702 defs.push(ServerDef { name, transport });
703 }
704
854 - let connects = defs.into_iter().map(|def| {
855 - let http = http.clone();
856 - async move { connect(&http, def).await }
857 - });
858 - let mut connected = futures::future::join_all(connects).await;
705 + let mut connected = futures::future::join_all(defs.into_iter().map(connect)).await;
706 connected.extend(clashes);
707
708 for server in &connected {
@@ -943,68 +790,59 @@ pub async fn call_tool(full_name: &str, arguments: &str) -> String {
790 }
791 };
792
946 - let http = MCP.get().map(|mcp| mcp.http.clone()).unwrap_or_default();
947 - match call_server(&http, server, &tool.remote_name, args).await {
793 + match call_server(server, &tool.remote_name, args).await {
794 Ok(text) => truncate(text),
795 Err(error) => format!("Error: {error}"),
796 }
797 }
798
953 -/// Send a `tools/call` and render the result into text. On HTTP, retries
954 -/// once after a re-`initialize` if the session was dropped (HTTP 404),
955 -/// which is how Streamable HTTP signals an expired session.
799 +/// Send a `tools/call` and render the result into text. An HTTP server whose
800 +/// call fails for any reason but a timeout is reconnected and the call retried
801 +/// once: that is how a restarted server or an expired session recovers.
802 async fn call_server(
957 - http: &reqwest::Client,
803 server: &ServerConn,
804 remote_name: &str,
805 args: Value,
806 ) -> Result<String, String> {
962 - let params = json!({ "name": remote_name, "arguments": args });
963 - let result = match &server.transport {
964 - None => return Err(format!("server '{}' is not connected", server.name)),
965 - Some(Transport::Stdio(stdio)) => {
966 - let timeout = if server.name == "xcode" {
967 - XCODE_CALL_TIMEOUT
968 - } else {
969 - CALL_TIMEOUT
970 - };
971 - stdio.request("tools/call", params, timeout).await?
807 + let timeout = if server.name == "xcode" {
808 + XCODE_CALL_TIMEOUT
809 + } else {
810 + CALL_TIMEOUT
811 + };
812 + let kind = if server.def.is_stdio() {
813 + "stdio server"
814 + } else {
815 + "server"
816 + };
817 + let Some(conn) = server.conn.read().await.clone() else {
818 + return Err(format!("{kind} '{}' is not connected", server.name));
819 + };
820 + let result = match conn.call_tool(remote_name, args.clone(), timeout).await {
821 + Ok(result) => result,
822 + Err(error) if server.def.is_stdio() || error.to_string().contains("timed out") => {
823 + return Err(format!("{kind} '{}': {error:#}", server.name));
824 }
973 - Some(Transport::Http(http_conn)) => {
974 - let body = json!({
975 - "jsonrpc": "2.0",
976 - "id": 0,
977 - "method": "tools/call",
978 - "params": params
979 - });
980 - let timeout = if server.name == "xcode" {
981 - XCODE_CALL_TIMEOUT
982 - } else {
983 - CALL_TIMEOUT
984 - };
985 - match post_rpc(http, &server.name, http_conn, &body, timeout).await {
986 - Ok(result) => result,
987 - Err(error) if error.contains("returned 404") => {
988 - // Session expired — drop it, re-handshake, and retry once.
989 - *http_conn.session_id.lock().await = None;
990 - initialize(http, server).await?;
991 - notify_initialized(http, server).await?;
992 - post_rpc(http, &server.name, http_conn, &body, timeout).await?
993 - }
994 - Err(error) => return Err(error),
995 - }
825 + Err(error) => {
826 + log::info!("mcp: '{}' failed ({error:#}); reconnecting", server.name);
827 + let fresh = Connection::connect(&server.def.spec(), &client_info(), HANDSHAKE_TIMEOUT)
828 + .await
829 + .map(Arc::new)
830 + .map_err(|again| {
831 + format!(
832 + "server '{}': {error:#}; reconnecting failed: {again:#}",
833 + server.name
834 + )
835 + })?;
836 + *server.conn.write().await = Some(fresh.clone());
837 + fresh
838 + .call_tool(remote_name, args, timeout)
839 + .await
840 + .map_err(|error| format!("server '{}': {error:#}", server.name))?
841 }
842 };
998 -
843 Ok(render_tool_result(&result))
844 }
845
1002 -impl Mcp {
1003 - fn next_id(&self) -> i64 {
1004 - self.next_id.fetch_add(1, Ordering::Relaxed)
1005 - }
1006 -}
1007 -
846 /// Flatten an MCP `tools/call` result into text. Joins text content blocks;
847 /// notes non-text blocks; honors `isError`.
848 fn render_tool_result(result: &Value) -> String {
@@ -1059,422 +897,6 @@ fn truncate(text: String) -> String {
897 format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]")
898 }
899
1062 -// ── Transport-generic JSON-RPC dispatch ─────────────────────────────────────
1063 -
1064 -/// Send a JSON-RPC request over whichever transport the server uses and return
1065 -/// its `result`.
1066 -async fn rpc_request(
1067 - http: &reqwest::Client,
1068 - conn: &ServerConn,
1069 - method: &str,
1070 - params: Value,
1071 - timeout: Duration,
1072 -) -> Result<Value, String> {
1073 - match &conn.transport {
1074 - None => Err(format!("server '{}' is not connected", conn.name)),
1075 - Some(Transport::Http(http_conn)) => {
1076 - let body = json!({ "jsonrpc": "2.0", "id": 0, "method": method, "params": params });
1077 - post_rpc(http, &conn.name, http_conn, &body, timeout).await
1078 - }
1079 - Some(Transport::Stdio(stdio)) => stdio.request(method, params, timeout).await,
1080 - }
1081 -}
1082 -
1083 -/// Send a JSON-RPC notification (no id, no response expected).
1084 -async fn rpc_notify(
1085 - http: &reqwest::Client,
1086 - conn: &ServerConn,
1087 - method: &str,
1088 - timeout: Duration,
1089 -) -> Result<(), String> {
1090 - match &conn.transport {
1091 - None => Err(format!("server '{}' is not connected", conn.name)),
1092 - Some(Transport::Http(http_conn)) => {
1093 - let body = json!({ "jsonrpc": "2.0", "method": method });
1094 - post_notification(http, &conn.name, http_conn, &body, timeout).await
1095 - }
1096 - Some(Transport::Stdio(stdio)) => stdio.notify(method).await,
1097 - }
1098 -}
1099 -
1100 -// ── stdio JSON-RPC plumbing ─────────────────────────────────────────────────
1101 -
1102 -/// Spawn a stdio MCP server and start its background reader task. The child's
1103 -/// stderr is inherited so it lands in sigit's own log stream; the given env
1104 -/// vars are added on top of the inherited environment.
1105 -fn spawn_stdio(
1106 - name: &str,
1107 - command: &str,
1108 - args: &[String],
1109 - env: &[(String, String)],
1110 -) -> Result<StdioConn, String> {
1111 - let mut cmd = Command::new(command);
1112 - cmd.args(args)
1113 - .stdin(std::process::Stdio::piped())
1114 - .stdout(std::process::Stdio::piped())
1115 - .stderr(std::process::Stdio::inherit())
1116 - .kill_on_drop(true);
1117 - for (key, value) in env {
1118 - cmd.env(key, value);
1119 - }
1120 - let mut child = cmd
1121 - .spawn()
1122 - .map_err(|error| format!("failed to spawn `{command}`: {error}"))?;
1123 - let stdin = child
1124 - .stdin
1125 - .take()
1126 - .ok_or_else(|| "child stdin was not captured".to_string())?;
1127 - let stdout = child
1128 - .stdout
1129 - .take()
1130 - .ok_or_else(|| "child stdout was not captured".to_string())?;
1131 -
1132 - let shared = Arc::new(StdioShared {
1133 - name: name.to_string(),
1134 - pending: StdMutex::new(HashMap::new()),
1135 - dead: StdMutex::new(None),
1136 - child: StdMutex::new(Some(child)),
1137 - });
1138 - tokio::spawn(stdio_reader(BufReader::new(stdout), Arc::clone(&shared)));
1139 -
1140 - Ok(StdioConn {
1141 - writer: Mutex::new(Some(stdin)),
1142 - shared,
1143 - next_id: AtomicI64::new(1),
1144 - })
1145 -}
1146 -
1147 -/// Background task owning a stdio child's stdout: parses one JSON-RPC message
1148 -/// per line and routes each response to the pending request that carries its
1149 -/// id. Server-initiated requests and notifications (anything with a `method`)
1150 -/// are logged and ignored — siGit doesn't support server→client calls. On EOF
1151 -/// or a read error the connection is marked dead, which fails every in-flight
1152 -/// request and reaps the child.
1153 -async fn stdio_reader(mut stdout: BufReader<ChildStdout>, shared: Arc<StdioShared>) {
1154 - let mut line = String::new();
1155 - loop {
1156 - line.clear();
1157 - match stdout.read_line(&mut line).await {
1158 - Ok(0) => {
1159 - shared.mark_dead("server closed its stdout (process exited)");
1160 - return;
1161 - }
1162 - Ok(_) => {}
1163 - Err(error) => {
1164 - shared.mark_dead(&format!("read error: {error}"));
1165 - return;
1166 - }
1167 - }
1168 - let trimmed = line.trim();
1169 - if trimmed.is_empty() {
1170 - continue;
1171 - }
1172 - let message: Value = match serde_json::from_str(trimmed) {
1173 - Ok(message) => message,
1174 - Err(error) => {
1175 - log::warn!("mcp: '{}' sent a non-JSON line: {error}", shared.name);
1176 - continue;
1177 - }
1178 - };
1179 - if let Some(method) = message.get("method").and_then(Value::as_str) {
1180 - log::debug!(
1181 - "mcp: ignoring server-initiated '{method}' from '{}'",
1182 - shared.name
1183 - );
1184 - continue;
1185 - }
1186 - let Some(id) = message
1187 - .get("id")
1188 - .and_then(|value| value.as_i64().or_else(|| value.as_str()?.parse().ok()))
1189 - else {
1190 - log::warn!(
1191 - "mcp: '{}' sent a response without a usable id; ignoring",
1192 - shared.name
1193 - );
1194 - continue;
1195 - };
1196 - let waiter = shared.pending.lock().unwrap().remove(&id);
1197 - match waiter {
1198 - Some(sender) => {
1199 - let _ = sender.send(message);
1200 - }
1201 - None => log::debug!(
1202 - "mcp: '{}' answered unknown/expired request id {id}; ignoring",
1203 - shared.name
1204 - ),
1205 - }
1206 - }
1207 -}
1208 -
1209 -impl Drop for StdioConn {
1210 - /// A connection nobody holds any more has no use for its child. Startup
1211 - /// servers live in a static and never get here; a session's servers do
1212 - /// once the session that brought them is replaced.
1213 - fn drop(&mut self) {
1214 - // `mark_dead` reaps on a spawned task, which needs a runtime.
1215 - if tokio::runtime::Handle::try_current().is_ok() {
1216 - self.shared.mark_dead("connection closed");
1217 - }
1218 - }
1219 -}
1220 -
1221 -impl StdioConn {
1222 - /// Send a JSON-RPC request and await its response, correlated by id. Fails
1223 - /// fast (in-band, never panicking) when the child has died.
1224 - async fn request(
1225 - &self,
1226 - method: &str,
1227 - params: Value,
1228 - timeout: Duration,
1229 - ) -> Result<Value, String> {
1230 - let name = &self.shared.name;
1231 - if let Some(reason) = self.shared.dead_reason() {
1232 - return Err(format!("stdio server '{name}' is not running: {reason}"));
1233 - }
1234 -
1235 - let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1236 - let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
1237 - let (sender, receiver) = oneshot::channel();
1238 - self.shared.pending.lock().unwrap().insert(id, sender);
1239 -
1240 - if let Err(error) = self.write_line(&body).await {
1241 - self.shared.pending.lock().unwrap().remove(&id);
1242 - self.shared.mark_dead(&error);
1243 - return Err(format!("stdio server '{name}': {error}"));
1244 - }
1245 -
1246 - let message = match tokio::time::timeout(timeout, receiver).await {
1247 - Ok(Ok(message)) => message,
1248 - // Our sender was dropped: the connection died mid-request.
1249 - Ok(Err(_)) => {
1250 - let reason = self
1251 - .shared
1252 - .dead_reason()
1253 - .unwrap_or_else(|| "connection closed".to_string());
1254 - return Err(format!("stdio server '{name}' is not running: {reason}"));
1255 - }
1256 - Err(_) => {
1257 - self.shared.pending.lock().unwrap().remove(&id);
1258 - return Err(format!(
1259 - "request to stdio server '{name}' timed out after {}s",
1260 - timeout.as_secs()
1261 - ));
1262 - }
1263 - };
1264 -
1265 - if let Some(error) = message.get("error") {
1266 - let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
1267 - let msg = error
1268 - .get("message")
1269 - .and_then(Value::as_str)
1270 - .unwrap_or("unknown error");
1271 - return Err(format!("'{name}' JSON-RPC error {code}: {msg}"));
1272 - }
1273 - message
1274 - .get("result")
1275 - .cloned()
1276 - .ok_or_else(|| format!("response from '{name}' had no result"))
1277 - }
1278 -
1279 - /// Send a JSON-RPC notification (no id, no response).
1280 - async fn notify(&self, method: &str) -> Result<(), String> {
1281 - let body = json!({ "jsonrpc": "2.0", "method": method });
1282 - if let Err(error) = self.write_line(&body).await {
1283 - self.shared.mark_dead(&error);
1284 - return Err(format!("stdio server '{}': {error}", self.shared.name));
1285 - }
1286 - Ok(())
1287 - }
1288 -
1289 - /// Write one newline-delimited JSON-RPC message. The writer mutex keeps
1290 - /// concurrent requests from interleaving bytes on the pipe.
1291 - async fn write_line(&self, body: &Value) -> Result<(), String> {
1292 - let mut guard = self.writer.lock().await;
1293 - let Some(writer) = guard.as_mut() else {
1294 - return Err("stdin already closed".to_string());
1295 - };
1296 - let mut line = body.to_string();
1297 - line.push('\n');
1298 - let result = async {
1299 - writer.write_all(line.as_bytes()).await?;
1300 - writer.flush().await
1301 - }
1302 - .await;
1303 - if let Err(error) = result {
1304 - // A broken pipe is unrecoverable; drop the writer so later calls
1305 - // fail fast.
1306 - *guard = None;
1307 - return Err(format!("write failed: {error}"));
1308 - }
1309 - Ok(())
1310 - }
1311 -}
1312 -
1313 -// ── Streamable HTTP JSON-RPC plumbing ───────────────────────────────────────
1314 -
1315 -/// POST a JSON-RPC request and return its `result`. Handles both an
1316 -/// `application/json` body and a `text/event-stream` (SSE) reply, captures the
1317 -/// session id from the response headers, and maps a JSON-RPC `error` to `Err`.
1318 -async fn post_rpc(
1319 - http: &reqwest::Client,
1320 - name: &str,
1321 - conn: &HttpConn,
1322 - body: &Value,
1323 - timeout: Duration,
1324 -) -> Result<Value, String> {
1325 - // Give every outbound request a fresh id; the on-the-wire id in `body` is a
1326 - // placeholder we overwrite so callers don't have to thread a counter.
1327 - let mut body = body.clone();
1328 - if body.get("id").is_some()
1329 - && let Some(mcp) = MCP.get()
1330 - {
1331 - body["id"] = json!(mcp.next_id());
1332 - }
1333 -
1334 - let response = build_request(http, conn, &body, timeout)
1335 - .await
1336 - .send()
1337 - .await
1338 - .map_err(|error| format!("request to {} failed: {error}", conn.url))?;
1339 -
1340 - // Persist the session id the server assigns on initialize.
1341 - if let Some(session) = response
1342 - .headers()
1343 - .get("mcp-session-id")
1344 - .and_then(|value| value.to_str().ok())
1345 - .map(str::to_string)
1346 - {
1347 - *conn.session_id.lock().await = Some(session);
1348 - }
1349 -
1350 - let status = response.status();
1351 - let content_type = response
1352 - .headers()
1353 - .get(reqwest::header::CONTENT_TYPE)
1354 - .and_then(|value| value.to_str().ok())
1355 - .unwrap_or("")
1356 - .to_string();
1357 -
1358 - if !status.is_success() {
1359 - let detail = response.text().await.unwrap_or_default();
1360 - let detail: String = detail.chars().take(500).collect();
1361 - return Err(format!(
1362 - "server '{name}' returned {}: {detail}",
1363 - status.as_u16()
1364 - ));
1365 - }
1366 -
1367 - let text = response
1368 - .text()
1369 - .await
1370 - .map_err(|error| format!("reading response from '{name}': {error}"))?;
1371 -
1372 - let message = if content_type.contains("text/event-stream") {
1373 - parse_sse_response(&text)
1374 - .ok_or_else(|| format!("no JSON-RPC message in SSE reply from '{name}'"))?
1375 - } else {
1376 - serde_json::from_str::<Value>(&text)
1377 - .map_err(|error| format!("parsing response from '{name}': {error}"))?
1378 - };
1379 -
1380 - if let Some(error) = message.get("error") {
1381 - let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
1382 - let msg = error
1383 - .get("message")
1384 - .and_then(Value::as_str)
1385 - .unwrap_or("unknown error");
1386 - return Err(format!("'{name}' JSON-RPC error {code}: {msg}"));
1387 - }
1388 -
1389 - message
1390 - .get("result")
1391 - .cloned()
1392 - .ok_or_else(|| format!("response from '{name}' had no result"))
1393 -}
1394 -
1395 -/// POST a JSON-RPC notification (no id, no response expected). A non-success
1396 -/// status is an error; an empty 202 body is the normal case.
1397 -async fn post_notification(
1398 - http: &reqwest::Client,
1399 - name: &str,
1400 - conn: &HttpConn,
1401 - body: &Value,
1402 - timeout: Duration,
1403 -) -> Result<(), String> {
1404 - let response = build_request(http, conn, body, timeout)
1405 - .await
1406 - .send()
1407 - .await
1408 - .map_err(|error| format!("notification to {} failed: {error}", conn.url))?;
1409 - if !response.status().is_success() {
1410 - return Err(format!(
1411 - "server '{name}' rejected notification: {}",
1412 - response.status().as_u16()
1413 - ));
1414 - }
1415 - Ok(())
1416 -}
1417 -
1418 -/// Build a request carrying the MCP headers: the dual `Accept`, the JSON body,
1419 -/// the configured static headers, the negotiated protocol version, and the
1420 -/// session id once we have one.
1421 -async fn build_request(
1422 - http: &reqwest::Client,
1423 - conn: &HttpConn,
1424 - body: &Value,
1425 - timeout: Duration,
1426 -) -> reqwest::RequestBuilder {
1427 - let mut request = http
1428 - .post(&conn.url)
1429 - .timeout(timeout)
1430 - .header(
1431 - reqwest::header::ACCEPT,
1432 - "application/json, text/event-stream",
1433 - )
1434 - .header("MCP-Protocol-Version", PROTOCOL_VERSION)
1435 - .json(body);
1436 -
1437 - for (key, value) in &conn.headers {
1438 - request = request.header(key.as_str(), value.as_str());
1439 - }
1440 - if let Some(session) = conn.session_id.lock().await.as_ref() {
1441 - request = request.header("Mcp-Session-Id", session.as_str());
1442 - }
1443 - request
1444 -}
1445 -
1446 -/// Extract the first JSON-RPC message from an SSE body. SSE frames are separated
1447 -/// by blank lines; each `data:` line contributes to the frame's payload. For a
1448 -/// single request/response exchange the server sends one `message` event whose
1449 -/// data is the JSON-RPC response.
1450 -fn parse_sse_response(body: &str) -> Option<Value> {
1451 - let mut data = String::new();
1452 - for line in body.lines() {
1453 - if let Some(rest) = line.strip_prefix("data:") {
1454 - if !data.is_empty() {
1455 - data.push('\n');
1456 - }
1457 - data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
1458 - } else if line.trim().is_empty() && !data.is_empty() {
1459 - // End of an event — try to parse it as a JSON-RPC message.
1460 - if let Ok(value) = serde_json::from_str::<Value>(&data)
1461 - && (value.get("result").is_some() || value.get("error").is_some())
1462 - {
1463 - return Some(value);
1464 - }
1465 - data.clear();
1466 - }
1467 - }
1468 - // Trailing event without a closing blank line.
1469 - if !data.is_empty()
1470 - && let Ok(value) = serde_json::from_str::<Value>(&data)
1471 - && (value.get("result").is_some() || value.get("error").is_some())
1472 - {
1473 - return Some(value);
1474 - }
1475 - None
1476 -}
1477 -
900 // ── Status reporting (`/mcp`) ────────────────────────────────────────────────
901
902 /// Human-readable summary of configured MCP servers and their tools, for the
@@ -1747,27 +1169,6 @@ mod tests {
1169 assert_eq!(defs[0].transport.endpoint(), "u2");
1170 }
1171
1750 - #[test]
1751 - fn parse_sse_extracts_jsonrpc_response() {
1752 - let body =
1753 - "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n";
1754 - let value = parse_sse_response(body).expect("a message");
1755 - assert_eq!(value["result"]["ok"], json!(true));
1756 - }
1757 -
1758 - #[test]
1759 - fn parse_sse_handles_no_trailing_blank_line() {
1760 - let body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}";
1761 - assert!(parse_sse_response(body).is_some());
1762 - }
1763 -
1764 - #[test]
1765 - fn parse_sse_ignores_non_response_frames() {
1766 - // A lone notification (no result/error) shouldn't be mistaken for the response.
1767 - let body = "data: {\"jsonrpc\":\"2.0\",\"method\":\"ping\"}\n\n";
1768 - assert!(parse_sse_response(body).is_none());
1769 - }
1770 -
1172 #[test]
1173 fn render_result_joins_text_blocks() {
1174 let result = json!({