1 //! Model Context Protocol (MCP) client for siGit Code.
2 //!
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 //! 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 //!
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.
21 //!
22 //! ## Baked-in servers
23 //!
24 //! siGit Code bakes in its official MCP server at `<cloud>/mcp` (default
25 //! `https://sigit.si/api/v1/mcp`, following `SIGIT_CLOUD_URL`). When the user is
26 //! signed in (`sigit login`) the cloud session token is sent as a bearer
27 //! credential.
28 //!
29 //! The smbCloud CLI's MCP server (`smb --mcp`, stdio) is also baked in, but
30 //! only when the `smb` binary is actually on `PATH` — no binary, no entry, no
31 //! error. Opt out with `smbcloud = false` in `mcp.toml` or
32 //! `SIGIT_MCP_SMBCLOUD=off`.
33 //!
34 //! Both baked-in entries yield to a user-defined `mcp.toml` server of the same
35 //! name, so either can be repointed or reconfigured without a special case.
36 //! Additional servers are configured in `mcp.toml` (see [`load_configs`]).
37 //!
38 //! ## Lifecycle
39 //!
40 //! Discovery is best-effort and happens once at startup via [`init`]: each
41 //! configured server is contacted concurrently (with a per-server timeout),
42 //! runs the `initialize` handshake, and has its `tools/list` cached. A server
43 //! that fails to connect is recorded with its error and simply contributes no
44 //! tools — it never blocks startup or the rest of the agent. The result is
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 //!
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.
55 //!
56 //! Tools are namespaced `mcp__<server>__<tool>` so they never collide with
57 //! built-in tools or with each other across servers. This mirrors the
58 //! convention used by other MCP-aware agents.
59 //!
60 //! Like the rest of the backend seam, MCP is wired up only through the
61 //! interactive client and the ACP agent loop. On non-Unix targets a few helpers
62 //! are unused, so the dead-code lint is suppressed there only.
63 #![cfg_attr(not(unix), allow(dead_code))]
64
65 use std::collections::BTreeMap;
66 use std::path::PathBuf;
67 use std::sync::Mutex as StdMutex;
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};
74 use tokio::sync::RwLock;
75
76 use crate::backend::ToolSpec;
77
78 /// Prefix marking a tool as MCP-provided. The full name is
79 /// `mcp__<server>__<tool>`.
80 pub const MCP_PREFIX: &str = "mcp__";
81
82 /// Name of the baked-in official siGit Code server; its tools are namespaced
83 /// `mcp__sigit__<tool>`. A user-defined `mcp.toml` entry with this name
84 /// overrides the baked-in URL/headers but keeps the namespace, so callers of
85 /// [`official_tool_name`] reach whatever the user pointed `sigit` at.
86 pub const OFFICIAL_SERVER_NAME: &str = "sigit";
87
88 /// The full namespaced name of a tool on the official server, e.g.
89 /// `official_tool_name("list_issues")` → `mcp__sigit__list_issues`.
90 pub fn official_tool_name(tool: &str) -> String {
91 format!("{MCP_PREFIX}{OFFICIAL_SERVER_NAME}__{tool}")
92 }
93
94 /// The bare tool name when `name` belongs to the official server
95 /// (`mcp__sigit__list_issues` → `Some("list_issues")`), else `None`.
96 pub fn official_tool_suffix(name: &str) -> Option<&str> {
97 name.strip_prefix(MCP_PREFIX)?
98 .strip_prefix(OFFICIAL_SERVER_NAME)?
99 .strip_prefix("__")
100 }
101
102 /// Name of the baked-in smbCloud CLI server; its tools are namespaced
103 /// `mcp__smbcloud__<tool>`. Like the official server, a user-defined `mcp.toml`
104 /// entry with this name overrides the baked-in command line.
105 const SMBCLOUD_SERVER_NAME: &str = "smbcloud";
106
107 /// The smbCloud CLI binary the baked-in stdio entry spawns (`smb --mcp`).
108 const SMBCLOUD_COMMAND: &str = "smb";
109
110 /// The bare tool name when `name` belongs to the smbCloud server
111 /// (`mcp__smbcloud__project_list` → `Some("project_list")`), else `None`.
112 pub fn smbcloud_tool_suffix(name: &str) -> Option<&str> {
113 name.strip_prefix(MCP_PREFIX)?
114 .strip_prefix(SMBCLOUD_SERVER_NAME)?
115 .strip_prefix("__")
116 }
117
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
121 /// sum.
122 const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(8);
123
124 /// Overall request timeout for an individual `tools/call`. Generous for build
125 /// and test tools, while the Xcode bridge gets a shorter bound below because
126 /// it otherwise leaves an ACP prompt looking permanently busy when Xcode
127 /// cannot service the request.
128 const CALL_TIMEOUT: Duration = Duration::from_secs(120);
129 const XCODE_CALL_TIMEOUT: Duration = Duration::from_secs(30);
130
131 /// Cap on the characters returned from a single tool call, so a chatty server
132 /// can't blow up the model's context. Matches the spirit of the file-read cap.
133 const RESULT_CHAR_LIMIT: usize = 30_000;
134
135 // ── Public types ──────────────────────────────────────────────────────────────
136
137 /// A tool discovered on an MCP server, in siGit's flattened form.
138 #[derive(Debug, Clone)]
139 struct McpTool {
140 /// Namespaced name exposed to the model: `mcp__<server>__<tool>`.
141 full_name: String,
142 /// The tool's name as the server knows it (sent back in `tools/call`).
143 remote_name: String,
144 /// Human/model-facing description, prefixed with the server name.
145 description: String,
146 /// JSON Schema for the tool's arguments, encoded as a string.
147 parameters_schema: String,
148 }
149
150 /// A configured MCP server and its live connection state.
151 struct ServerConn {
152 /// Sanitized server name used in tool namespacing and the `/mcp` listing.
153 name: String,
154 /// Display endpoint for the `/mcp` listing: the URL for HTTP servers, the
155 /// command line for stdio servers.
156 endpoint: String,
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
167 /// The process-global MCP state: every configured server.
168 struct Mcp {
169 servers: Vec<ServerConn>,
170 }
171
172 static MCP: OnceLock<Mcp> = OnceLock::new();
173
174 // ── Configuration ───────────────────────────────────────────────────────────
175
176 /// Default endpoint of the official siGit Code MCP server, derived from the
177 /// cloud base URL so `SIGIT_CLOUD_URL` (dev) carries over.
178 fn official_url() -> String {
179 format!(
180 "{}/mcp",
181 crate::provider::cloud_base_url().trim_end_matches('/')
182 )
183 }
184
185 /// A server entry as written in `mcp.toml`. Exactly one of `url` (Streamable
186 /// HTTP) or `command` (stdio) selects the transport.
187 #[derive(Debug, Deserialize)]
188 struct ServerEntry {
189 name: String,
190 /// Streamable HTTP endpoint. Mutually exclusive with `command`.
191 #[serde(default)]
192 url: Option<String>,
193 /// stdio server executable. Mutually exclusive with `url`.
194 #[serde(default)]
195 command: Option<String>,
196 /// Arguments for `command`.
197 #[serde(default)]
198 args: Vec<String>,
199 /// Extra environment variables for `command`, added on top of the
200 /// inherited environment.
201 #[serde(default)]
202 env: BTreeMap<String, String>,
203 /// Set `enabled = false` to keep an entry in the file but skip connecting.
204 #[serde(default)]
205 enabled: Option<bool>,
206 /// Static headers, e.g. `Authorization = "Bearer ..."`. HTTP only.
207 #[serde(default)]
208 headers: BTreeMap<String, String>,
209 }
210
211 impl ServerEntry {
212 /// Resolve the entry's transport. `url` and `command` are mutually
213 /// exclusive and exactly one is required; anything else is a config error.
214 fn transport_def(&self) -> Result<TransportDef, String> {
215 let url = self.url.as_deref().map(str::trim).filter(|v| !v.is_empty());
216 let command = self
217 .command
218 .as_deref()
219 .map(str::trim)
220 .filter(|v| !v.is_empty());
221 match (url, command) {
222 (Some(_), Some(_)) => {
223 Err("has both `url` and `command`; a server uses exactly one transport".to_string())
224 }
225 (None, None) => {
226 Err("needs either `url` (Streamable HTTP) or `command` (stdio)".to_string())
227 }
228 (Some(url), None) => Ok(TransportDef::Http {
229 url: url.to_string(),
230 headers: self
231 .headers
232 .iter()
233 .map(|(k, v)| (k.clone(), v.clone()))
234 .collect(),
235 }),
236 (None, Some(command)) => Ok(TransportDef::Stdio {
237 command: command.to_string(),
238 args: self.args.clone(),
239 env: self
240 .env
241 .iter()
242 .map(|(k, v)| (k.clone(), v.clone()))
243 .collect(),
244 }),
245 }
246 }
247 }
248
249 /// The `mcp.toml` schema.
250 #[derive(Debug, Default, Deserialize)]
251 struct McpFile {
252 /// Include the baked-in official server. Defaults to `true`; set `false` to
253 /// opt out.
254 #[serde(default)]
255 official: Option<bool>,
256 /// Include the baked-in smbCloud CLI server (`smb --mcp`). Defaults to
257 /// `true`; set `false` to opt out. Moot when `smb` isn't installed.
258 #[serde(default)]
259 smbcloud: Option<bool>,
260 #[serde(default)]
261 server: Vec<ServerEntry>,
262 }
263
264 /// How to reach a configured server, before connecting.
265 #[derive(Debug, Clone)]
266 enum TransportDef {
267 Http {
268 url: String,
269 headers: Vec<(String, String)>,
270 },
271 Stdio {
272 command: String,
273 args: Vec<String>,
274 env: Vec<(String, String)>,
275 },
276 }
277
278 impl TransportDef {
279 /// Human-readable endpoint for logs and the `/mcp` listing: the URL for
280 /// HTTP, the command line for stdio.
281 fn endpoint(&self) -> String {
282 match self {
283 TransportDef::Http { url, .. } => url.clone(),
284 TransportDef::Stdio { command, args, .. } => {
285 let mut line = command.clone();
286 for arg in args {
287 line.push(' ');
288 line.push_str(arg);
289 }
290 line
291 }
292 }
293 }
294 }
295
296 /// A resolved server definition, before connecting.
297 #[derive(Debug, Clone)]
298 struct ServerDef {
299 name: String,
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> {
335 let mut paths = Vec::new();
336 if let Some(dir) = sigit_config_dir() {
337 paths.push(dir.join("mcp.toml"));
338 }
339 if let Ok(cwd) = std::env::current_dir() {
340 paths.push(cwd.join(".sigit").join("mcp.toml"));
341 }
342 paths
343 }
344
345 fn sigit_config_dir() -> Option<PathBuf> {
346 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
347 && !dir.is_empty()
348 {
349 return Some(PathBuf::from(dir));
350 }
351 std::env::var("HOME")
352 .ok()
353 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
354 }
355
356 /// Global escape hatch: `SIGIT_MCP=off` disables MCP entirely, including
357 /// servers an ACP client supplies per session.
358 fn disabled_by_env() -> bool {
359 std::env::var("SIGIT_MCP").is_ok_and(|value| {
360 matches!(
361 value.trim().to_ascii_lowercase().as_str(),
362 "off" | "0" | "false" | "no" | "disabled"
363 )
364 })
365 }
366
367 /// Resolve the full set of servers to connect to: the baked-in official server
368 /// (unless opted out) plus any from `mcp.toml`. Project-local entries override
369 /// global ones, and a user entry named `sigit` overrides the official default.
370 fn load_configs() -> Vec<ServerDef> {
371 if disabled_by_env() {
372 log::info!("mcp: disabled via SIGIT_MCP");
373 return Vec::new();
374 }
375
376 let mut include_official = true;
377 let mut include_smbcloud = true;
378 // De-duplicated by sanitized name; a later config file overrides an earlier
379 // one for the same name (project-local wins over global).
380 let mut defs: Vec<ServerDef> = Vec::new();
381
382 for path in config_paths() {
383 let Ok(contents) = std::fs::read_to_string(&path) else {
384 continue;
385 };
386 let parsed: McpFile = match toml::from_str(&contents) {
387 Ok(parsed) => parsed,
388 Err(error) => {
389 log::warn!("mcp: ignoring {}: {error}", path.display());
390 continue;
391 }
392 };
393 if let Some(official) = parsed.official {
394 include_official = official;
395 }
396 if let Some(smbcloud) = parsed.smbcloud {
397 include_smbcloud = smbcloud;
398 }
399 for entry in parsed.server {
400 if entry.enabled == Some(false) {
401 continue;
402 }
403 let name = sanitize(&entry.name);
404 if name.is_empty() {
405 log::warn!("mcp: skipping server with empty name in {}", path.display());
406 continue;
407 }
408 let transport = match entry.transport_def() {
409 Ok(transport) => transport,
410 Err(error) => {
411 log::warn!(
412 "mcp: skipping server '{name}' in {}: {error}",
413 path.display()
414 );
415 continue;
416 }
417 };
418 upsert(&mut defs, ServerDef { name, transport });
419 }
420 }
421
422 // The official server can also be disabled with SIGIT_MCP_OFFICIAL=off.
423 if let Ok(value) = std::env::var("SIGIT_MCP_OFFICIAL")
424 && matches!(
425 value.trim().to_ascii_lowercase().as_str(),
426 "off" | "0" | "false" | "no"
427 )
428 {
429 include_official = false;
430 }
431
432 // Add the baked-in official server, but never clobber a user-defined entry
433 // named `sigit` — an explicit config (e.g. a custom URL or headers) wins.
434 if include_official && !defs.iter().any(|d| d.name == OFFICIAL_SERVER_NAME) {
435 let mut headers = Vec::new();
436 if let Some(token) = crate::credentials::load_token() {
437 headers.push(("Authorization".to_string(), format!("Bearer {token}")));
438 }
439 defs.push(ServerDef {
440 name: OFFICIAL_SERVER_NAME.to_string(),
441 transport: TransportDef::Http {
442 url: official_url(),
443 headers,
444 },
445 });
446 }
447
448 // The smbCloud server can also be disabled with SIGIT_MCP_SMBCLOUD=off.
449 if let Ok(value) = std::env::var("SIGIT_MCP_SMBCLOUD")
450 && matches!(
451 value.trim().to_ascii_lowercase().as_str(),
452 "off" | "0" | "false" | "no"
453 )
454 {
455 include_smbcloud = false;
456 }
457
458 // Add the baked-in smbCloud CLI server, with the same never-clobber rule as
459 // the official one. Only when `smb` is actually installed: a hardwired
460 // entry for a binary most users don't have would surface a spawn failure
461 // in `/mcp` instead of just staying out of the way.
462 if include_smbcloud && !defs.iter().any(|d| d.name == SMBCLOUD_SERVER_NAME) {
463 if on_path(SMBCLOUD_COMMAND) {
464 defs.push(ServerDef {
465 name: SMBCLOUD_SERVER_NAME.to_string(),
466 transport: TransportDef::Stdio {
467 command: SMBCLOUD_COMMAND.to_string(),
468 args: vec!["--mcp".to_string()],
469 env: Vec::new(),
470 },
471 });
472 } else {
473 log::debug!("mcp: `{SMBCLOUD_COMMAND}` not on PATH; skipping the smbcloud server");
474 }
475 }
476
477 defs
478 }
479
480 /// Whether `binary` resolves to an executable file on `PATH` (with the
481 /// platform's executable suffix, `.exe` on Windows).
482 fn on_path(binary: &str) -> bool {
483 let Some(path) = std::env::var_os("PATH") else {
484 return false;
485 };
486 let file = format!("{binary}{}", std::env::consts::EXE_SUFFIX);
487 std::env::split_paths(&path).any(|dir| !dir.as_os_str().is_empty() && dir.join(&file).is_file())
488 }
489
490 /// Insert `def`, replacing any existing entry with the same name.
491 fn upsert(defs: &mut Vec<ServerDef>, def: ServerDef) {
492 if let Some(slot) = defs.iter_mut().find(|d| d.name == def.name) {
493 *slot = def;
494 } else {
495 defs.push(def);
496 }
497 }
498
499 /// Sanitize a name into the `[a-zA-Z0-9_-]` set tool names are restricted to,
500 /// collapsing anything else to `_`.
501 fn sanitize(raw: &str) -> String {
502 raw.trim()
503 .chars()
504 .map(|c| {
505 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
506 c
507 } else {
508 '_'
509 }
510 })
511 .collect()
512 }
513
514 // ── Startup / discovery ─────────────────────────────────────────────────────
515
516 /// Connect to every configured server and cache the tools they expose. Idempotent
517 /// and best-effort: a server that can't be reached is recorded with its error and
518 /// contributes no tools. Safe to call from either entry point; only the first
519 /// call does work.
520 pub async fn init() {
521 if MCP.get().is_some() {
522 return;
523 }
524
525 // Contact servers concurrently so one slow/unreachable host doesn't serialize
526 // the rest. Each handshake is bounded by HANDSHAKE_TIMEOUT.
527 let servers = futures::future::join_all(load_configs().into_iter().map(connect)).await;
528
529 for server in &servers {
530 match &server.error {
531 Some(error) => log::warn!("mcp: server '{}' unavailable: {error}", server.name),
532 None => log::info!(
533 "mcp: server '{}' ready, {} tool(s)",
534 server.name,
535 server.tools.len()
536 ),
537 }
538 }
539
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.
550 async fn connect(def: ServerDef) -> ServerConn {
551 let endpoint = def.transport.endpoint();
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 }
558 Err(error) => (None, Vec::new(), Some(format!("{error:#}"))),
559 };
560 ServerConn {
561 name: def.name.clone(),
562 endpoint,
563 def,
564 conn: RwLock::new(conn),
565 tools,
566 error,
567 }
568 }
569
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 }
582 let remote_desc = tool.description.as_deref().unwrap_or("").trim();
583 let description = if remote_desc.is_empty() {
584 format!("[MCP server '{server}'] {remote_name}")
585 } else {
586 format!("[MCP server '{server}'] {remote_desc}")
587 };
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,
594 remote_name,
595 description,
596 parameters_schema: schema.to_string(),
597 }
598 })
599 .collect()
600 }
601
602 // ── Client-supplied servers (per session) ───────────────────────────────────
603
604 /// A server an ACP client passed in `mcpServers` on a session request.
605 #[derive(Debug, Clone)]
606 pub struct ClientServer {
607 pub name: String,
608 pub transport: ClientTransport,
609 }
610
611 /// How a client-supplied server is reached. stdio is the transport every ACP
612 /// agent must support; Streamable HTTP is the optional one siGit Code
613 /// advertises through `mcpCapabilities.http`.
614 #[derive(Debug, Clone)]
615 pub enum ClientTransport {
616 Stdio {
617 command: String,
618 args: Vec<String>,
619 env: Vec<(String, String)>,
620 },
621 Http {
622 url: String,
623 headers: Vec<(String, String)>,
624 },
625 }
626
627 /// The MCP servers one ACP session brought with it, connected.
628 ///
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
632 /// last clone goes away the connections close, which stops stdio children.
633 #[derive(Clone, Default)]
634 pub struct SessionServers(Arc<Vec<ServerConn>>);
635
636 impl std::fmt::Debug for SessionServers {
637 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638 f.debug_list()
639 .entries(self.0.iter().map(|server| &server.name))
640 .finish()
641 }
642 }
643
644 /// The live session's client-supplied servers. One process serves every
645 /// thread the editor has open, so like the roots in `workspace.rs` this
646 /// always belongs to the session `main.rs` has installed.
647 static SESSION_SERVERS: StdMutex<Option<SessionServers>> = StdMutex::new(None);
648
649 /// Connect to the servers a client supplied for one session. Best effort, like
650 /// [`init`]: a server that fails its handshake is kept with its error so `/mcp`
651 /// can show it, and contributes no tools.
652 ///
653 /// A name already taken by a startup server (or by an earlier entry in the same
654 /// list) is not connected. Its tools would share the `mcp__<server>__` prefix
655 /// with the other server's, and a call could not be routed.
656 pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServers {
657 if servers.is_empty() {
658 return SessionServers::default();
659 }
660 if disabled_by_env() {
661 log::info!(
662 "mcp: ignoring {} client-supplied server(s), disabled via SIGIT_MCP",
663 servers.len()
664 );
665 return SessionServers::default();
666 }
667
668 let mut taken: Vec<String> = MCP
669 .get()
670 .map(|mcp| mcp.servers.iter().map(|s| s.name.clone()).collect())
671 .unwrap_or_default();
672
673 let mut clashes = Vec::new();
674 let mut defs = Vec::new();
675 for server in servers {
676 let name = sanitize(&server.name);
677 let transport = match server.transport {
678 ClientTransport::Stdio { command, args, env } => {
679 TransportDef::Stdio { command, args, env }
680 }
681 ClientTransport::Http { url, headers } => TransportDef::Http { url, headers },
682 };
683 if name.is_empty() || taken.contains(&name) {
684 clashes.push(ServerConn {
685 endpoint: transport.endpoint(),
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;
700 }
701 taken.push(name.clone());
702 defs.push(ServerDef { name, transport });
703 }
704
705 let mut connected = futures::future::join_all(defs.into_iter().map(connect)).await;
706 connected.extend(clashes);
707
708 for server in &connected {
709 match &server.error {
710 Some(error) => log::warn!(
711 "mcp: client-supplied server '{}' unavailable: {error}",
712 server.name
713 ),
714 None => log::info!(
715 "mcp: client-supplied server '{}' ready, {} tool(s)",
716 server.name,
717 server.tools.len()
718 ),
719 }
720 }
721
722 SessionServers(Arc::new(connected))
723 }
724
725 /// Make `servers` the live session's set. Called whenever `main.rs` installs a
726 /// session, so a thread never sees another thread's servers.
727 pub fn set_session_servers(servers: SessionServers) {
728 *SESSION_SERVERS.lock().unwrap() = Some(servers);
729 }
730
731 fn session_servers() -> SessionServers {
732 SESSION_SERVERS.lock().unwrap().clone().unwrap_or_default()
733 }
734
735 // ── Tool exposure + dispatch ────────────────────────────────────────────────
736
737 /// Whether a tool name belongs to MCP. The dispatch in `tools::execute_tool`
738 /// uses this to route a call here.
739 pub fn is_mcp_tool(name: &str) -> bool {
740 name.starts_with(MCP_PREFIX)
741 }
742
743 /// All discovered MCP tools as agent [`ToolSpec`]s, ready to append to the
744 /// built-in tool list: the startup servers' tools, then the ones the live
745 /// session's client supplied. Empty when no server exposed any.
746 pub fn tool_specs() -> Vec<ToolSpec> {
747 let session = session_servers();
748 let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
749 let mut specs = Vec::new();
750 for server in startup.iter().chain(session.0.iter()) {
751 for tool in &server.tools {
752 specs.push(ToolSpec {
753 name: tool.full_name.clone(),
754 description: tool.description.clone(),
755 parameters_schema: tool.parameters_schema.clone(),
756 });
757 }
758 }
759 specs
760 }
761
762 /// Execute an MCP tool call by name, returning text to feed back to the model.
763 /// Errors are returned as plain strings (never panics) so a failing tool degrades
764 /// to a message the model can react to, exactly like the built-in tools.
765 pub async fn call_tool(full_name: &str, arguments: &str) -> String {
766 let session = session_servers();
767 let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
768 if MCP.get().is_none() && session.0.is_empty() {
769 return "Error: MCP is not initialized.".to_string();
770 }
771
772 let Some((server, tool)) = startup.iter().chain(session.0.iter()).find_map(|s| {
773 s.tools
774 .iter()
775 .find(|t| t.full_name == full_name)
776 .map(|t| (s, t))
777 }) else {
778 return format!("Error: unknown MCP tool \"{full_name}\".");
779 };
780
781 // Arguments arrive as a JSON-encoded string; an empty/blank string means no
782 // arguments. Anything that isn't a JSON object is a model mistake.
783 let args: Value = if arguments.trim().is_empty() {
784 json!({})
785 } else {
786 match serde_json::from_str(arguments) {
787 Ok(value @ Value::Object(_)) => value,
788 Ok(_) => return "Error: tool arguments must be a JSON object.".to_string(),
789 Err(error) => return format!("Error: failed to parse arguments: {error}"),
790 }
791 };
792
793 match call_server(server, &tool.remote_name, args).await {
794 Ok(text) => truncate(text),
795 Err(error) => format!("Error: {error}"),
796 }
797 }
798
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(
803 server: &ServerConn,
804 remote_name: &str,
805 args: Value,
806 ) -> Result<String, String> {
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 }
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 };
843 Ok(render_tool_result(&result))
844 }
845
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 {
849 let mut out = String::new();
850 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
851 for block in blocks {
852 match block.get("type").and_then(Value::as_str) {
853 Some("text") => {
854 if let Some(text) = block.get("text").and_then(Value::as_str) {
855 if !out.is_empty() {
856 out.push('\n');
857 }
858 out.push_str(text);
859 }
860 }
861 Some(other) => {
862 if !out.is_empty() {
863 out.push('\n');
864 }
865 out.push_str(&format!("[{other} content omitted]"));
866 }
867 None => {}
868 }
869 }
870 }
871
872 // Some servers return only `structuredContent`; surface it if there was no
873 // textual content.
874 if out.is_empty()
875 && let Some(structured) = result.get("structuredContent")
876 {
877 out = structured.to_string();
878 }
879
880 if out.is_empty() {
881 out = "(tool returned no content)".to_string();
882 }
883
884 if result.get("isError").and_then(Value::as_bool) == Some(true) {
885 format!("Tool reported an error:\n{out}")
886 } else {
887 out
888 }
889 }
890
891 /// Truncate tool output to the context-protecting limit, with a trailing note.
892 fn truncate(text: String) -> String {
893 if text.chars().count() <= RESULT_CHAR_LIMIT {
894 return text;
895 }
896 let kept: String = text.chars().take(RESULT_CHAR_LIMIT).collect();
897 format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]")
898 }
899
900 // ── Status reporting (`/mcp`) ────────────────────────────────────────────────
901
902 /// Human-readable summary of configured MCP servers and their tools, for the
903 /// `/mcp` slash command. Shows the URL for HTTP servers and the command line
904 /// for stdio servers.
905 pub fn status_summary() -> String {
906 let session = session_servers();
907 let Some(mcp) = MCP.get() else {
908 if session.0.is_empty() {
909 return "MCP is not initialized.".to_string();
910 }
911 return summarize_servers(&[], &session.0);
912 };
913 if mcp.servers.is_empty() && session.0.is_empty() {
914 return "No MCP servers configured. Add one in ~/.config/sigit/mcp.toml \
915 or .sigit/mcp.toml. See https://modelcontextprotocol.io."
916 .to_string();
917 }
918 summarize_servers(&mcp.servers, &session.0)
919 }
920
921 /// The `/mcp` listing for the startup servers followed by the ones the live
922 /// session's client supplied, which are marked as such.
923 fn summarize_servers(startup: &[ServerConn], session: &[ServerConn]) -> String {
924 let all = || startup.iter().chain(session.iter());
925 let total_tools: usize = all().map(|s| s.tools.len()).sum();
926 let mut lines = vec![format!(
927 "{} MCP server(s), {total_tools} tool(s) available:",
928 all().count()
929 )];
930 for (index, server) in all().enumerate() {
931 let origin = if index >= startup.len() {
932 ", from the editor"
933 } else {
934 ""
935 };
936 match &server.error {
937 Some(error) => lines.push(format!(
938 "- {} ({}{origin}) — unavailable: {error}",
939 server.name, server.endpoint
940 )),
941 None => {
942 lines.push(format!(
943 "- {} ({}{origin}) — {} tool(s)",
944 server.name,
945 server.endpoint,
946 server.tools.len()
947 ));
948 for tool in &server.tools {
949 lines.push(format!(" • {}", tool.full_name));
950 }
951 }
952 }
953 }
954 lines.join("\n")
955 }
956
957 #[cfg(test)]
958 mod tests {
959 use super::*;
960
961 #[test]
962 fn is_mcp_tool_detects_prefix() {
963 assert!(is_mcp_tool("mcp__sigit__search"));
964 assert!(!is_mcp_tool("read_file"));
965 assert!(!is_mcp_tool("skill"));
966 }
967
968 #[test]
969 fn official_tool_name_matches_the_namespacing_convention() {
970 assert_eq!(official_tool_name("list_issues"), "mcp__sigit__list_issues");
971 assert_eq!(
972 official_tool_name("get_pull_request"),
973 "mcp__sigit__get_pull_request"
974 );
975 }
976
977 #[test]
978 fn official_tool_suffix_strips_only_the_official_namespace() {
979 assert_eq!(
980 official_tool_suffix("mcp__sigit__list_issues"),
981 Some("list_issues")
982 );
983 assert_eq!(official_tool_suffix("mcp__other__list_issues"), None);
984 // `sigit` must be the whole server name, not a prefix of it.
985 assert_eq!(official_tool_suffix("mcp__sigitx__list_issues"), None);
986 assert_eq!(official_tool_suffix("list_issues"), None);
987 assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
988 }
989
990 #[test]
991 fn smbcloud_tool_suffix_strips_only_the_smbcloud_namespace() {
992 assert_eq!(
993 smbcloud_tool_suffix("mcp__smbcloud__project_list"),
994 Some("project_list")
995 );
996 assert_eq!(smbcloud_tool_suffix("mcp__sigit__project_list"), None);
997 // `smbcloud` must be the whole server name, not a prefix of it.
998 assert_eq!(smbcloud_tool_suffix("mcp__smbcloudx__project_list"), None);
999 assert_eq!(smbcloud_tool_suffix("project_list"), None);
1000 assert_eq!(smbcloud_tool_suffix("mcp__smbcloud__"), Some(""));
1001 }
1002
1003 #[test]
1004 fn sanitize_collapses_invalid_chars() {
1005 assert_eq!(sanitize("github"), "github");
1006 assert_eq!(sanitize("my server"), "my_server");
1007 assert_eq!(sanitize("a.b/c:d"), "a_b_c_d");
1008 assert_eq!(sanitize("keep-_ok9"), "keep-_ok9");
1009 }
1010
1011 #[test]
1012 fn parses_mcp_file_with_servers() {
1013 let toml = r#"
1014 official = false
1015
1016 [[server]]
1017 name = "github"
1018 url = "https://api.example.com/mcp"
1019
1020 [[server]]
1021 name = "disabled-one"
1022 url = "https://nope.example.com/mcp"
1023 enabled = false
1024
1025 [server.headers]
1026 Authorization = "Bearer xyz"
1027 "#;
1028 let parsed: McpFile = toml::from_str(toml).unwrap();
1029 assert_eq!(parsed.official, Some(false));
1030 assert_eq!(parsed.smbcloud, None);
1031 assert_eq!(parsed.server.len(), 2);
1032 assert_eq!(parsed.server[0].name, "github");
1033 assert_eq!(parsed.server[1].enabled, Some(false));
1034 assert_eq!(
1035 parsed.server[1]
1036 .headers
1037 .get("Authorization")
1038 .map(String::as_str),
1039 Some("Bearer xyz")
1040 );
1041 }
1042
1043 #[test]
1044 fn parses_smbcloud_opt_out_flag() {
1045 let parsed: McpFile = toml::from_str("smbcloud = false").unwrap();
1046 assert_eq!(parsed.smbcloud, Some(false));
1047 // Absent means "include" (the default stays true in load_configs).
1048 let parsed: McpFile = toml::from_str("").unwrap();
1049 assert_eq!(parsed.smbcloud, None);
1050 }
1051
1052 #[test]
1053 fn on_path_finds_real_binaries_only() {
1054 assert!(!on_path("definitely-not-a-real-binary-xyzzy"));
1055 #[cfg(unix)]
1056 assert!(on_path("sh"));
1057 }
1058
1059 #[test]
1060 fn parses_stdio_server_with_args_and_env() {
1061 let toml = r#"
1062 [[server]]
1063 name = "fs"
1064 command = "npx"
1065 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1066
1067 [server.env]
1068 LOG_LEVEL = "debug"
1069 TOKEN = "abc"
1070 "#;
1071 let parsed: McpFile = toml::from_str(toml).unwrap();
1072 assert_eq!(parsed.server.len(), 1);
1073 let entry = &parsed.server[0];
1074 assert_eq!(entry.command.as_deref(), Some("npx"));
1075 assert_eq!(entry.args.len(), 3);
1076 assert_eq!(
1077 entry.env.get("LOG_LEVEL").map(String::as_str),
1078 Some("debug")
1079 );
1080 assert_eq!(entry.env.get("TOKEN").map(String::as_str), Some("abc"));
1081
1082 let def = entry.transport_def().expect("valid stdio entry");
1083 match def {
1084 TransportDef::Stdio { command, args, env } => {
1085 assert_eq!(command, "npx");
1086 assert_eq!(args[0], "-y");
1087 assert_eq!(env.len(), 2);
1088 }
1089 TransportDef::Http { .. } => panic!("expected a stdio transport"),
1090 }
1091 }
1092
1093 #[test]
1094 fn entry_with_url_and_command_is_a_config_error() {
1095 let toml = r#"
1096 [[server]]
1097 name = "confused"
1098 url = "https://example.com/mcp"
1099 command = "npx"
1100 "#;
1101 let parsed: McpFile = toml::from_str(toml).unwrap();
1102 let error = parsed.server[0].transport_def().unwrap_err();
1103 assert!(error.contains("both"), "unexpected error: {error}");
1104 }
1105
1106 #[test]
1107 fn entry_with_neither_url_nor_command_is_a_config_error() {
1108 let toml = r#"
1109 [[server]]
1110 name = "empty"
1111 "#;
1112 let parsed: McpFile = toml::from_str(toml).unwrap();
1113 let error = parsed.server[0].transport_def().unwrap_err();
1114 assert!(error.contains("needs"), "unexpected error: {error}");
1115 }
1116
1117 #[test]
1118 fn blank_url_or_command_counts_as_absent() {
1119 let toml = r#"
1120 [[server]]
1121 name = "blank"
1122 url = " "
1123 command = "server-bin"
1124 "#;
1125 let parsed: McpFile = toml::from_str(toml).unwrap();
1126 // A blank url is treated as absent, so this resolves to stdio.
1127 match parsed.server[0].transport_def().expect("stdio") {
1128 TransportDef::Stdio { command, .. } => assert_eq!(command, "server-bin"),
1129 TransportDef::Http { .. } => panic!("expected stdio"),
1130 }
1131 }
1132
1133 #[test]
1134 fn endpoint_renders_url_or_command_line() {
1135 let http = TransportDef::Http {
1136 url: "https://example.com/mcp".into(),
1137 headers: vec![],
1138 };
1139 assert_eq!(http.endpoint(), "https://example.com/mcp");
1140
1141 let stdio = TransportDef::Stdio {
1142 command: "npx".into(),
1143 args: vec!["-y".into(), "server-fs".into()],
1144 env: vec![],
1145 };
1146 assert_eq!(stdio.endpoint(), "npx -y server-fs");
1147 }
1148
1149 #[test]
1150 fn upsert_replaces_same_name() {
1151 let mut defs = vec![ServerDef {
1152 name: "a".into(),
1153 transport: TransportDef::Http {
1154 url: "u1".into(),
1155 headers: vec![],
1156 },
1157 }];
1158 upsert(
1159 &mut defs,
1160 ServerDef {
1161 name: "a".into(),
1162 transport: TransportDef::Http {
1163 url: "u2".into(),
1164 headers: vec![],
1165 },
1166 },
1167 );
1168 assert_eq!(defs.len(), 1);
1169 assert_eq!(defs[0].transport.endpoint(), "u2");
1170 }
1171
1172 #[test]
1173 fn render_result_joins_text_blocks() {
1174 let result = json!({
1175 "content": [
1176 { "type": "text", "text": "line one" },
1177 { "type": "text", "text": "line two" }
1178 ]
1179 });
1180 assert_eq!(render_tool_result(&result), "line one\nline two");
1181 }
1182
1183 #[test]
1184 fn render_result_marks_errors_and_non_text() {
1185 let result = json!({
1186 "isError": true,
1187 "content": [
1188 { "type": "text", "text": "boom" },
1189 { "type": "image", "data": "..." }
1190 ]
1191 });
1192 let rendered = render_tool_result(&result);
1193 assert!(rendered.starts_with("Tool reported an error:"));
1194 assert!(rendered.contains("boom"));
1195 assert!(rendered.contains("[image content omitted]"));
1196 }
1197
1198 #[test]
1199 fn render_result_falls_back_to_structured_content() {
1200 let result = json!({ "structuredContent": { "value": 42 } });
1201 assert!(render_tool_result(&result).contains("42"));
1202 }
1203
1204 #[test]
1205 fn truncate_caps_long_output() {
1206 let long = "x".repeat(RESULT_CHAR_LIMIT + 100);
1207 let out = truncate(long);
1208 assert!(out.contains("[output truncated"));
1209 }
1210
1211 #[test]
1212 fn tool_specs_empty_before_init() {
1213 // Without init() the global is unset; this must not panic.
1214 assert!(super::tool_specs().is_empty() || MCP.get().is_some());
1215 }
1216 }