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.
8 //!
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 //! ## Baked-in servers
25 //!
26 //! siGit Code bakes in its official MCP server at `<cloud>/mcp` (default
27 //! `https://sigit.si/api/v1/mcp`, following `SIGIT_CLOUD_URL`). When the user is
28 //! signed in (`sigit login`) the cloud session token is sent as a bearer
29 //! credential.
30 //!
31 //! The smbCloud CLI's MCP server (`smb --mcp`, stdio) is also baked in, but
32 //! only when the `smb` binary is actually on `PATH` — no binary, no entry, no
33 //! error. Opt out with `smbcloud = false` in `mcp.toml` or
34 //! `SIGIT_MCP_SMBCLOUD=off`.
35 //!
36 //! Both baked-in entries yield to a user-defined `mcp.toml` server of the same
37 //! name, so either can be repointed or reconfigured without a special case.
38 //! Additional servers are configured in `mcp.toml` (see [`load_configs`]).
39 //!
40 //! ## Lifecycle
41 //!
42 //! Discovery is best-effort and happens once at startup via [`init`]: each
43 //! configured server is contacted concurrently (with a per-server timeout),
44 //! runs the `initialize` handshake, and has its `tools/list` cached. A server
45 //! that fails to connect is recorded with its error and simply contributes no
46 //! tools — it never blocks startup or the rest of the agent. The result is
47 //! stored in a process-global so the synchronous tool-spec builders
48 //! ([`tool_specs`]) and the async dispatch ([`call_tool`]) can both read it.
49 //!
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.
53 //! `/reload` does *not* re-run discovery ([`init`] is once-per-process), so a
54 //! changed `mcp.toml` or a dead server needs a sigit restart. At process exit
55 //! children see EOF on their stdin and exit on their own.
56 //!
57 //! Tools are namespaced `mcp__<server>__<tool>` so they never collide with
58 //! built-in tools or with each other across servers. This mirrors the
59 //! convention used by other MCP-aware agents.
60 //!
61 //! Like the rest of the backend seam, MCP is wired up only through the
62 //! interactive client and the ACP agent loop. On non-Unix targets a few helpers
63 //! are unused, so the dead-code lint is suppressed there only.
64 #![cfg_attr(not(unix), allow(dead_code))]
65
66 use std::collections::{BTreeMap, HashMap};
67 use std::path::PathBuf;
68 use std::sync::Mutex as StdMutex;
69 use std::sync::atomic::{AtomicI64, Ordering};
70 use std::sync::{Arc, OnceLock};
71 use std::time::Duration;
72
73 use serde::Deserialize;
74 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};
78
79 use crate::backend::ToolSpec;
80
81 /// Prefix marking a tool as MCP-provided. The full name is
82 /// `mcp__<server>__<tool>`.
83 pub const MCP_PREFIX: &str = "mcp__";
84
85 /// Name of the baked-in official siGit Code server; its tools are namespaced
86 /// `mcp__sigit__<tool>`. A user-defined `mcp.toml` entry with this name
87 /// overrides the baked-in URL/headers but keeps the namespace, so callers of
88 /// [`official_tool_name`] reach whatever the user pointed `sigit` at.
89 pub const OFFICIAL_SERVER_NAME: &str = "sigit";
90
91 /// The full namespaced name of a tool on the official server, e.g.
92 /// `official_tool_name("list_issues")` → `mcp__sigit__list_issues`.
93 pub fn official_tool_name(tool: &str) -> String {
94 format!("{MCP_PREFIX}{OFFICIAL_SERVER_NAME}__{tool}")
95 }
96
97 /// The bare tool name when `name` belongs to the official server
98 /// (`mcp__sigit__list_issues` → `Some("list_issues")`), else `None`.
99 pub fn official_tool_suffix(name: &str) -> Option<&str> {
100 name.strip_prefix(MCP_PREFIX)?
101 .strip_prefix(OFFICIAL_SERVER_NAME)?
102 .strip_prefix("__")
103 }
104
105 /// Name of the baked-in smbCloud CLI server; its tools are namespaced
106 /// `mcp__smbcloud__<tool>`. Like the official server, a user-defined `mcp.toml`
107 /// entry with this name overrides the baked-in command line.
108 const SMBCLOUD_SERVER_NAME: &str = "smbcloud";
109
110 /// The smbCloud CLI binary the baked-in stdio entry spawns (`smb --mcp`).
111 const SMBCLOUD_COMMAND: &str = "smb";
112
113 /// The bare tool name when `name` belongs to the smbCloud server
114 /// (`mcp__smbcloud__project_list` → `Some("project_list")`), else `None`.
115 pub fn smbcloud_tool_suffix(name: &str) -> Option<&str> {
116 name.strip_prefix(MCP_PREFIX)?
117 .strip_prefix(SMBCLOUD_SERVER_NAME)?
118 .strip_prefix("__")
119 }
120
121 /// JSON-RPC / MCP protocol version we advertise in the handshake.
122 const PROTOCOL_VERSION: &str = "2025-06-18";
123
124 /// Per-server budget for the connect + `initialize` + `tools/list` handshake at
125 /// startup. Bounds how long an unreachable server can delay startup; servers are
126 /// contacted concurrently, so this is the worst case for the whole set, not the
127 /// sum.
128 const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(8);
129
130 /// Overall request timeout for an individual `tools/call`. Generous for build
131 /// and test tools, while the Xcode bridge gets a shorter bound below because
132 /// it otherwise leaves an ACP prompt looking permanently busy when Xcode
133 /// cannot service the request.
134 const CALL_TIMEOUT: Duration = Duration::from_secs(120);
135 const XCODE_CALL_TIMEOUT: Duration = Duration::from_secs(30);
136
137 /// Cap on the characters returned from a single tool call, so a chatty server
138 /// can't blow up the model's context. Matches the spirit of the file-read cap.
139 const RESULT_CHAR_LIMIT: usize = 30_000;
140
141 // ── Public types ──────────────────────────────────────────────────────────────
142
143 /// A tool discovered on an MCP server, in siGit's flattened form.
144 #[derive(Debug, Clone)]
145 struct McpTool {
146 /// Namespaced name exposed to the model: `mcp__<server>__<tool>`.
147 full_name: String,
148 /// The tool's name as the server knows it (sent back in `tools/call`).
149 remote_name: String,
150 /// Human/model-facing description, prefixed with the server name.
151 description: String,
152 /// JSON Schema for the tool's arguments, encoded as a string.
153 parameters_schema: String,
154 }
155
156 /// A configured MCP server and its live connection state.
157 struct ServerConn {
158 /// Sanitized server name used in tool namespacing and the `/mcp` listing.
159 name: String,
160 /// Display endpoint for the `/mcp` listing: the URL for HTTP servers, the
161 /// command line for stdio servers.
162 endpoint: String,
163 /// The live transport. `None` when a stdio server failed to even spawn.
164 transport: Option<Transport>,
165 /// Tools discovered at startup. Empty when the server failed to connect.
166 tools: Vec<McpTool>,
167 /// Connection error, if the handshake failed. Surfaced by `/mcp`.
168 error: Option<String>,
169 }
170
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.
243 struct Mcp {
244 http: reqwest::Client,
245 servers: Vec<ServerConn>,
246 next_id: AtomicI64,
247 }
248
249 static MCP: OnceLock<Mcp> = OnceLock::new();
250
251 // ── Configuration ───────────────────────────────────────────────────────────
252
253 /// Default endpoint of the official siGit Code MCP server, derived from the
254 /// cloud base URL so `SIGIT_CLOUD_URL` (dev) carries over.
255 fn official_url() -> String {
256 format!(
257 "{}/mcp",
258 crate::provider::cloud_base_url().trim_end_matches('/')
259 )
260 }
261
262 /// A server entry as written in `mcp.toml`. Exactly one of `url` (Streamable
263 /// HTTP) or `command` (stdio) selects the transport.
264 #[derive(Debug, Deserialize)]
265 struct ServerEntry {
266 name: String,
267 /// Streamable HTTP endpoint. Mutually exclusive with `command`.
268 #[serde(default)]
269 url: Option<String>,
270 /// stdio server executable. Mutually exclusive with `url`.
271 #[serde(default)]
272 command: Option<String>,
273 /// Arguments for `command`.
274 #[serde(default)]
275 args: Vec<String>,
276 /// Extra environment variables for `command`, added on top of the
277 /// inherited environment.
278 #[serde(default)]
279 env: BTreeMap<String, String>,
280 /// Set `enabled = false` to keep an entry in the file but skip connecting.
281 #[serde(default)]
282 enabled: Option<bool>,
283 /// Static headers, e.g. `Authorization = "Bearer ..."`. HTTP only.
284 #[serde(default)]
285 headers: BTreeMap<String, String>,
286 }
287
288 impl ServerEntry {
289 /// Resolve the entry's transport. `url` and `command` are mutually
290 /// exclusive and exactly one is required; anything else is a config error.
291 fn transport_def(&self) -> Result<TransportDef, String> {
292 let url = self.url.as_deref().map(str::trim).filter(|v| !v.is_empty());
293 let command = self
294 .command
295 .as_deref()
296 .map(str::trim)
297 .filter(|v| !v.is_empty());
298 match (url, command) {
299 (Some(_), Some(_)) => {
300 Err("has both `url` and `command`; a server uses exactly one transport".to_string())
301 }
302 (None, None) => {
303 Err("needs either `url` (Streamable HTTP) or `command` (stdio)".to_string())
304 }
305 (Some(url), None) => Ok(TransportDef::Http {
306 url: url.to_string(),
307 headers: self
308 .headers
309 .iter()
310 .map(|(k, v)| (k.clone(), v.clone()))
311 .collect(),
312 }),
313 (None, Some(command)) => Ok(TransportDef::Stdio {
314 command: command.to_string(),
315 args: self.args.clone(),
316 env: self
317 .env
318 .iter()
319 .map(|(k, v)| (k.clone(), v.clone()))
320 .collect(),
321 }),
322 }
323 }
324 }
325
326 /// The `mcp.toml` schema.
327 #[derive(Debug, Default, Deserialize)]
328 struct McpFile {
329 /// Include the baked-in official server. Defaults to `true`; set `false` to
330 /// opt out.
331 #[serde(default)]
332 official: Option<bool>,
333 /// Include the baked-in smbCloud CLI server (`smb --mcp`). Defaults to
334 /// `true`; set `false` to opt out. Moot when `smb` isn't installed.
335 #[serde(default)]
336 smbcloud: Option<bool>,
337 #[serde(default)]
338 server: Vec<ServerEntry>,
339 }
340
341 /// How to reach a configured server, before connecting.
342 #[derive(Debug, Clone)]
343 enum TransportDef {
344 Http {
345 url: String,
346 headers: Vec<(String, String)>,
347 },
348 Stdio {
349 command: String,
350 args: Vec<String>,
351 env: Vec<(String, String)>,
352 },
353 }
354
355 impl TransportDef {
356 /// Human-readable endpoint for logs and the `/mcp` listing: the URL for
357 /// HTTP, the command line for stdio.
358 fn endpoint(&self) -> String {
359 match self {
360 TransportDef::Http { url, .. } => url.clone(),
361 TransportDef::Stdio { command, args, .. } => {
362 let mut line = command.clone();
363 for arg in args {
364 line.push(' ');
365 line.push_str(arg);
366 }
367 line
368 }
369 }
370 }
371 }
372
373 /// A resolved server definition, before connecting.
374 #[derive(Debug, Clone)]
375 struct ServerDef {
376 name: String,
377 transport: TransportDef,
378 }
379
380 /// Config files to read, in priority order (later wins on a name clash):
381 /// global `$SIGIT_CONFIG_DIR/mcp.toml`, then project-local `<cwd>/.sigit/mcp.toml`.
382 fn config_paths() -> Vec<PathBuf> {
383 let mut paths = Vec::new();
384 if let Some(dir) = sigit_config_dir() {
385 paths.push(dir.join("mcp.toml"));
386 }
387 if let Ok(cwd) = std::env::current_dir() {
388 paths.push(cwd.join(".sigit").join("mcp.toml"));
389 }
390 paths
391 }
392
393 fn sigit_config_dir() -> Option<PathBuf> {
394 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
395 && !dir.is_empty()
396 {
397 return Some(PathBuf::from(dir));
398 }
399 std::env::var("HOME")
400 .ok()
401 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
402 }
403
404 /// Resolve the full set of servers to connect to: the baked-in official server
405 /// (unless opted out) plus any from `mcp.toml`. Project-local entries override
406 /// global ones, and a user entry named `sigit` overrides the official default.
407 fn load_configs() -> Vec<ServerDef> {
408 // Global escape hatch: `SIGIT_MCP=off` disables MCP entirely.
409 if let Ok(value) = std::env::var("SIGIT_MCP")
410 && matches!(
411 value.trim().to_ascii_lowercase().as_str(),
412 "off" | "0" | "false" | "no" | "disabled"
413 )
414 {
415 log::info!("mcp: disabled via SIGIT_MCP");
416 return Vec::new();
417 }
418
419 let mut include_official = true;
420 let mut include_smbcloud = true;
421 // De-duplicated by sanitized name; a later config file overrides an earlier
422 // one for the same name (project-local wins over global).
423 let mut defs: Vec<ServerDef> = Vec::new();
424
425 for path in config_paths() {
426 let Ok(contents) = std::fs::read_to_string(&path) else {
427 continue;
428 };
429 let parsed: McpFile = match toml::from_str(&contents) {
430 Ok(parsed) => parsed,
431 Err(error) => {
432 log::warn!("mcp: ignoring {}: {error}", path.display());
433 continue;
434 }
435 };
436 if let Some(official) = parsed.official {
437 include_official = official;
438 }
439 if let Some(smbcloud) = parsed.smbcloud {
440 include_smbcloud = smbcloud;
441 }
442 for entry in parsed.server {
443 if entry.enabled == Some(false) {
444 continue;
445 }
446 let name = sanitize(&entry.name);
447 if name.is_empty() {
448 log::warn!("mcp: skipping server with empty name in {}", path.display());
449 continue;
450 }
451 let transport = match entry.transport_def() {
452 Ok(transport) => transport,
453 Err(error) => {
454 log::warn!(
455 "mcp: skipping server '{name}' in {}: {error}",
456 path.display()
457 );
458 continue;
459 }
460 };
461 upsert(&mut defs, ServerDef { name, transport });
462 }
463 }
464
465 // The official server can also be disabled with SIGIT_MCP_OFFICIAL=off.
466 if let Ok(value) = std::env::var("SIGIT_MCP_OFFICIAL")
467 && matches!(
468 value.trim().to_ascii_lowercase().as_str(),
469 "off" | "0" | "false" | "no"
470 )
471 {
472 include_official = false;
473 }
474
475 // Add the baked-in official server, but never clobber a user-defined entry
476 // named `sigit` — an explicit config (e.g. a custom URL or headers) wins.
477 if include_official && !defs.iter().any(|d| d.name == OFFICIAL_SERVER_NAME) {
478 let mut headers = Vec::new();
479 if let Some(token) = crate::credentials::load_token() {
480 headers.push(("Authorization".to_string(), format!("Bearer {token}")));
481 }
482 defs.push(ServerDef {
483 name: OFFICIAL_SERVER_NAME.to_string(),
484 transport: TransportDef::Http {
485 url: official_url(),
486 headers,
487 },
488 });
489 }
490
491 // The smbCloud server can also be disabled with SIGIT_MCP_SMBCLOUD=off.
492 if let Ok(value) = std::env::var("SIGIT_MCP_SMBCLOUD")
493 && matches!(
494 value.trim().to_ascii_lowercase().as_str(),
495 "off" | "0" | "false" | "no"
496 )
497 {
498 include_smbcloud = false;
499 }
500
501 // Add the baked-in smbCloud CLI server, with the same never-clobber rule as
502 // the official one. Only when `smb` is actually installed: a hardwired
503 // entry for a binary most users don't have would surface a spawn failure
504 // in `/mcp` instead of just staying out of the way.
505 if include_smbcloud && !defs.iter().any(|d| d.name == SMBCLOUD_SERVER_NAME) {
506 if on_path(SMBCLOUD_COMMAND) {
507 defs.push(ServerDef {
508 name: SMBCLOUD_SERVER_NAME.to_string(),
509 transport: TransportDef::Stdio {
510 command: SMBCLOUD_COMMAND.to_string(),
511 args: vec!["--mcp".to_string()],
512 env: Vec::new(),
513 },
514 });
515 } else {
516 log::debug!("mcp: `{SMBCLOUD_COMMAND}` not on PATH; skipping the smbcloud server");
517 }
518 }
519
520 defs
521 }
522
523 /// Whether `binary` resolves to an executable file on `PATH` (with the
524 /// platform's executable suffix, `.exe` on Windows).
525 fn on_path(binary: &str) -> bool {
526 let Some(path) = std::env::var_os("PATH") else {
527 return false;
528 };
529 let file = format!("{binary}{}", std::env::consts::EXE_SUFFIX);
530 std::env::split_paths(&path).any(|dir| !dir.as_os_str().is_empty() && dir.join(&file).is_file())
531 }
532
533 /// Insert `def`, replacing any existing entry with the same name.
534 fn upsert(defs: &mut Vec<ServerDef>, def: ServerDef) {
535 if let Some(slot) = defs.iter_mut().find(|d| d.name == def.name) {
536 *slot = def;
537 } else {
538 defs.push(def);
539 }
540 }
541
542 /// Sanitize a name into the `[a-zA-Z0-9_-]` set tool names are restricted to,
543 /// collapsing anything else to `_`.
544 fn sanitize(raw: &str) -> String {
545 raw.trim()
546 .chars()
547 .map(|c| {
548 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
549 c
550 } else {
551 '_'
552 }
553 })
554 .collect()
555 }
556
557 // ── Startup / discovery ─────────────────────────────────────────────────────
558
559 /// Connect to every configured server and cache the tools they expose. Idempotent
560 /// and best-effort: a server that can't be reached is recorded with its error and
561 /// contributes no tools. Safe to call from either entry point; only the first
562 /// call does work.
563 pub async fn init() {
564 if MCP.get().is_some() {
565 return;
566 }
567
568 let defs = load_configs();
569 let http = reqwest::Client::builder()
570 .timeout(CALL_TIMEOUT)
571 .user_agent(concat!(
572 "sigit/",
573 env!("CARGO_PKG_VERSION"),
574 " (mcp-client)"
575 ))
576 .build()
577 .unwrap_or_default();
578
579 // Contact servers concurrently so one slow/unreachable host doesn't serialize
580 // the rest. Each handshake is bounded by HANDSHAKE_TIMEOUT.
581 let connects = defs.into_iter().map(|def| {
582 let http = http.clone();
583 async move { connect(&http, def).await }
584 });
585 let servers = futures::future::join_all(connects).await;
586
587 for server in &servers {
588 match &server.error {
589 Some(error) => log::warn!("mcp: server '{}' unavailable: {error}", server.name),
590 None => log::info!(
591 "mcp: server '{}' ready, {} tool(s)",
592 server.name,
593 server.tools.len()
594 ),
595 }
596 }
597
598 let _ = MCP.set(Mcp {
599 http,
600 servers,
601 next_id: AtomicI64::new(1),
602 });
603 }
604
605 /// Run the handshake against one server and collect its tools. Always returns a
606 /// `ServerConn`; failures land in its `error` field rather than propagating.
607 async fn connect(http: &reqwest::Client, def: ServerDef) -> ServerConn {
608 let endpoint = def.transport.endpoint();
609 let transport = match &def.transport {
610 TransportDef::Http { url, headers } => Transport::Http(HttpConn {
611 url: url.clone(),
612 headers: headers.clone(),
613 session_id: Mutex::new(None),
614 }),
615 TransportDef::Stdio { command, args, env } => {
616 match spawn_stdio(&def.name, command, args, env) {
617 Ok(conn) => Transport::Stdio(conn),
618 Err(error) => {
619 return ServerConn {
620 name: def.name,
621 endpoint,
622 transport: None,
623 tools: Vec::new(),
624 error: Some(error),
625 };
626 }
627 }
628 }
629 };
630
631 let mut conn = ServerConn {
632 name: def.name,
633 endpoint,
634 transport: Some(transport),
635 tools: Vec::new(),
636 error: None,
637 };
638
639 let handshake = tokio::time::timeout(HANDSHAKE_TIMEOUT, async {
640 // initialize → notifications/initialized → tools/list
641 initialize(http, &conn).await?;
642 notify_initialized(http, &conn).await?;
643 list_tools(http, &conn).await
644 })
645 .await;
646
647 match handshake {
648 Ok(Ok(tools)) => conn.tools = tools,
649 Ok(Err(error)) => conn.error = Some(error),
650 Err(_) => conn.error = Some(format!("timed out after {}s", HANDSHAKE_TIMEOUT.as_secs())),
651 }
652
653 // A stdio child that failed its handshake is useless — kill it rather than
654 // leave it running for the rest of the process.
655 if let Some(error) = conn.error.clone()
656 && let Some(Transport::Stdio(stdio)) = &conn.transport
657 {
658 stdio.shared.mark_dead(&error);
659 }
660
661 conn
662 }
663
664 /// The `initialize` request: negotiate protocol version and (on HTTP) capture
665 /// the session id from the response headers (handled inside [`post_rpc`]).
666 async fn initialize(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
667 let params = json!({
668 "protocolVersion": PROTOCOL_VERSION,
669 "capabilities": {},
670 "clientInfo": { "name": "sigit", "version": env!("CARGO_PKG_VERSION") }
671 });
672 rpc_request(http, conn, "initialize", params, HANDSHAKE_TIMEOUT).await?;
673 Ok(())
674 }
675
676 /// The `notifications/initialized` notification. Servers expect it before
677 /// fielding requests; it carries no id and no response.
678 async fn notify_initialized(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
679 rpc_notify(http, conn, "notifications/initialized", HANDSHAKE_TIMEOUT).await
680 }
681
682 /// `tools/list`, following `nextCursor` pagination, mapped into [`McpTool`]s.
683 async fn list_tools(http: &reqwest::Client, conn: &ServerConn) -> Result<Vec<McpTool>, String> {
684 let mut tools = Vec::new();
685 let mut cursor: Option<String> = None;
686
687 loop {
688 let params = match &cursor {
689 Some(c) => json!({ "cursor": c }),
690 None => json!({}),
691 };
692 let result = rpc_request(http, conn, "tools/list", params, HANDSHAKE_TIMEOUT).await?;
693
694 for tool in result
695 .get("tools")
696 .and_then(Value::as_array)
697 .into_iter()
698 .flatten()
699 {
700 let Some(remote_name) = tool.get("name").and_then(Value::as_str) else {
701 continue;
702 };
703 let full_name = format!("{MCP_PREFIX}{}__{}", conn.name, sanitize(remote_name));
704 if full_name.chars().count() > 64 {
705 log::warn!(
706 "mcp: tool name '{full_name}' exceeds 64 chars; some backends may reject it"
707 );
708 }
709 let remote_desc = tool
710 .get("description")
711 .and_then(Value::as_str)
712 .unwrap_or("")
713 .trim();
714 let description = if remote_desc.is_empty() {
715 format!("[MCP server '{}'] {remote_name}", conn.name)
716 } else {
717 format!("[MCP server '{}'] {remote_desc}", conn.name)
718 };
719 // `inputSchema` is a JSON Schema object; default to a permissive
720 // object schema when a server omits it.
721 let parameters_schema = tool
722 .get("inputSchema")
723 .filter(|schema| schema.is_object())
724 .cloned()
725 .unwrap_or_else(|| json!({ "type": "object" }))
726 .to_string();
727
728 tools.push(McpTool {
729 full_name,
730 remote_name: remote_name.to_string(),
731 description,
732 parameters_schema,
733 });
734 }
735
736 cursor = result
737 .get("nextCursor")
738 .and_then(Value::as_str)
739 .map(str::to_string);
740 if cursor.is_none() {
741 break;
742 }
743 }
744
745 Ok(tools)
746 }
747
748 // ── Tool exposure + dispatch ────────────────────────────────────────────────
749
750 /// Whether a tool name belongs to MCP. The dispatch in `tools::execute_tool`
751 /// uses this to route a call here.
752 pub fn is_mcp_tool(name: &str) -> bool {
753 name.starts_with(MCP_PREFIX)
754 }
755
756 /// All discovered MCP tools as agent [`ToolSpec`]s, ready to append to the
757 /// built-in tool list. Empty when MCP is uninitialized or no server exposed any.
758 pub fn tool_specs() -> Vec<ToolSpec> {
759 let Some(mcp) = MCP.get() else {
760 return Vec::new();
761 };
762 let mut specs = Vec::new();
763 for server in &mcp.servers {
764 for tool in &server.tools {
765 specs.push(ToolSpec {
766 name: tool.full_name.clone(),
767 description: tool.description.clone(),
768 parameters_schema: tool.parameters_schema.clone(),
769 });
770 }
771 }
772 specs
773 }
774
775 /// Execute an MCP tool call by name, returning text to feed back to the model.
776 /// Errors are returned as plain strings (never panics) so a failing tool degrades
777 /// to a message the model can react to, exactly like the built-in tools.
778 pub async fn call_tool(full_name: &str, arguments: &str) -> String {
779 let Some(mcp) = MCP.get() else {
780 return "Error: MCP is not initialized.".to_string();
781 };
782
783 let Some((server, tool)) = mcp.servers.iter().find_map(|s| {
784 s.tools
785 .iter()
786 .find(|t| t.full_name == full_name)
787 .map(|t| (s, t))
788 }) else {
789 return format!("Error: unknown MCP tool \"{full_name}\".");
790 };
791
792 // Arguments arrive as a JSON-encoded string; an empty/blank string means no
793 // arguments. Anything that isn't a JSON object is a model mistake.
794 let args: Value = if arguments.trim().is_empty() {
795 json!({})
796 } else {
797 match serde_json::from_str(arguments) {
798 Ok(value @ Value::Object(_)) => value,
799 Ok(_) => return "Error: tool arguments must be a JSON object.".to_string(),
800 Err(error) => return format!("Error: failed to parse arguments: {error}"),
801 }
802 };
803
804 match mcp.call(server, &tool.remote_name, args).await {
805 Ok(text) => truncate(text),
806 Err(error) => format!("Error: {error}"),
807 }
808 }
809
810 impl Mcp {
811 /// Send a `tools/call` and render the result into text. On HTTP, retries
812 /// once after a re-`initialize` if the session was dropped (HTTP 404),
813 /// which is how Streamable HTTP signals an expired session.
814 async fn call(
815 &self,
816 server: &ServerConn,
817 remote_name: &str,
818 args: Value,
819 ) -> Result<String, String> {
820 let params = json!({ "name": remote_name, "arguments": args });
821 let result = match &server.transport {
822 None => return Err(format!("server '{}' is not connected", server.name)),
823 Some(Transport::Stdio(stdio)) => {
824 let timeout = if server.name == "xcode" {
825 XCODE_CALL_TIMEOUT
826 } else {
827 CALL_TIMEOUT
828 };
829 stdio.request("tools/call", params, timeout).await?
830 }
831 Some(Transport::Http(http_conn)) => {
832 let body = json!({
833 "jsonrpc": "2.0",
834 "id": 0,
835 "method": "tools/call",
836 "params": params
837 });
838 let timeout = if server.name == "xcode" {
839 XCODE_CALL_TIMEOUT
840 } else {
841 CALL_TIMEOUT
842 };
843 match post_rpc(&self.http, &server.name, http_conn, &body, timeout).await {
844 Ok(result) => result,
845 Err(error) if error.contains("returned 404") => {
846 // Session expired — drop it, re-handshake, and retry once.
847 *http_conn.session_id.lock().await = None;
848 initialize(&self.http, server).await?;
849 notify_initialized(&self.http, server).await?;
850 post_rpc(&self.http, &server.name, http_conn, &body, timeout).await?
851 }
852 Err(error) => return Err(error),
853 }
854 }
855 };
856
857 Ok(render_tool_result(&result))
858 }
859
860 fn next_id(&self) -> i64 {
861 self.next_id.fetch_add(1, Ordering::Relaxed)
862 }
863 }
864
865 /// Flatten an MCP `tools/call` result into text. Joins text content blocks;
866 /// notes non-text blocks; honors `isError`.
867 fn render_tool_result(result: &Value) -> String {
868 let mut out = String::new();
869 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
870 for block in blocks {
871 match block.get("type").and_then(Value::as_str) {
872 Some("text") => {
873 if let Some(text) = block.get("text").and_then(Value::as_str) {
874 if !out.is_empty() {
875 out.push('\n');
876 }
877 out.push_str(text);
878 }
879 }
880 Some(other) => {
881 if !out.is_empty() {
882 out.push('\n');
883 }
884 out.push_str(&format!("[{other} content omitted]"));
885 }
886 None => {}
887 }
888 }
889 }
890
891 // Some servers return only `structuredContent`; surface it if there was no
892 // textual content.
893 if out.is_empty()
894 && let Some(structured) = result.get("structuredContent")
895 {
896 out = structured.to_string();
897 }
898
899 if out.is_empty() {
900 out = "(tool returned no content)".to_string();
901 }
902
903 if result.get("isError").and_then(Value::as_bool) == Some(true) {
904 format!("Tool reported an error:\n{out}")
905 } else {
906 out
907 }
908 }
909
910 /// Truncate tool output to the context-protecting limit, with a trailing note.
911 fn truncate(text: String) -> String {
912 if text.chars().count() <= RESULT_CHAR_LIMIT {
913 return text;
914 }
915 let kept: String = text.chars().take(RESULT_CHAR_LIMIT).collect();
916 format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]")
917 }
918
919 // ── Transport-generic JSON-RPC dispatch ─────────────────────────────────────
920
921 /// Send a JSON-RPC request over whichever transport the server uses and return
922 /// its `result`.
923 async fn rpc_request(
924 http: &reqwest::Client,
925 conn: &ServerConn,
926 method: &str,
927 params: Value,
928 timeout: Duration,
929 ) -> Result<Value, String> {
930 match &conn.transport {
931 None => Err(format!("server '{}' is not connected", conn.name)),
932 Some(Transport::Http(http_conn)) => {
933 let body = json!({ "jsonrpc": "2.0", "id": 0, "method": method, "params": params });
934 post_rpc(http, &conn.name, http_conn, &body, timeout).await
935 }
936 Some(Transport::Stdio(stdio)) => stdio.request(method, params, timeout).await,
937 }
938 }
939
940 /// Send a JSON-RPC notification (no id, no response expected).
941 async fn rpc_notify(
942 http: &reqwest::Client,
943 conn: &ServerConn,
944 method: &str,
945 timeout: Duration,
946 ) -> Result<(), String> {
947 match &conn.transport {
948 None => Err(format!("server '{}' is not connected", conn.name)),
949 Some(Transport::Http(http_conn)) => {
950 let body = json!({ "jsonrpc": "2.0", "method": method });
951 post_notification(http, &conn.name, http_conn, &body, timeout).await
952 }
953 Some(Transport::Stdio(stdio)) => stdio.notify(method).await,
954 }
955 }
956
957 // ── stdio JSON-RPC plumbing ─────────────────────────────────────────────────
958
959 /// Spawn a stdio MCP server and start its background reader task. The child's
960 /// stderr is inherited so it lands in sigit's own log stream; the given env
961 /// vars are added on top of the inherited environment.
962 fn spawn_stdio(
963 name: &str,
964 command: &str,
965 args: &[String],
966 env: &[(String, String)],
967 ) -> Result<StdioConn, String> {
968 let mut cmd = Command::new(command);
969 cmd.args(args)
970 .stdin(std::process::Stdio::piped())
971 .stdout(std::process::Stdio::piped())
972 .stderr(std::process::Stdio::inherit())
973 .kill_on_drop(true);
974 for (key, value) in env {
975 cmd.env(key, value);
976 }
977 let mut child = cmd
978 .spawn()
979 .map_err(|error| format!("failed to spawn `{command}`: {error}"))?;
980 let stdin = child
981 .stdin
982 .take()
983 .ok_or_else(|| "child stdin was not captured".to_string())?;
984 let stdout = child
985 .stdout
986 .take()
987 .ok_or_else(|| "child stdout was not captured".to_string())?;
988
989 let shared = Arc::new(StdioShared {
990 name: name.to_string(),
991 pending: StdMutex::new(HashMap::new()),
992 dead: StdMutex::new(None),
993 child: StdMutex::new(Some(child)),
994 });
995 tokio::spawn(stdio_reader(BufReader::new(stdout), Arc::clone(&shared)));
996
997 Ok(StdioConn {
998 writer: Mutex::new(Some(stdin)),
999 shared,
1000 next_id: AtomicI64::new(1),
1001 })
1002 }
1003
1004 /// Background task owning a stdio child's stdout: parses one JSON-RPC message
1005 /// per line and routes each response to the pending request that carries its
1006 /// id. Server-initiated requests and notifications (anything with a `method`)
1007 /// are logged and ignored — siGit doesn't support server→client calls. On EOF
1008 /// or a read error the connection is marked dead, which fails every in-flight
1009 /// request and reaps the child.
1010 async fn stdio_reader(mut stdout: BufReader<ChildStdout>, shared: Arc<StdioShared>) {
1011 let mut line = String::new();
1012 loop {
1013 line.clear();
1014 match stdout.read_line(&mut line).await {
1015 Ok(0) => {
1016 shared.mark_dead("server closed its stdout (process exited)");
1017 return;
1018 }
1019 Ok(_) => {}
1020 Err(error) => {
1021 shared.mark_dead(&format!("read error: {error}"));
1022 return;
1023 }
1024 }
1025 let trimmed = line.trim();
1026 if trimmed.is_empty() {
1027 continue;
1028 }
1029 let message: Value = match serde_json::from_str(trimmed) {
1030 Ok(message) => message,
1031 Err(error) => {
1032 log::warn!("mcp: '{}' sent a non-JSON line: {error}", shared.name);
1033 continue;
1034 }
1035 };
1036 if let Some(method) = message.get("method").and_then(Value::as_str) {
1037 log::debug!(
1038 "mcp: ignoring server-initiated '{method}' from '{}'",
1039 shared.name
1040 );
1041 continue;
1042 }
1043 let Some(id) = message
1044 .get("id")
1045 .and_then(|value| value.as_i64().or_else(|| value.as_str()?.parse().ok()))
1046 else {
1047 log::warn!(
1048 "mcp: '{}' sent a response without a usable id; ignoring",
1049 shared.name
1050 );
1051 continue;
1052 };
1053 let waiter = shared.pending.lock().unwrap().remove(&id);
1054 match waiter {
1055 Some(sender) => {
1056 let _ = sender.send(message);
1057 }
1058 None => log::debug!(
1059 "mcp: '{}' answered unknown/expired request id {id}; ignoring",
1060 shared.name
1061 ),
1062 }
1063 }
1064 }
1065
1066 impl StdioConn {
1067 /// Send a JSON-RPC request and await its response, correlated by id. Fails
1068 /// fast (in-band, never panicking) when the child has died.
1069 async fn request(
1070 &self,
1071 method: &str,
1072 params: Value,
1073 timeout: Duration,
1074 ) -> Result<Value, String> {
1075 let name = &self.shared.name;
1076 if let Some(reason) = self.shared.dead_reason() {
1077 return Err(format!("stdio server '{name}' is not running: {reason}"));
1078 }
1079
1080 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1081 let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
1082 let (sender, receiver) = oneshot::channel();
1083 self.shared.pending.lock().unwrap().insert(id, sender);
1084
1085 if let Err(error) = self.write_line(&body).await {
1086 self.shared.pending.lock().unwrap().remove(&id);
1087 self.shared.mark_dead(&error);
1088 return Err(format!("stdio server '{name}': {error}"));
1089 }
1090
1091 let message = match tokio::time::timeout(timeout, receiver).await {
1092 Ok(Ok(message)) => message,
1093 // Our sender was dropped: the connection died mid-request.
1094 Ok(Err(_)) => {
1095 let reason = self
1096 .shared
1097 .dead_reason()
1098 .unwrap_or_else(|| "connection closed".to_string());
1099 return Err(format!("stdio server '{name}' is not running: {reason}"));
1100 }
1101 Err(_) => {
1102 self.shared.pending.lock().unwrap().remove(&id);
1103 return Err(format!(
1104 "request to stdio server '{name}' timed out after {}s",
1105 timeout.as_secs()
1106 ));
1107 }
1108 };
1109
1110 if let Some(error) = message.get("error") {
1111 let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
1112 let msg = error
1113 .get("message")
1114 .and_then(Value::as_str)
1115 .unwrap_or("unknown error");
1116 return Err(format!("'{name}' JSON-RPC error {code}: {msg}"));
1117 }
1118 message
1119 .get("result")
1120 .cloned()
1121 .ok_or_else(|| format!("response from '{name}' had no result"))
1122 }
1123
1124 /// Send a JSON-RPC notification (no id, no response).
1125 async fn notify(&self, method: &str) -> Result<(), String> {
1126 let body = json!({ "jsonrpc": "2.0", "method": method });
1127 if let Err(error) = self.write_line(&body).await {
1128 self.shared.mark_dead(&error);
1129 return Err(format!("stdio server '{}': {error}", self.shared.name));
1130 }
1131 Ok(())
1132 }
1133
1134 /// Write one newline-delimited JSON-RPC message. The writer mutex keeps
1135 /// concurrent requests from interleaving bytes on the pipe.
1136 async fn write_line(&self, body: &Value) -> Result<(), String> {
1137 let mut guard = self.writer.lock().await;
1138 let Some(writer) = guard.as_mut() else {
1139 return Err("stdin already closed".to_string());
1140 };
1141 let mut line = body.to_string();
1142 line.push('\n');
1143 let result = async {
1144 writer.write_all(line.as_bytes()).await?;
1145 writer.flush().await
1146 }
1147 .await;
1148 if let Err(error) = result {
1149 // A broken pipe is unrecoverable; drop the writer so later calls
1150 // fail fast.
1151 *guard = None;
1152 return Err(format!("write failed: {error}"));
1153 }
1154 Ok(())
1155 }
1156 }
1157
1158 // ── Streamable HTTP JSON-RPC plumbing ───────────────────────────────────────
1159
1160 /// POST a JSON-RPC request and return its `result`. Handles both an
1161 /// `application/json` body and a `text/event-stream` (SSE) reply, captures the
1162 /// session id from the response headers, and maps a JSON-RPC `error` to `Err`.
1163 async fn post_rpc(
1164 http: &reqwest::Client,
1165 name: &str,
1166 conn: &HttpConn,
1167 body: &Value,
1168 timeout: Duration,
1169 ) -> Result<Value, String> {
1170 // Give every outbound request a fresh id; the on-the-wire id in `body` is a
1171 // placeholder we overwrite so callers don't have to thread a counter.
1172 let mut body = body.clone();
1173 if body.get("id").is_some()
1174 && let Some(mcp) = MCP.get()
1175 {
1176 body["id"] = json!(mcp.next_id());
1177 }
1178
1179 let response = build_request(http, conn, &body, timeout)
1180 .await
1181 .send()
1182 .await
1183 .map_err(|error| format!("request to {} failed: {error}", conn.url))?;
1184
1185 // Persist the session id the server assigns on initialize.
1186 if let Some(session) = response
1187 .headers()
1188 .get("mcp-session-id")
1189 .and_then(|value| value.to_str().ok())
1190 .map(str::to_string)
1191 {
1192 *conn.session_id.lock().await = Some(session);
1193 }
1194
1195 let status = response.status();
1196 let content_type = response
1197 .headers()
1198 .get(reqwest::header::CONTENT_TYPE)
1199 .and_then(|value| value.to_str().ok())
1200 .unwrap_or("")
1201 .to_string();
1202
1203 if !status.is_success() {
1204 let detail = response.text().await.unwrap_or_default();
1205 let detail: String = detail.chars().take(500).collect();
1206 return Err(format!(
1207 "server '{name}' returned {}: {detail}",
1208 status.as_u16()
1209 ));
1210 }
1211
1212 let text = response
1213 .text()
1214 .await
1215 .map_err(|error| format!("reading response from '{name}': {error}"))?;
1216
1217 let message = if content_type.contains("text/event-stream") {
1218 parse_sse_response(&text)
1219 .ok_or_else(|| format!("no JSON-RPC message in SSE reply from '{name}'"))?
1220 } else {
1221 serde_json::from_str::<Value>(&text)
1222 .map_err(|error| format!("parsing response from '{name}': {error}"))?
1223 };
1224
1225 if let Some(error) = message.get("error") {
1226 let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
1227 let msg = error
1228 .get("message")
1229 .and_then(Value::as_str)
1230 .unwrap_or("unknown error");
1231 return Err(format!("'{name}' JSON-RPC error {code}: {msg}"));
1232 }
1233
1234 message
1235 .get("result")
1236 .cloned()
1237 .ok_or_else(|| format!("response from '{name}' had no result"))
1238 }
1239
1240 /// POST a JSON-RPC notification (no id, no response expected). A non-success
1241 /// status is an error; an empty 202 body is the normal case.
1242 async fn post_notification(
1243 http: &reqwest::Client,
1244 name: &str,
1245 conn: &HttpConn,
1246 body: &Value,
1247 timeout: Duration,
1248 ) -> Result<(), String> {
1249 let response = build_request(http, conn, body, timeout)
1250 .await
1251 .send()
1252 .await
1253 .map_err(|error| format!("notification to {} failed: {error}", conn.url))?;
1254 if !response.status().is_success() {
1255 return Err(format!(
1256 "server '{name}' rejected notification: {}",
1257 response.status().as_u16()
1258 ));
1259 }
1260 Ok(())
1261 }
1262
1263 /// Build a request carrying the MCP headers: the dual `Accept`, the JSON body,
1264 /// the configured static headers, the negotiated protocol version, and the
1265 /// session id once we have one.
1266 async fn build_request(
1267 http: &reqwest::Client,
1268 conn: &HttpConn,
1269 body: &Value,
1270 timeout: Duration,
1271 ) -> reqwest::RequestBuilder {
1272 let mut request = http
1273 .post(&conn.url)
1274 .timeout(timeout)
1275 .header(
1276 reqwest::header::ACCEPT,
1277 "application/json, text/event-stream",
1278 )
1279 .header("MCP-Protocol-Version", PROTOCOL_VERSION)
1280 .json(body);
1281
1282 for (key, value) in &conn.headers {
1283 request = request.header(key.as_str(), value.as_str());
1284 }
1285 if let Some(session) = conn.session_id.lock().await.as_ref() {
1286 request = request.header("Mcp-Session-Id", session.as_str());
1287 }
1288 request
1289 }
1290
1291 /// Extract the first JSON-RPC message from an SSE body. SSE frames are separated
1292 /// by blank lines; each `data:` line contributes to the frame's payload. For a
1293 /// single request/response exchange the server sends one `message` event whose
1294 /// data is the JSON-RPC response.
1295 fn parse_sse_response(body: &str) -> Option<Value> {
1296 let mut data = String::new();
1297 for line in body.lines() {
1298 if let Some(rest) = line.strip_prefix("data:") {
1299 if !data.is_empty() {
1300 data.push('\n');
1301 }
1302 data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
1303 } else if line.trim().is_empty() && !data.is_empty() {
1304 // End of an event — try to parse it as a JSON-RPC message.
1305 if let Ok(value) = serde_json::from_str::<Value>(&data)
1306 && (value.get("result").is_some() || value.get("error").is_some())
1307 {
1308 return Some(value);
1309 }
1310 data.clear();
1311 }
1312 }
1313 // Trailing event without a closing blank line.
1314 if !data.is_empty()
1315 && let Ok(value) = serde_json::from_str::<Value>(&data)
1316 && (value.get("result").is_some() || value.get("error").is_some())
1317 {
1318 return Some(value);
1319 }
1320 None
1321 }
1322
1323 // ── Status reporting (`/mcp`) ────────────────────────────────────────────────
1324
1325 /// Human-readable summary of configured MCP servers and their tools, for the
1326 /// `/mcp` slash command. Shows the URL for HTTP servers and the command line
1327 /// for stdio servers.
1328 pub fn status_summary() -> String {
1329 let Some(mcp) = MCP.get() else {
1330 return "MCP is not initialized.".to_string();
1331 };
1332 if mcp.servers.is_empty() {
1333 return "No MCP servers configured. Add one in ~/.config/sigit/mcp.toml \
1334 or .sigit/mcp.toml. See https://modelcontextprotocol.io."
1335 .to_string();
1336 }
1337
1338 let total_tools: usize = mcp.servers.iter().map(|s| s.tools.len()).sum();
1339 let mut lines = vec![format!(
1340 "{} MCP server(s), {total_tools} tool(s) available:",
1341 mcp.servers.len()
1342 )];
1343 for server in &mcp.servers {
1344 match &server.error {
1345 Some(error) => lines.push(format!(
1346 "- {} ({}) — unavailable: {error}",
1347 server.name, server.endpoint
1348 )),
1349 None => {
1350 lines.push(format!(
1351 "- {} ({}) — {} tool(s)",
1352 server.name,
1353 server.endpoint,
1354 server.tools.len()
1355 ));
1356 for tool in &server.tools {
1357 lines.push(format!(" • {}", tool.full_name));
1358 }
1359 }
1360 }
1361 }
1362 lines.join("\n")
1363 }
1364
1365 #[cfg(test)]
1366 mod tests {
1367 use super::*;
1368
1369 #[test]
1370 fn is_mcp_tool_detects_prefix() {
1371 assert!(is_mcp_tool("mcp__sigit__search"));
1372 assert!(!is_mcp_tool("read_file"));
1373 assert!(!is_mcp_tool("skill"));
1374 }
1375
1376 #[test]
1377 fn official_tool_name_matches_the_namespacing_convention() {
1378 assert_eq!(official_tool_name("list_issues"), "mcp__sigit__list_issues");
1379 assert_eq!(
1380 official_tool_name("get_pull_request"),
1381 "mcp__sigit__get_pull_request"
1382 );
1383 }
1384
1385 #[test]
1386 fn official_tool_suffix_strips_only_the_official_namespace() {
1387 assert_eq!(
1388 official_tool_suffix("mcp__sigit__list_issues"),
1389 Some("list_issues")
1390 );
1391 assert_eq!(official_tool_suffix("mcp__other__list_issues"), None);
1392 // `sigit` must be the whole server name, not a prefix of it.
1393 assert_eq!(official_tool_suffix("mcp__sigitx__list_issues"), None);
1394 assert_eq!(official_tool_suffix("list_issues"), None);
1395 assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
1396 }
1397
1398 #[test]
1399 fn smbcloud_tool_suffix_strips_only_the_smbcloud_namespace() {
1400 assert_eq!(
1401 smbcloud_tool_suffix("mcp__smbcloud__project_list"),
1402 Some("project_list")
1403 );
1404 assert_eq!(smbcloud_tool_suffix("mcp__sigit__project_list"), None);
1405 // `smbcloud` must be the whole server name, not a prefix of it.
1406 assert_eq!(smbcloud_tool_suffix("mcp__smbcloudx__project_list"), None);
1407 assert_eq!(smbcloud_tool_suffix("project_list"), None);
1408 assert_eq!(smbcloud_tool_suffix("mcp__smbcloud__"), Some(""));
1409 }
1410
1411 #[test]
1412 fn sanitize_collapses_invalid_chars() {
1413 assert_eq!(sanitize("github"), "github");
1414 assert_eq!(sanitize("my server"), "my_server");
1415 assert_eq!(sanitize("a.b/c:d"), "a_b_c_d");
1416 assert_eq!(sanitize("keep-_ok9"), "keep-_ok9");
1417 }
1418
1419 #[test]
1420 fn parses_mcp_file_with_servers() {
1421 let toml = r#"
1422 official = false
1423
1424 [[server]]
1425 name = "github"
1426 url = "https://api.example.com/mcp"
1427
1428 [[server]]
1429 name = "disabled-one"
1430 url = "https://nope.example.com/mcp"
1431 enabled = false
1432
1433 [server.headers]
1434 Authorization = "Bearer xyz"
1435 "#;
1436 let parsed: McpFile = toml::from_str(toml).unwrap();
1437 assert_eq!(parsed.official, Some(false));
1438 assert_eq!(parsed.smbcloud, None);
1439 assert_eq!(parsed.server.len(), 2);
1440 assert_eq!(parsed.server[0].name, "github");
1441 assert_eq!(parsed.server[1].enabled, Some(false));
1442 assert_eq!(
1443 parsed.server[1]
1444 .headers
1445 .get("Authorization")
1446 .map(String::as_str),
1447 Some("Bearer xyz")
1448 );
1449 }
1450
1451 #[test]
1452 fn parses_smbcloud_opt_out_flag() {
1453 let parsed: McpFile = toml::from_str("smbcloud = false").unwrap();
1454 assert_eq!(parsed.smbcloud, Some(false));
1455 // Absent means "include" (the default stays true in load_configs).
1456 let parsed: McpFile = toml::from_str("").unwrap();
1457 assert_eq!(parsed.smbcloud, None);
1458 }
1459
1460 #[test]
1461 fn on_path_finds_real_binaries_only() {
1462 assert!(!on_path("definitely-not-a-real-binary-xyzzy"));
1463 #[cfg(unix)]
1464 assert!(on_path("sh"));
1465 }
1466
1467 #[test]
1468 fn parses_stdio_server_with_args_and_env() {
1469 let toml = r#"
1470 [[server]]
1471 name = "fs"
1472 command = "npx"
1473 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1474
1475 [server.env]
1476 LOG_LEVEL = "debug"
1477 TOKEN = "abc"
1478 "#;
1479 let parsed: McpFile = toml::from_str(toml).unwrap();
1480 assert_eq!(parsed.server.len(), 1);
1481 let entry = &parsed.server[0];
1482 assert_eq!(entry.command.as_deref(), Some("npx"));
1483 assert_eq!(entry.args.len(), 3);
1484 assert_eq!(
1485 entry.env.get("LOG_LEVEL").map(String::as_str),
1486 Some("debug")
1487 );
1488 assert_eq!(entry.env.get("TOKEN").map(String::as_str), Some("abc"));
1489
1490 let def = entry.transport_def().expect("valid stdio entry");
1491 match def {
1492 TransportDef::Stdio { command, args, env } => {
1493 assert_eq!(command, "npx");
1494 assert_eq!(args[0], "-y");
1495 assert_eq!(env.len(), 2);
1496 }
1497 TransportDef::Http { .. } => panic!("expected a stdio transport"),
1498 }
1499 }
1500
1501 #[test]
1502 fn entry_with_url_and_command_is_a_config_error() {
1503 let toml = r#"
1504 [[server]]
1505 name = "confused"
1506 url = "https://example.com/mcp"
1507 command = "npx"
1508 "#;
1509 let parsed: McpFile = toml::from_str(toml).unwrap();
1510 let error = parsed.server[0].transport_def().unwrap_err();
1511 assert!(error.contains("both"), "unexpected error: {error}");
1512 }
1513
1514 #[test]
1515 fn entry_with_neither_url_nor_command_is_a_config_error() {
1516 let toml = r#"
1517 [[server]]
1518 name = "empty"
1519 "#;
1520 let parsed: McpFile = toml::from_str(toml).unwrap();
1521 let error = parsed.server[0].transport_def().unwrap_err();
1522 assert!(error.contains("needs"), "unexpected error: {error}");
1523 }
1524
1525 #[test]
1526 fn blank_url_or_command_counts_as_absent() {
1527 let toml = r#"
1528 [[server]]
1529 name = "blank"
1530 url = " "
1531 command = "server-bin"
1532 "#;
1533 let parsed: McpFile = toml::from_str(toml).unwrap();
1534 // A blank url is treated as absent, so this resolves to stdio.
1535 match parsed.server[0].transport_def().expect("stdio") {
1536 TransportDef::Stdio { command, .. } => assert_eq!(command, "server-bin"),
1537 TransportDef::Http { .. } => panic!("expected stdio"),
1538 }
1539 }
1540
1541 #[test]
1542 fn endpoint_renders_url_or_command_line() {
1543 let http = TransportDef::Http {
1544 url: "https://example.com/mcp".into(),
1545 headers: vec![],
1546 };
1547 assert_eq!(http.endpoint(), "https://example.com/mcp");
1548
1549 let stdio = TransportDef::Stdio {
1550 command: "npx".into(),
1551 args: vec!["-y".into(), "server-fs".into()],
1552 env: vec![],
1553 };
1554 assert_eq!(stdio.endpoint(), "npx -y server-fs");
1555 }
1556
1557 #[test]
1558 fn upsert_replaces_same_name() {
1559 let mut defs = vec![ServerDef {
1560 name: "a".into(),
1561 transport: TransportDef::Http {
1562 url: "u1".into(),
1563 headers: vec![],
1564 },
1565 }];
1566 upsert(
1567 &mut defs,
1568 ServerDef {
1569 name: "a".into(),
1570 transport: TransportDef::Http {
1571 url: "u2".into(),
1572 headers: vec![],
1573 },
1574 },
1575 );
1576 assert_eq!(defs.len(), 1);
1577 assert_eq!(defs[0].transport.endpoint(), "u2");
1578 }
1579
1580 #[test]
1581 fn parse_sse_extracts_jsonrpc_response() {
1582 let body =
1583 "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n";
1584 let value = parse_sse_response(body).expect("a message");
1585 assert_eq!(value["result"]["ok"], json!(true));
1586 }
1587
1588 #[test]
1589 fn parse_sse_handles_no_trailing_blank_line() {
1590 let body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}";
1591 assert!(parse_sse_response(body).is_some());
1592 }
1593
1594 #[test]
1595 fn parse_sse_ignores_non_response_frames() {
1596 // A lone notification (no result/error) shouldn't be mistaken for the response.
1597 let body = "data: {\"jsonrpc\":\"2.0\",\"method\":\"ping\"}\n\n";
1598 assert!(parse_sse_response(body).is_none());
1599 }
1600
1601 #[test]
1602 fn render_result_joins_text_blocks() {
1603 let result = json!({
1604 "content": [
1605 { "type": "text", "text": "line one" },
1606 { "type": "text", "text": "line two" }
1607 ]
1608 });
1609 assert_eq!(render_tool_result(&result), "line one\nline two");
1610 }
1611
1612 #[test]
1613 fn render_result_marks_errors_and_non_text() {
1614 let result = json!({
1615 "isError": true,
1616 "content": [
1617 { "type": "text", "text": "boom" },
1618 { "type": "image", "data": "..." }
1619 ]
1620 });
1621 let rendered = render_tool_result(&result);
1622 assert!(rendered.starts_with("Tool reported an error:"));
1623 assert!(rendered.contains("boom"));
1624 assert!(rendered.contains("[image content omitted]"));
1625 }
1626
1627 #[test]
1628 fn render_result_falls_back_to_structured_content() {
1629 let result = json!({ "structuredContent": { "value": 42 } });
1630 assert!(render_tool_result(&result).contains("42"));
1631 }
1632
1633 #[test]
1634 fn truncate_caps_long_output() {
1635 let long = "x".repeat(RESULT_CHAR_LIMIT + 100);
1636 let out = truncate(long);
1637 assert!(out.contains("[output truncated"));
1638 }
1639
1640 #[test]
1641 fn tool_specs_empty_before_init() {
1642 // Without init() the global is unset; this must not panic.
1643 assert!(super::tool_specs().is_empty() || MCP.get().is_some());
1644 }
1645 }