I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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 //! Ascapi — the App Stores Coolest API, via its own CLI's MCP server
35 //! (`asc --mcp`, stdio) — is baked in the same way: only when the `asc` binary
36 //! is on `PATH`, opt out with `ascapi = false` in `mcp.toml` or
37 //! `SIGIT_MCP_ASCAPI=off`.
38 //!
39 //! All baked-in entries yield to a user-defined `mcp.toml` server of the same
40 //! name, so either can be repointed or reconfigured without a special case.
41 //! Additional servers are configured in `mcp.toml` (see [`load_configs`]).
42 //!
43 //! ## Lifecycle
44 //!
45 //! Discovery is best-effort and happens once at startup via [`init`]: each
46 //! configured server is contacted concurrently (with a per-server timeout),
47 //! runs the `initialize` handshake, and has its `tools/list` cached. A server
48 //! that fails to connect is recorded with its error and simply contributes no
49 //! tools — it never blocks startup or the rest of the agent. The result is
50 //! stored in a process-global so the synchronous tool-spec builders
51 //! ([`tool_specs`]) and the async dispatch ([`call_tool`]) can both read it.
52 //!
53 //! stdio children live for the sigit process. When a child dies, later calls
54 //! return an in-band error string the model can react to; there is no
55 //! automatic restart. An HTTP server that stops answering (a restart, an
56 //! expired session) is reconnected once and the call retried.
57 //! `/reload` does *not* re-run discovery ([`init`] is once-per-process), so a
58 //! changed `mcp.toml` or a dead server needs a sigit restart. At process exit
59 //! children see EOF on their stdin and exit on their own.
60 //!
61 //! Tools are namespaced `mcp__<server>__<tool>` so they never collide with
62 //! built-in tools or with each other across servers. This mirrors the
63 //! convention used by other MCP-aware agents.
64 //!
65 //! Like the rest of the backend seam, MCP is wired up only through the
66 //! interactive client and the ACP agent loop. On non-Unix targets a few helpers
67 //! are unused, so the dead-code lint is suppressed there only.
68 #![cfg_attr(not(unix), allow(dead_code))]
69
70 use std::collections::BTreeMap;
71 use std::path::PathBuf;
72 use std::sync::Mutex as StdMutex;
73 use std::sync::{Arc, OnceLock, RwLock as StdRwLock};
74 use std::time::Duration;
75
76 use ed_mcp::{ClientInfo, Connection, ServerSpec};
77 use serde::Deserialize;
78 use serde_json::{Value, json};
79 use tokio::sync::RwLock;
80
81 use crate::backend::ToolSpec;
82
83 /// Prefix marking a tool as MCP-provided. The full name is
84 /// `mcp__<server>__<tool>`.
85 pub const MCP_PREFIX: &str = "mcp__";
86
87 /// Name of the baked-in official siGit Code server; its tools are namespaced
88 /// `mcp__sigit__<tool>`. A user-defined `mcp.toml` entry with this name
89 /// overrides the baked-in URL/headers but keeps the namespace, so callers of
90 /// [`official_tool_name`] reach whatever the user pointed `sigit` at.
91 pub const OFFICIAL_SERVER_NAME: &str = "sigit";
92
93 /// The full namespaced name of a tool on the official server, e.g.
94 /// `official_tool_name("list_issues")` → `mcp__sigit__list_issues`.
95 pub fn official_tool_name(tool: &str) -> String {
96 format!("{MCP_PREFIX}{OFFICIAL_SERVER_NAME}__{tool}")
97 }
98
99 /// The bare tool name when `name` belongs to the official server
100 /// (`mcp__sigit__list_issues` → `Some("list_issues")`), else `None`.
101 pub fn official_tool_suffix(name: &str) -> Option<&str> {
102 name.strip_prefix(MCP_PREFIX)?
103 .strip_prefix(OFFICIAL_SERVER_NAME)?
104 .strip_prefix("__")
105 }
106
107 /// Name of the baked-in smbCloud CLI server; its tools are namespaced
108 /// `mcp__smbcloud__<tool>`. Like the official server, a user-defined `mcp.toml`
109 /// entry with this name overrides the baked-in command line.
110 const SMBCLOUD_SERVER_NAME: &str = "smbcloud";
111
112 /// The smbCloud CLI binary the baked-in stdio entry spawns (`smb --mcp`).
113 const SMBCLOUD_COMMAND: &str = "smb";
114
115 /// The bare tool name when `name` belongs to the smbCloud server
116 /// (`mcp__smbcloud__project_list` → `Some("project_list")`), else `None`.
117 pub fn smbcloud_tool_suffix(name: &str) -> Option<&str> {
118 name.strip_prefix(MCP_PREFIX)?
119 .strip_prefix(SMBCLOUD_SERVER_NAME)?
120 .strip_prefix("__")
121 }
122
123 /// Name of the baked-in Ascapi (the App Stores Coolest API) server; its tools
124 /// are namespaced `mcp__ascapi__<tool>`. Like the other baked-in servers, a
125 /// user-defined `mcp.toml` entry with this name overrides the baked-in
126 /// command line.
127 const ASCAPI_SERVER_NAME: &str = "ascapi";
128
129 /// The Ascapi CLI binary the baked-in stdio entry spawns (`asc --mcp`).
130 const ASCAPI_COMMAND: &str = "asc";
131
132 /// The bare tool name when `name` belongs to the Ascapi server
133 /// (`mcp__ascapi__app_list` → `Some("app_list")`), else `None`.
134 pub fn ascapi_tool_suffix(name: &str) -> Option<&str> {
135 name.strip_prefix(MCP_PREFIX)?
136 .strip_prefix(ASCAPI_SERVER_NAME)?
137 .strip_prefix("__")
138 }
139
140 /// Per-server budget for the connect + `initialize` + `tools/list` handshake at
141 /// startup. Bounds how long an unreachable server can delay startup; servers are
142 /// contacted concurrently, so this is the worst case for the whole set, not the
143 /// sum.
144 const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(8);
145
146 /// Overall request timeout for an individual `tools/call`. Generous for build
147 /// and test tools, while the Xcode bridge gets a shorter bound below because
148 /// it otherwise leaves an ACP prompt looking permanently busy when Xcode
149 /// cannot service the request.
150 const CALL_TIMEOUT: Duration = Duration::from_secs(120);
151 const XCODE_CALL_TIMEOUT: Duration = Duration::from_secs(30);
152
153 /// Cap on the characters returned from a single tool call, so a chatty server
154 /// can't blow up the model's context. Matches the spirit of the file-read cap.
155 const RESULT_CHAR_LIMIT: usize = 30_000;
156
157 // ── Public types ──────────────────────────────────────────────────────────────
158
159 /// A tool discovered on an MCP server, in siGit's flattened form.
160 #[derive(Debug, Clone)]
161 struct McpTool {
162 /// Namespaced name exposed to the model: `mcp__<server>__<tool>`.
163 full_name: String,
164 /// The tool's name as the server knows it (sent back in `tools/call`).
165 remote_name: String,
166 /// Human/model-facing description, prefixed with the server name.
167 description: String,
168 /// JSON Schema for the tool's arguments, encoded as a string.
169 parameters_schema: String,
170 }
171
172 /// A configured MCP server and its live connection state.
173 struct ServerConn {
174 /// Sanitized server name used in tool namespacing and the `/mcp` listing.
175 name: String,
176 /// Display endpoint for the `/mcp` listing: the URL for HTTP servers, the
177 /// command line for stdio servers.
178 endpoint: String,
179 /// How it was reached, kept so an HTTP server can be reconnected.
180 def: ServerDef,
181 /// The live connection. `None` when the server failed to connect.
182 conn: RwLock<Option<Live>>,
183 /// Tools discovered at the last (re)connect. Empty when the server failed
184 /// to connect. Replaced when a reconnect finds a different set.
185 tools: StdRwLock<Vec<McpTool>>,
186 /// Connection error, if the last handshake failed. Surfaced by `/mcp`.
187 error: StdRwLock<Option<String>>,
188 }
189
190 /// A connection and the session token it was opened with, so a changed sign-in
191 /// can be told apart from a connection that is still current.
192 struct Live {
193 conn: Arc<Connection>,
194 token: Option<String>,
195 }
196
197 impl ServerConn {
198 fn tools(&self) -> std::sync::RwLockReadGuard<'_, Vec<McpTool>> {
199 self.tools.read().unwrap_or_else(|e| e.into_inner())
200 }
201
202 fn error(&self) -> Option<String> {
203 self.error.read().unwrap_or_else(|e| e.into_inner()).clone()
204 }
205 }
206
207 /// The process-global MCP state: every configured server.
208 struct Mcp {
209 servers: Vec<ServerConn>,
210 }
211
212 static MCP: OnceLock<Mcp> = OnceLock::new();
213
214 // ── Configuration ───────────────────────────────────────────────────────────
215
216 /// Default endpoint of the official siGit Code MCP server, derived from the
217 /// cloud base URL so `SIGIT_CLOUD_URL` (dev) carries over.
218 fn official_url() -> String {
219 format!(
220 "{}/mcp",
221 crate::provider::cloud_base_url().trim_end_matches('/')
222 )
223 }
224
225 /// A server entry as written in `mcp.toml`. Exactly one of `url` (Streamable
226 /// HTTP) or `command` (stdio) selects the transport.
227 #[derive(Debug, Deserialize)]
228 struct ServerEntry {
229 name: String,
230 /// Streamable HTTP endpoint. Mutually exclusive with `command`.
231 #[serde(default)]
232 url: Option<String>,
233 /// stdio server executable. Mutually exclusive with `url`.
234 #[serde(default)]
235 command: Option<String>,
236 /// Arguments for `command`.
237 #[serde(default)]
238 args: Vec<String>,
239 /// Extra environment variables for `command`, added on top of the
240 /// inherited environment.
241 #[serde(default)]
242 env: BTreeMap<String, String>,
243 /// Set `enabled = false` to keep an entry in the file but skip connecting.
244 #[serde(default)]
245 enabled: Option<bool>,
246 /// Static headers, e.g. `Authorization = "Bearer ..."`. HTTP only.
247 #[serde(default)]
248 headers: BTreeMap<String, String>,
249 }
250
251 impl ServerEntry {
252 /// Resolve the entry's transport. `url` and `command` are mutually
253 /// exclusive and exactly one is required; anything else is a config error.
254 fn transport_def(&self) -> Result<TransportDef, String> {
255 let url = self.url.as_deref().map(str::trim).filter(|v| !v.is_empty());
256 let command = self
257 .command
258 .as_deref()
259 .map(str::trim)
260 .filter(|v| !v.is_empty());
261 match (url, command) {
262 (Some(_), Some(_)) => {
263 Err("has both `url` and `command`; a server uses exactly one transport".to_string())
264 }
265 (None, None) => {
266 Err("needs either `url` (Streamable HTTP) or `command` (stdio)".to_string())
267 }
268 (Some(url), None) => Ok(TransportDef::Http {
269 url: url.to_string(),
270 headers: self
271 .headers
272 .iter()
273 .map(|(k, v)| (k.clone(), v.clone()))
274 .collect(),
275 }),
276 (None, Some(command)) => Ok(TransportDef::Stdio {
277 command: command.to_string(),
278 args: self.args.clone(),
279 env: self
280 .env
281 .iter()
282 .map(|(k, v)| (k.clone(), v.clone()))
283 .collect(),
284 }),
285 }
286 }
287 }
288
289 /// The `mcp.toml` schema.
290 #[derive(Debug, Default, Deserialize)]
291 struct McpFile {
292 /// Include the baked-in official server. Defaults to `true`; set `false` to
293 /// opt out.
294 #[serde(default)]
295 official: Option<bool>,
296 /// Include the baked-in smbCloud CLI server (`smb --mcp`). Defaults to
297 /// `true`; set `false` to opt out. Moot when `smb` isn't installed.
298 #[serde(default)]
299 smbcloud: Option<bool>,
300 /// Include the baked-in Ascapi server (`asc --mcp`) — Ascapi is the App
301 /// Stores Coolest API. Defaults to `true`; set `false` to opt out. Moot
302 /// when `asc` isn't installed.
303 #[serde(default)]
304 ascapi: Option<bool>,
305 #[serde(default)]
306 server: Vec<ServerEntry>,
307 }
308
309 /// How to reach a configured server, before connecting.
310 #[derive(Debug, Clone)]
311 enum TransportDef {
312 Http {
313 url: String,
314 headers: Vec<(String, String)>,
315 },
316 Stdio {
317 command: String,
318 args: Vec<String>,
319 env: Vec<(String, String)>,
320 },
321 }
322
323 impl TransportDef {
324 /// Human-readable endpoint for logs and the `/mcp` listing: the URL for
325 /// HTTP, the command line for stdio.
326 fn endpoint(&self) -> String {
327 match self {
328 TransportDef::Http { url, .. } => url.clone(),
329 TransportDef::Stdio { command, args, .. } => {
330 let mut line = command.clone();
331 for arg in args {
332 line.push(' ');
333 line.push_str(arg);
334 }
335 line
336 }
337 }
338 }
339 }
340
341 /// A resolved server definition, before connecting.
342 #[derive(Debug, Clone)]
343 struct ServerDef {
344 name: String,
345 transport: TransportDef,
346 /// Whether the server authenticates as the signed-in siGit account. Only
347 /// the built-in official server does. Its bearer is read from the stored
348 /// credentials on every (re)connect instead of being fixed at startup.
349 follows_session: bool,
350 }
351
352 impl ServerDef {
353 /// The `ed-mcp` spec for this server. An `Authorization: Bearer` header is
354 /// passed as the bearer token, which is how `rmcp` wants it.
355 /// The stored session token, for a server that follows the session.
356 fn session_token(&self) -> Option<String> {
357 if self.follows_session {
358 crate::credentials::load_token()
359 } else {
360 None
361 }
362 }
363
364 fn spec_with(&self, session_token: Option<&str>) -> ServerSpec {
365 match &self.transport {
366 TransportDef::Http { url, headers } => {
367 let spec =
368 headers
369 .iter()
370 .fold(
371 ServerSpec::http(&self.name, url),
372 |spec, (key, value)| match value.strip_prefix("Bearer ") {
373 Some(token) if key.eq_ignore_ascii_case("authorization") => {
374 spec.with_bearer(token)
375 }
376 _ => spec.with_header(key, value),
377 },
378 );
379 match session_token {
380 Some(token) => spec.with_bearer(token),
381 None => spec,
382 }
383 }
384 TransportDef::Stdio { command, args, env } => {
385 ServerSpec::stdio(&self.name, command, args.clone(), env.clone())
386 }
387 }
388 }
389
390 fn is_stdio(&self) -> bool {
391 matches!(self.transport, TransportDef::Stdio { .. })
392 }
393 }
394
395 /// Config files to read, in priority order (later wins on a name clash):
396 /// global `$SIGIT_CONFIG_DIR/mcp.toml`, then project-local `<cwd>/.sigit/mcp.toml`.
397 fn config_paths() -> Vec<PathBuf> {
398 let mut paths = Vec::new();
399 if let Some(dir) = sigit_config_dir() {
400 paths.push(dir.join("mcp.toml"));
401 }
402 if let Ok(cwd) = std::env::current_dir() {
403 paths.push(cwd.join(".sigit").join("mcp.toml"));
404 }
405 paths
406 }
407
408 fn sigit_config_dir() -> Option<PathBuf> {
409 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
410 && !dir.is_empty()
411 {
412 return Some(PathBuf::from(dir));
413 }
414 std::env::var("HOME")
415 .ok()
416 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
417 }
418
419 /// Global escape hatch: `SIGIT_MCP=off` disables MCP entirely, including
420 /// servers an ACP client supplies per session.
421 fn disabled_by_env() -> bool {
422 std::env::var("SIGIT_MCP").is_ok_and(|value| {
423 matches!(
424 value.trim().to_ascii_lowercase().as_str(),
425 "off" | "0" | "false" | "no" | "disabled"
426 )
427 })
428 }
429
430 /// Resolve the full set of servers to connect to: the baked-in official server
431 /// (unless opted out) plus any from `mcp.toml`. Project-local entries override
432 /// global ones, and a user entry named `sigit` overrides the official default.
433 fn load_configs() -> Vec<ServerDef> {
434 if disabled_by_env() {
435 log::info!("mcp: disabled via SIGIT_MCP");
436 return Vec::new();
437 }
438
439 let mut include_official = true;
440 let mut include_smbcloud = true;
441 let mut include_ascapi = true;
442 // De-duplicated by sanitized name; a later config file overrides an earlier
443 // one for the same name (project-local wins over global).
444 let mut defs: Vec<ServerDef> = Vec::new();
445
446 for path in config_paths() {
447 let Ok(contents) = std::fs::read_to_string(&path) else {
448 continue;
449 };
450 let parsed: McpFile = match toml::from_str(&contents) {
451 Ok(parsed) => parsed,
452 Err(error) => {
453 log::warn!("mcp: ignoring {}: {error}", path.display());
454 continue;
455 }
456 };
457 if let Some(official) = parsed.official {
458 include_official = official;
459 }
460 if let Some(smbcloud) = parsed.smbcloud {
461 include_smbcloud = smbcloud;
462 }
463 if let Some(ascapi) = parsed.ascapi {
464 include_ascapi = ascapi;
465 }
466 for entry in parsed.server {
467 if entry.enabled == Some(false) {
468 continue;
469 }
470 let name = sanitize(&entry.name);
471 if name.is_empty() {
472 log::warn!("mcp: skipping server with empty name in {}", path.display());
473 continue;
474 }
475 let transport = match entry.transport_def() {
476 Ok(transport) => transport,
477 Err(error) => {
478 log::warn!(
479 "mcp: skipping server '{name}' in {}: {error}",
480 path.display()
481 );
482 continue;
483 }
484 };
485 upsert(
486 &mut defs,
487 ServerDef {
488 name,
489 transport,
490 follows_session: false,
491 },
492 );
493 }
494 }
495
496 // The official server can also be disabled with SIGIT_MCP_OFFICIAL=off.
497 if let Ok(value) = std::env::var("SIGIT_MCP_OFFICIAL")
498 && matches!(
499 value.trim().to_ascii_lowercase().as_str(),
500 "off" | "0" | "false" | "no"
501 )
502 {
503 include_official = false;
504 }
505
506 // Add the baked-in official server, but never clobber a user-defined entry
507 // named `sigit` — an explicit config (e.g. a custom URL or headers) wins.
508 if include_official && !defs.iter().any(|d| d.name == OFFICIAL_SERVER_NAME) {
509 defs.push(ServerDef {
510 name: OFFICIAL_SERVER_NAME.to_string(),
511 transport: TransportDef::Http {
512 url: official_url(),
513 headers: Vec::new(),
514 },
515 follows_session: true,
516 });
517 }
518
519 // The smbCloud server can also be disabled with SIGIT_MCP_SMBCLOUD=off.
520 if let Ok(value) = std::env::var("SIGIT_MCP_SMBCLOUD")
521 && matches!(
522 value.trim().to_ascii_lowercase().as_str(),
523 "off" | "0" | "false" | "no"
524 )
525 {
526 include_smbcloud = false;
527 }
528
529 // Add the baked-in smbCloud CLI server, with the same never-clobber rule as
530 // the official one. Only when `smb` is actually installed: a hardwired
531 // entry for a binary most users don't have would surface a spawn failure
532 // in `/mcp` instead of just staying out of the way.
533 if include_smbcloud && !defs.iter().any(|d| d.name == SMBCLOUD_SERVER_NAME) {
534 if on_path(SMBCLOUD_COMMAND) {
535 defs.push(ServerDef {
536 name: SMBCLOUD_SERVER_NAME.to_string(),
537 transport: TransportDef::Stdio {
538 command: SMBCLOUD_COMMAND.to_string(),
539 args: vec!["--mcp".to_string()],
540 env: Vec::new(),
541 },
542 follows_session: false,
543 });
544 } else {
545 log::debug!("mcp: `{SMBCLOUD_COMMAND}` not on PATH; skipping the smbcloud server");
546 }
547 }
548
549 // The Ascapi server can also be disabled with SIGIT_MCP_ASCAPI=off.
550 if let Ok(value) = std::env::var("SIGIT_MCP_ASCAPI")
551 && matches!(
552 value.trim().to_ascii_lowercase().as_str(),
553 "off" | "0" | "false" | "no"
554 )
555 {
556 include_ascapi = false;
557 }
558
559 // Add the baked-in Ascapi server (`asc --mcp`), with the same
560 // never-clobber rule as the others. Only when `asc` is actually
561 // installed: a hardwired entry for a binary most users don't have
562 // would surface a spawn failure in `/mcp` instead of just staying out
563 // of the way.
564 if include_ascapi && !defs.iter().any(|d| d.name == ASCAPI_SERVER_NAME) {
565 if on_path(ASCAPI_COMMAND) {
566 defs.push(ServerDef {
567 name: ASCAPI_SERVER_NAME.to_string(),
568 transport: TransportDef::Stdio {
569 command: ASCAPI_COMMAND.to_string(),
570 args: vec!["--mcp".to_string()],
571 env: Vec::new(),
572 },
573 follows_session: false,
574 });
575 } else {
576 log::debug!("mcp: `{ASCAPI_COMMAND}` not on PATH; skipping the Ascapi server");
577 }
578 }
579
580 defs
581 }
582
583 /// Whether `binary` resolves to an executable file on `PATH` (with the
584 /// platform's executable suffix, `.exe` on Windows).
585 fn on_path(binary: &str) -> bool {
586 let Some(path) = std::env::var_os("PATH") else {
587 return false;
588 };
589 let file = format!("{binary}{}", std::env::consts::EXE_SUFFIX);
590 std::env::split_paths(&path).any(|dir| !dir.as_os_str().is_empty() && dir.join(&file).is_file())
591 }
592
593 /// Insert `def`, replacing any existing entry with the same name.
594 fn upsert(defs: &mut Vec<ServerDef>, def: ServerDef) {
595 if let Some(slot) = defs.iter_mut().find(|d| d.name == def.name) {
596 *slot = def;
597 } else {
598 defs.push(def);
599 }
600 }
601
602 /// Sanitize a name into the `[a-zA-Z0-9_-]` set tool names are restricted to,
603 /// collapsing anything else to `_`.
604 fn sanitize(raw: &str) -> String {
605 raw.trim()
606 .chars()
607 .map(|c| {
608 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
609 c
610 } else {
611 '_'
612 }
613 })
614 .collect()
615 }
616
617 // ── Startup / discovery ─────────────────────────────────────────────────────
618
619 /// Connect to every configured server and cache the tools they expose. Idempotent
620 /// and best-effort: a server that can't be reached is recorded with its error and
621 /// contributes no tools. Safe to call from either entry point; only the first
622 /// call does work.
623 pub async fn init() {
624 if MCP.get().is_some() {
625 return;
626 }
627
628 // Contact servers concurrently so one slow/unreachable host doesn't serialize
629 // the rest. Each handshake is bounded by HANDSHAKE_TIMEOUT.
630 let servers = futures::future::join_all(load_configs().into_iter().map(connect)).await;
631
632 for server in &servers {
633 match server.error() {
634 Some(error) => log::warn!("mcp: server '{}' unavailable: {error}", server.name),
635 None => log::info!(
636 "mcp: server '{}' ready, {} tool(s)",
637 server.name,
638 server.tools().len()
639 ),
640 }
641 }
642
643 let _ = MCP.set(Mcp { servers });
644 }
645
646 /// Sent to every server as `clientInfo`.
647 fn client_info() -> ClientInfo {
648 ClientInfo::new("sigit", env!("CARGO_PKG_VERSION"))
649 }
650
651 /// Run the handshake against one server and collect its tools. Always returns a
652 /// `ServerConn`; failures land in its `error` field rather than propagating.
653 async fn connect(def: ServerDef) -> ServerConn {
654 let endpoint = def.transport.endpoint();
655 let token = def.session_token();
656 let (live, tools, error) = match Connection::connect(
657 &def.spec_with(token.as_deref()),
658 &client_info(),
659 HANDSHAKE_TIMEOUT,
660 )
661 .await
662 {
663 Ok(conn) => {
664 let tools = server_tools(&def.name, &conn);
665 (
666 Some(Live {
667 conn: Arc::new(conn),
668 token,
669 }),
670 tools,
671 None,
672 )
673 }
674 Err(error) => (None, Vec::new(), Some(format!("{error:#}"))),
675 };
676 ServerConn {
677 name: def.name.clone(),
678 endpoint,
679 def,
680 conn: RwLock::new(live),
681 tools: StdRwLock::new(tools),
682 error: StdRwLock::new(error),
683 }
684 }
685
686 /// The tools `conn` listed, in siGit's flattened form.
687 fn server_tools(server: &str, conn: &Connection) -> Vec<McpTool> {
688 conn.tools()
689 .iter()
690 .map(|tool| {
691 let remote_name = tool.name.to_string();
692 let full_name = format!("{MCP_PREFIX}{server}__{}", sanitize(&remote_name));
693 if full_name.chars().count() > 64 {
694 log::warn!(
695 "mcp: tool name '{full_name}' exceeds 64 chars; some backends may reject it"
696 );
697 }
698 let remote_desc = tool.description.as_deref().unwrap_or("").trim();
699 let description = if remote_desc.is_empty() {
700 format!("[MCP server '{server}'] {remote_name}")
701 } else {
702 format!("[MCP server '{server}'] {remote_desc}")
703 };
704 let mut schema = Value::Object((*tool.input_schema).clone());
705 if schema.get("type").is_none() {
706 schema["type"] = json!("object");
707 }
708 McpTool {
709 full_name,
710 remote_name,
711 description,
712 parameters_schema: schema.to_string(),
713 }
714 })
715 .collect()
716 }
717
718 // ── Client-supplied servers (per session) ───────────────────────────────────
719
720 /// A server an ACP client passed in `mcpServers` on a session request.
721 #[derive(Debug, Clone)]
722 pub struct ClientServer {
723 pub name: String,
724 pub transport: ClientTransport,
725 }
726
727 /// How a client-supplied server is reached. stdio is the transport every ACP
728 /// agent must support; Streamable HTTP is the optional one siGit Code
729 /// advertises through `mcpCapabilities.http`.
730 #[derive(Debug, Clone)]
731 pub enum ClientTransport {
732 Stdio {
733 command: String,
734 args: Vec<String>,
735 env: Vec<(String, String)>,
736 },
737 Http {
738 url: String,
739 headers: Vec<(String, String)>,
740 },
741 }
742
743 /// The MCP servers one ACP session brought with it, connected.
744 ///
745 /// Unlike the startup servers in [`MCP`], these belong to a session: the
746 /// client names them on `session/new` (or load/fork) and they are offered to
747 /// that session only. Cheap to clone; the connections are shared. When the
748 /// last clone goes away the connections close, which stops stdio children.
749 #[derive(Clone, Default)]
750 pub struct SessionServers(Arc<Vec<ServerConn>>);
751
752 impl std::fmt::Debug for SessionServers {
753 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754 f.debug_list()
755 .entries(self.0.iter().map(|server| &server.name))
756 .finish()
757 }
758 }
759
760 /// The live session's client-supplied servers. One process serves every
761 /// thread the editor has open, so like the roots in `workspace.rs` this
762 /// always belongs to the session `main.rs` has installed.
763 static SESSION_SERVERS: StdMutex<Option<SessionServers>> = StdMutex::new(None);
764
765 /// Connect to the servers a client supplied for one session. Best effort, like
766 /// [`init`]: a server that fails its handshake is kept with its error so `/mcp`
767 /// can show it, and contributes no tools.
768 ///
769 /// A name already taken by a startup server (or by an earlier entry in the same
770 /// list) is not connected. Its tools would share the `mcp__<server>__` prefix
771 /// with the other server's, and a call could not be routed.
772 pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServers {
773 if servers.is_empty() {
774 return SessionServers::default();
775 }
776 if disabled_by_env() {
777 log::info!(
778 "mcp: ignoring {} client-supplied server(s), disabled via SIGIT_MCP",
779 servers.len()
780 );
781 return SessionServers::default();
782 }
783
784 let mut taken: Vec<String> = MCP
785 .get()
786 .map(|mcp| mcp.servers.iter().map(|s| s.name.clone()).collect())
787 .unwrap_or_default();
788
789 let mut clashes = Vec::new();
790 let mut defs = Vec::new();
791 for server in servers {
792 let name = sanitize(&server.name);
793 let transport = match server.transport {
794 ClientTransport::Stdio { command, args, env } => {
795 TransportDef::Stdio { command, args, env }
796 }
797 ClientTransport::Http { url, headers } => TransportDef::Http { url, headers },
798 };
799 if name.is_empty() || taken.contains(&name) {
800 clashes.push(ServerConn {
801 endpoint: transport.endpoint(),
802 error: StdRwLock::new(Some(if name.is_empty() {
803 "the client sent a server with no name".to_string()
804 } else {
805 format!("another MCP server is already named '{name}'")
806 })),
807 def: ServerDef {
808 name: name.clone(),
809 transport,
810 follows_session: false,
811 },
812 conn: RwLock::new(None),
813 tools: StdRwLock::new(Vec::new()),
814 name,
815 });
816 continue;
817 }
818 taken.push(name.clone());
819 defs.push(ServerDef {
820 name,
821 transport,
822 follows_session: false,
823 });
824 }
825
826 let mut connected = futures::future::join_all(defs.into_iter().map(connect)).await;
827 connected.extend(clashes);
828
829 for server in &connected {
830 match server.error() {
831 Some(error) => log::warn!(
832 "mcp: client-supplied server '{}' unavailable: {error}",
833 server.name
834 ),
835 None => log::info!(
836 "mcp: client-supplied server '{}' ready, {} tool(s)",
837 server.name,
838 server.tools().len()
839 ),
840 }
841 }
842
843 SessionServers(Arc::new(connected))
844 }
845
846 /// Make `servers` the live session's set. Called whenever `main.rs` installs a
847 /// session, so a thread never sees another thread's servers.
848 pub fn set_session_servers(servers: SessionServers) {
849 *SESSION_SERVERS.lock().unwrap() = Some(servers);
850 }
851
852 fn session_servers() -> SessionServers {
853 SESSION_SERVERS.lock().unwrap().clone().unwrap_or_default()
854 }
855
856 // ── Tool exposure + dispatch ────────────────────────────────────────────────
857
858 /// Whether a tool name belongs to MCP. The dispatch in `tools::execute_tool`
859 /// uses this to route a call here.
860 pub fn is_mcp_tool(name: &str) -> bool {
861 name.starts_with(MCP_PREFIX)
862 }
863
864 /// All discovered MCP tools as agent [`ToolSpec`]s, ready to append to the
865 /// built-in tool list: the startup servers' tools, then the ones the live
866 /// session's client supplied. Empty when no server exposed any.
867 pub fn tool_specs() -> Vec<ToolSpec> {
868 let session = session_servers();
869 let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
870 let mut specs = Vec::new();
871 for server in startup.iter().chain(session.0.iter()) {
872 for tool in server.tools().iter() {
873 specs.push(ToolSpec {
874 name: tool.full_name.clone(),
875 description: tool.description.clone(),
876 parameters_schema: tool.parameters_schema.clone(),
877 });
878 }
879 }
880 specs
881 }
882
883 /// Execute an MCP tool call by name, returning text to feed back to the model.
884 /// Errors are returned as plain strings (never panics) so a failing tool degrades
885 /// to a message the model can react to, exactly like the built-in tools.
886 pub async fn call_tool(full_name: &str, arguments: &str) -> String {
887 let session = session_servers();
888 let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
889 if MCP.get().is_none() && session.0.is_empty() {
890 return "Error: MCP is not initialized.".to_string();
891 }
892
893 let Some((server, remote_name)) = startup.iter().chain(session.0.iter()).find_map(|s| {
894 s.tools()
895 .iter()
896 .find(|t| t.full_name == full_name)
897 .map(|t| (s, t.remote_name.clone()))
898 }) else {
899 return format!("Error: unknown MCP tool \"{full_name}\".");
900 };
901
902 // Arguments arrive as a JSON-encoded string; an empty/blank string means no
903 // arguments. Anything that isn't a JSON object is a model mistake.
904 let args: Value = if arguments.trim().is_empty() {
905 json!({})
906 } else {
907 match serde_json::from_str(arguments) {
908 Ok(value @ Value::Object(_)) => value,
909 Ok(_) => return "Error: tool arguments must be a JSON object.".to_string(),
910 Err(error) => return format!("Error: failed to parse arguments: {error}"),
911 }
912 };
913
914 match call_server(server, &remote_name, args).await {
915 Ok(text) => truncate(text),
916 Err(error) => format!("Error: {error}"),
917 }
918 }
919
920 /// Send a `tools/call` and render the result into text. An HTTP server whose
921 /// call fails for any reason but a timeout is reconnected and the call retried
922 /// once: that is how a restarted server or an expired session recovers.
923 async fn call_server(
924 server: &ServerConn,
925 remote_name: &str,
926 args: Value,
927 ) -> Result<String, String> {
928 let timeout = if server.name == "xcode" {
929 XCODE_CALL_TIMEOUT
930 } else {
931 CALL_TIMEOUT
932 };
933 let kind = if server.def.is_stdio() {
934 "stdio server"
935 } else {
936 "server"
937 };
938 // Reconnect before sending if the signed-in account changed, so a call is
939 // never made with the previous account's bearer.
940 if server.session_changed().await
941 && let Err(error) = reconnect(server).await
942 {
943 return Err(format!("{kind} '{}': {error}", server.name));
944 }
945 let Some(conn) = server
946 .conn
947 .read()
948 .await
949 .as_ref()
950 .map(|live| live.conn.clone())
951 else {
952 return Err(format!("{kind} '{}' is not connected", server.name));
953 };
954 let result = match conn.call_tool(remote_name, args.clone(), timeout).await {
955 Ok(result) => result,
956 Err(error) if server.def.is_stdio() || error.to_string().contains("timed out") => {
957 return Err(format!("{kind} '{}': {error:#}", server.name));
958 }
959 Err(error) => {
960 log::info!("mcp: '{}' failed ({error:#}); reconnecting", server.name);
961 let fresh = reconnect(server).await.map_err(|again| {
962 format!(
963 "server '{}': {error:#}; reconnecting failed: {again}",
964 server.name
965 )
966 })?;
967 fresh
968 .call_tool(remote_name, args, timeout)
969 .await
970 .map_err(|error| format!("server '{}': {error:#}", server.name))?
971 }
972 };
973 Ok(render_tool_result(&result))
974 }
975
976 impl ServerConn {
977 /// Whether this server follows the signed-in account and the stored token
978 /// is no longer the one its connection was opened with (or it has none).
979 async fn session_changed(&self) -> bool {
980 if !self.def.follows_session {
981 return false;
982 }
983 let current = crate::credentials::load_token();
984 match self.conn.read().await.as_ref() {
985 Some(live) => live.token != current,
986 None => true,
987 }
988 }
989 }
990
991 /// Open a fresh connection with the current spec and swap it in, along with
992 /// the tools it lists and the handshake outcome.
993 async fn reconnect(server: &ServerConn) -> Result<Arc<Connection>, String> {
994 let token = server.def.session_token();
995 let result = Connection::connect(
996 &server.def.spec_with(token.as_deref()),
997 &client_info(),
998 HANDSHAKE_TIMEOUT,
999 )
1000 .await;
1001 match result {
1002 Ok(conn) => {
1003 let conn = Arc::new(conn);
1004 *server.tools.write().unwrap_or_else(|e| e.into_inner()) =
1005 server_tools(&server.name, &conn);
1006 *server.error.write().unwrap_or_else(|e| e.into_inner()) = None;
1007 *server.conn.write().await = Some(Live {
1008 conn: conn.clone(),
1009 token,
1010 });
1011 Ok(conn)
1012 }
1013 Err(error) => {
1014 let message = format!("{error:#}");
1015 // Drop the old connection: it carries a bearer that no longer
1016 // matches the stored account.
1017 if server.def.follows_session {
1018 *server.conn.write().await = None;
1019 server
1020 .tools
1021 .write()
1022 .unwrap_or_else(|e| e.into_inner())
1023 .clear();
1024 }
1025 *server.error.write().unwrap_or_else(|e| e.into_inner()) = Some(message.clone());
1026 Err(message)
1027 }
1028 }
1029 }
1030
1031 /// Reconnect the official server if the stored sign-in changed since it
1032 /// connected, so its tool list and `/mcp` entry update right away. No-op when
1033 /// MCP isn't initialized or the token is unchanged.
1034 pub async fn refresh_session_auth() {
1035 let Some(mcp) = MCP.get() else {
1036 return;
1037 };
1038 for server in mcp.servers.iter().filter(|s| s.def.follows_session) {
1039 if server.session_changed().await {
1040 log::info!("mcp: '{}' sign-in changed; reconnecting", server.name);
1041 if let Err(error) = reconnect(server).await {
1042 log::warn!("mcp: server '{}' unavailable: {error}", server.name);
1043 }
1044 }
1045 }
1046 }
1047
1048 /// Flatten an MCP `tools/call` result into text. Joins text content blocks;
1049 /// notes non-text blocks; honors `isError`.
1050 fn render_tool_result(result: &Value) -> String {
1051 let mut out = String::new();
1052 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
1053 for block in blocks {
1054 match block.get("type").and_then(Value::as_str) {
1055 Some("text") => {
1056 if let Some(text) = block.get("text").and_then(Value::as_str) {
1057 if !out.is_empty() {
1058 out.push('\n');
1059 }
1060 out.push_str(text);
1061 }
1062 }
1063 Some(other) => {
1064 if !out.is_empty() {
1065 out.push('\n');
1066 }
1067 out.push_str(&format!("[{other} content omitted]"));
1068 }
1069 None => {}
1070 }
1071 }
1072 }
1073
1074 // Some servers return only `structuredContent`; surface it if there was no
1075 // textual content.
1076 if out.is_empty()
1077 && let Some(structured) = result.get("structuredContent")
1078 {
1079 out = structured.to_string();
1080 }
1081
1082 if out.is_empty() {
1083 out = "(tool returned no content)".to_string();
1084 }
1085
1086 if result.get("isError").and_then(Value::as_bool) == Some(true) {
1087 format!("Tool reported an error:\n{out}")
1088 } else {
1089 out
1090 }
1091 }
1092
1093 /// Truncate tool output to the context-protecting limit, with a trailing note.
1094 fn truncate(text: String) -> String {
1095 if text.chars().count() <= RESULT_CHAR_LIMIT {
1096 return text;
1097 }
1098 let kept: String = text.chars().take(RESULT_CHAR_LIMIT).collect();
1099 format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]")
1100 }
1101
1102 // ── Status reporting (`/mcp`) ────────────────────────────────────────────────
1103
1104 /// Human-readable summary of configured MCP servers and their tools, for the
1105 /// `/mcp` slash command. Shows the URL for HTTP servers and the command line
1106 /// for stdio servers.
1107 pub fn status_summary() -> String {
1108 let session = session_servers();
1109 let Some(mcp) = MCP.get() else {
1110 if session.0.is_empty() {
1111 return "MCP is not initialized.".to_string();
1112 }
1113 return summarize_servers(&[], &session.0);
1114 };
1115 if mcp.servers.is_empty() && session.0.is_empty() {
1116 return "No MCP servers configured. Add one in ~/.config/sigit/mcp.toml \
1117 or .sigit/mcp.toml. See https://modelcontextprotocol.io."
1118 .to_string();
1119 }
1120 summarize_servers(&mcp.servers, &session.0)
1121 }
1122
1123 /// Counts for the launch checklist: configured startup servers, the tools they
1124 /// expose, and how many could not be reached. `None` before [`init`] has run.
1125 #[cfg(unix)]
1126 pub struct Overview {
1127 pub servers: usize,
1128 pub tools: usize,
1129 pub unavailable: usize,
1130 }
1131
1132 #[cfg(unix)]
1133 pub fn overview() -> Option<Overview> {
1134 let mcp = MCP.get()?;
1135 Some(Overview {
1136 servers: mcp.servers.len(),
1137 tools: mcp.servers.iter().map(|s| s.tools().len()).sum(),
1138 unavailable: mcp.servers.iter().filter(|s| s.error().is_some()).count(),
1139 })
1140 }
1141
1142 /// The `/mcp` listing for the startup servers followed by the ones the live
1143 /// session's client supplied, which are marked as such.
1144 fn summarize_servers(startup: &[ServerConn], session: &[ServerConn]) -> String {
1145 let all = || startup.iter().chain(session.iter());
1146 let total_tools: usize = all().map(|s| s.tools().len()).sum();
1147 let mut lines = vec![format!(
1148 "{} MCP server(s), {total_tools} tool(s) available:",
1149 all().count()
1150 )];
1151 for (index, server) in all().enumerate() {
1152 let origin = if index >= startup.len() {
1153 ", from the editor"
1154 } else {
1155 ""
1156 };
1157 match server.error() {
1158 Some(error) => lines.push(format!(
1159 "- {} ({}{origin}) — unavailable: {error}",
1160 server.name, server.endpoint
1161 )),
1162 None => {
1163 lines.push(format!(
1164 "- {} ({}{origin}) — {} tool(s)",
1165 server.name,
1166 server.endpoint,
1167 server.tools().len()
1168 ));
1169 for tool in server.tools().iter() {
1170 lines.push(format!(" • {}", tool.full_name));
1171 }
1172 }
1173 }
1174 }
1175 lines.join("\n")
1176 }
1177
1178 #[cfg(test)]
1179 mod tests {
1180 use super::*;
1181
1182 #[test]
1183 fn is_mcp_tool_detects_prefix() {
1184 assert!(is_mcp_tool("mcp__sigit__search"));
1185 assert!(!is_mcp_tool("read_file"));
1186 assert!(!is_mcp_tool("skill"));
1187 }
1188
1189 #[test]
1190 fn official_tool_name_matches_the_namespacing_convention() {
1191 assert_eq!(official_tool_name("list_issues"), "mcp__sigit__list_issues");
1192 assert_eq!(
1193 official_tool_name("get_pull_request"),
1194 "mcp__sigit__get_pull_request"
1195 );
1196 }
1197
1198 #[test]
1199 fn official_tool_suffix_strips_only_the_official_namespace() {
1200 assert_eq!(
1201 official_tool_suffix("mcp__sigit__list_issues"),
1202 Some("list_issues")
1203 );
1204 assert_eq!(official_tool_suffix("mcp__other__list_issues"), None);
1205 // `sigit` must be the whole server name, not a prefix of it.
1206 assert_eq!(official_tool_suffix("mcp__sigitx__list_issues"), None);
1207 assert_eq!(official_tool_suffix("list_issues"), None);
1208 assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
1209 }
1210
1211 #[test]
1212 fn smbcloud_tool_suffix_strips_only_the_smbcloud_namespace() {
1213 assert_eq!(
1214 smbcloud_tool_suffix("mcp__smbcloud__project_list"),
1215 Some("project_list")
1216 );
1217 assert_eq!(smbcloud_tool_suffix("mcp__sigit__project_list"), None);
1218 // `smbcloud` must be the whole server name, not a prefix of it.
1219 assert_eq!(smbcloud_tool_suffix("mcp__smbcloudx__project_list"), None);
1220 assert_eq!(smbcloud_tool_suffix("project_list"), None);
1221 assert_eq!(smbcloud_tool_suffix("mcp__smbcloud__"), Some(""));
1222 }
1223
1224 #[test]
1225 fn ascapi_tool_suffix_strips_only_the_ascapi_namespace() {
1226 assert_eq!(
1227 ascapi_tool_suffix("mcp__ascapi__app_list"),
1228 Some("app_list")
1229 );
1230 assert_eq!(ascapi_tool_suffix("mcp__sigit__app_list"), None);
1231 // `ascapi` must be the whole server name, not a prefix of it.
1232 assert_eq!(ascapi_tool_suffix("mcp__ascapix__app_list"), None);
1233 assert_eq!(ascapi_tool_suffix("app_list"), None);
1234 assert_eq!(ascapi_tool_suffix("mcp__ascapi__"), Some(""));
1235 }
1236
1237 #[test]
1238 fn sanitize_collapses_invalid_chars() {
1239 assert_eq!(sanitize("github"), "github");
1240 assert_eq!(sanitize("my server"), "my_server");
1241 assert_eq!(sanitize("a.b/c:d"), "a_b_c_d");
1242 assert_eq!(sanitize("keep-_ok9"), "keep-_ok9");
1243 }
1244
1245 #[test]
1246 fn parses_mcp_file_with_servers() {
1247 let toml = r#"
1248 official = false
1249
1250 [[server]]
1251 name = "github"
1252 url = "https://api.example.com/mcp"
1253
1254 [[server]]
1255 name = "disabled-one"
1256 url = "https://nope.example.com/mcp"
1257 enabled = false
1258
1259 [server.headers]
1260 Authorization = "Bearer xyz"
1261 "#;
1262 let parsed: McpFile = toml::from_str(toml).unwrap();
1263 assert_eq!(parsed.official, Some(false));
1264 assert_eq!(parsed.smbcloud, None);
1265 assert_eq!(parsed.server.len(), 2);
1266 assert_eq!(parsed.server[0].name, "github");
1267 assert_eq!(parsed.server[1].enabled, Some(false));
1268 assert_eq!(
1269 parsed.server[1]
1270 .headers
1271 .get("Authorization")
1272 .map(String::as_str),
1273 Some("Bearer xyz")
1274 );
1275 }
1276
1277 #[test]
1278 fn parses_smbcloud_opt_out_flag() {
1279 let parsed: McpFile = toml::from_str("smbcloud = false").unwrap();
1280 assert_eq!(parsed.smbcloud, Some(false));
1281 // Absent means "include" (the default stays true in load_configs).
1282 let parsed: McpFile = toml::from_str("").unwrap();
1283 assert_eq!(parsed.smbcloud, None);
1284 }
1285
1286 #[test]
1287 fn parses_ascapi_opt_out_flag() {
1288 let parsed: McpFile = toml::from_str("ascapi = false").unwrap();
1289 assert_eq!(parsed.ascapi, Some(false));
1290 // Absent means "include" (the default stays true in load_configs).
1291 let parsed: McpFile = toml::from_str("").unwrap();
1292 assert_eq!(parsed.ascapi, None);
1293 }
1294
1295 #[test]
1296 fn on_path_finds_real_binaries_only() {
1297 assert!(!on_path("definitely-not-a-real-binary-xyzzy"));
1298 #[cfg(unix)]
1299 assert!(on_path("sh"));
1300 }
1301
1302 #[test]
1303 fn parses_stdio_server_with_args_and_env() {
1304 let toml = r#"
1305 [[server]]
1306 name = "fs"
1307 command = "npx"
1308 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1309
1310 [server.env]
1311 LOG_LEVEL = "debug"
1312 TOKEN = "abc"
1313 "#;
1314 let parsed: McpFile = toml::from_str(toml).unwrap();
1315 assert_eq!(parsed.server.len(), 1);
1316 let entry = &parsed.server[0];
1317 assert_eq!(entry.command.as_deref(), Some("npx"));
1318 assert_eq!(entry.args.len(), 3);
1319 assert_eq!(
1320 entry.env.get("LOG_LEVEL").map(String::as_str),
1321 Some("debug")
1322 );
1323 assert_eq!(entry.env.get("TOKEN").map(String::as_str), Some("abc"));
1324
1325 let def = entry.transport_def().expect("valid stdio entry");
1326 match def {
1327 TransportDef::Stdio { command, args, env } => {
1328 assert_eq!(command, "npx");
1329 assert_eq!(args[0], "-y");
1330 assert_eq!(env.len(), 2);
1331 }
1332 TransportDef::Http { .. } => panic!("expected a stdio transport"),
1333 }
1334 }
1335
1336 #[test]
1337 fn entry_with_url_and_command_is_a_config_error() {
1338 let toml = r#"
1339 [[server]]
1340 name = "confused"
1341 url = "https://example.com/mcp"
1342 command = "npx"
1343 "#;
1344 let parsed: McpFile = toml::from_str(toml).unwrap();
1345 let error = parsed.server[0].transport_def().unwrap_err();
1346 assert!(error.contains("both"), "unexpected error: {error}");
1347 }
1348
1349 #[test]
1350 fn entry_with_neither_url_nor_command_is_a_config_error() {
1351 let toml = r#"
1352 [[server]]
1353 name = "empty"
1354 "#;
1355 let parsed: McpFile = toml::from_str(toml).unwrap();
1356 let error = parsed.server[0].transport_def().unwrap_err();
1357 assert!(error.contains("needs"), "unexpected error: {error}");
1358 }
1359
1360 #[test]
1361 fn blank_url_or_command_counts_as_absent() {
1362 let toml = r#"
1363 [[server]]
1364 name = "blank"
1365 url = " "
1366 command = "server-bin"
1367 "#;
1368 let parsed: McpFile = toml::from_str(toml).unwrap();
1369 // A blank url is treated as absent, so this resolves to stdio.
1370 match parsed.server[0].transport_def().expect("stdio") {
1371 TransportDef::Stdio { command, .. } => assert_eq!(command, "server-bin"),
1372 TransportDef::Http { .. } => panic!("expected stdio"),
1373 }
1374 }
1375
1376 #[test]
1377 fn endpoint_renders_url_or_command_line() {
1378 let http = TransportDef::Http {
1379 url: "https://example.com/mcp".into(),
1380 headers: vec![],
1381 };
1382 assert_eq!(http.endpoint(), "https://example.com/mcp");
1383
1384 let stdio = TransportDef::Stdio {
1385 command: "npx".into(),
1386 args: vec!["-y".into(), "server-fs".into()],
1387 env: vec![],
1388 };
1389 assert_eq!(stdio.endpoint(), "npx -y server-fs");
1390 }
1391
1392 #[test]
1393 fn upsert_replaces_same_name() {
1394 let mut defs = vec![ServerDef {
1395 name: "a".into(),
1396 transport: TransportDef::Http {
1397 url: "u1".into(),
1398 headers: vec![],
1399 },
1400 follows_session: false,
1401 }];
1402 upsert(
1403 &mut defs,
1404 ServerDef {
1405 name: "a".into(),
1406 transport: TransportDef::Http {
1407 url: "u2".into(),
1408 headers: vec![],
1409 },
1410 follows_session: false,
1411 },
1412 );
1413 assert_eq!(defs.len(), 1);
1414 assert_eq!(defs[0].transport.endpoint(), "u2");
1415 }
1416
1417 #[test]
1418 fn render_result_joins_text_blocks() {
1419 let result = json!({
1420 "content": [
1421 { "type": "text", "text": "line one" },
1422 { "type": "text", "text": "line two" }
1423 ]
1424 });
1425 assert_eq!(render_tool_result(&result), "line one\nline two");
1426 }
1427
1428 #[test]
1429 fn render_result_marks_errors_and_non_text() {
1430 let result = json!({
1431 "isError": true,
1432 "content": [
1433 { "type": "text", "text": "boom" },
1434 { "type": "image", "data": "..." }
1435 ]
1436 });
1437 let rendered = render_tool_result(&result);
1438 assert!(rendered.starts_with("Tool reported an error:"));
1439 assert!(rendered.contains("boom"));
1440 assert!(rendered.contains("[image content omitted]"));
1441 }
1442
1443 #[test]
1444 fn render_result_falls_back_to_structured_content() {
1445 let result = json!({ "structuredContent": { "value": 42 } });
1446 assert!(render_tool_result(&result).contains("42"));
1447 }
1448
1449 #[test]
1450 fn truncate_caps_long_output() {
1451 let long = "x".repeat(RESULT_CHAR_LIMIT + 100);
1452 let out = truncate(long);
1453 assert!(out.contains("[output truncated"));
1454 }
1455
1456 #[test]
1457 fn tool_specs_empty_before_init() {
1458 // Without init() the global is unset; this must not panic.
1459 assert!(super::tool_specs().is_empty() || MCP.get().is_some());
1460 }
1461
1462 #[test]
1463 fn the_official_server_follows_the_session_without_a_static_header() {
1464 let defs = load_configs();
1465 let Some(official) = defs.iter().find(|d| d.name == OFFICIAL_SERVER_NAME) else {
1466 // SIGIT_MCP or SIGIT_MCP_OFFICIAL is set in this environment.
1467 return;
1468 };
1469 assert!(official.follows_session);
1470 match &official.transport {
1471 TransportDef::Http { headers, .. } => assert!(
1472 !headers
1473 .iter()
1474 .any(|(key, _)| key.eq_ignore_ascii_case("authorization"))
1475 ),
1476 TransportDef::Stdio { .. } => panic!("the official server is HTTP"),
1477 }
1478 }
1479
1480 #[test]
1481 fn a_user_defined_sigit_entry_keeps_its_header_and_does_not_follow() {
1482 let file: McpFile = toml::from_str(
1483 r#"
1484 [[server]]
1485 name = "sigit"
1486 url = "https://example.com/mcp"
1487 headers = { Authorization = "Bearer static" }
1488 "#,
1489 )
1490 .unwrap();
1491 let entry = &file.server[0];
1492 let mut defs = Vec::new();
1493 upsert(
1494 &mut defs,
1495 ServerDef {
1496 name: sanitize(&entry.name),
1497 transport: entry.transport_def().unwrap(),
1498 follows_session: false,
1499 },
1500 );
1501 assert!(!defs[0].follows_session);
1502 assert_eq!(defs[0].session_token(), None);
1503 match &defs[0].transport {
1504 TransportDef::Http { headers, .. } => {
1505 assert_eq!(headers[0].1, "Bearer static");
1506 }
1507 TransportDef::Stdio { .. } => panic!("expected HTTP"),
1508 }
1509 }
1510 }