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};
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<Arc<Connection>>>,
183 /// Tools discovered at startup. Empty when the server failed to connect.
184 tools: Vec<McpTool>,
185 /// Connection error, if the handshake failed. Surfaced by `/mcp`.
186 error: Option<String>,
187 }
188
189 /// The process-global MCP state: every configured server.
190 struct Mcp {
191 servers: Vec<ServerConn>,
192 }
193
194 static MCP: OnceLock<Mcp> = OnceLock::new();
195
196 // ── Configuration ───────────────────────────────────────────────────────────
197
198 /// Default endpoint of the official siGit Code MCP server, derived from the
199 /// cloud base URL so `SIGIT_CLOUD_URL` (dev) carries over.
200 fn official_url() -> String {
201 format!(
202 "{}/mcp",
203 crate::provider::cloud_base_url().trim_end_matches('/')
204 )
205 }
206
207 /// A server entry as written in `mcp.toml`. Exactly one of `url` (Streamable
208 /// HTTP) or `command` (stdio) selects the transport.
209 #[derive(Debug, Deserialize)]
210 struct ServerEntry {
211 name: String,
212 /// Streamable HTTP endpoint. Mutually exclusive with `command`.
213 #[serde(default)]
214 url: Option<String>,
215 /// stdio server executable. Mutually exclusive with `url`.
216 #[serde(default)]
217 command: Option<String>,
218 /// Arguments for `command`.
219 #[serde(default)]
220 args: Vec<String>,
221 /// Extra environment variables for `command`, added on top of the
222 /// inherited environment.
223 #[serde(default)]
224 env: BTreeMap<String, String>,
225 /// Set `enabled = false` to keep an entry in the file but skip connecting.
226 #[serde(default)]
227 enabled: Option<bool>,
228 /// Static headers, e.g. `Authorization = "Bearer ..."`. HTTP only.
229 #[serde(default)]
230 headers: BTreeMap<String, String>,
231 }
232
233 impl ServerEntry {
234 /// Resolve the entry's transport. `url` and `command` are mutually
235 /// exclusive and exactly one is required; anything else is a config error.
236 fn transport_def(&self) -> Result<TransportDef, String> {
237 let url = self.url.as_deref().map(str::trim).filter(|v| !v.is_empty());
238 let command = self
239 .command
240 .as_deref()
241 .map(str::trim)
242 .filter(|v| !v.is_empty());
243 match (url, command) {
244 (Some(_), Some(_)) => {
245 Err("has both `url` and `command`; a server uses exactly one transport".to_string())
246 }
247 (None, None) => {
248 Err("needs either `url` (Streamable HTTP) or `command` (stdio)".to_string())
249 }
250 (Some(url), None) => Ok(TransportDef::Http {
251 url: url.to_string(),
252 headers: self
253 .headers
254 .iter()
255 .map(|(k, v)| (k.clone(), v.clone()))
256 .collect(),
257 }),
258 (None, Some(command)) => Ok(TransportDef::Stdio {
259 command: command.to_string(),
260 args: self.args.clone(),
261 env: self
262 .env
263 .iter()
264 .map(|(k, v)| (k.clone(), v.clone()))
265 .collect(),
266 }),
267 }
268 }
269 }
270
271 /// The `mcp.toml` schema.
272 #[derive(Debug, Default, Deserialize)]
273 struct McpFile {
274 /// Include the baked-in official server. Defaults to `true`; set `false` to
275 /// opt out.
276 #[serde(default)]
277 official: Option<bool>,
278 /// Include the baked-in smbCloud CLI server (`smb --mcp`). Defaults to
279 /// `true`; set `false` to opt out. Moot when `smb` isn't installed.
280 #[serde(default)]
281 smbcloud: Option<bool>,
282 /// Include the baked-in Ascapi server (`asc --mcp`) — Ascapi is the App
283 /// Stores Coolest API. Defaults to `true`; set `false` to opt out. Moot
284 /// when `asc` isn't installed.
285 #[serde(default)]
286 ascapi: Option<bool>,
287 #[serde(default)]
288 server: Vec<ServerEntry>,
289 }
290
291 /// How to reach a configured server, before connecting.
292 #[derive(Debug, Clone)]
293 enum TransportDef {
294 Http {
295 url: String,
296 headers: Vec<(String, String)>,
297 },
298 Stdio {
299 command: String,
300 args: Vec<String>,
301 env: Vec<(String, String)>,
302 },
303 }
304
305 impl TransportDef {
306 /// Human-readable endpoint for logs and the `/mcp` listing: the URL for
307 /// HTTP, the command line for stdio.
308 fn endpoint(&self) -> String {
309 match self {
310 TransportDef::Http { url, .. } => url.clone(),
311 TransportDef::Stdio { command, args, .. } => {
312 let mut line = command.clone();
313 for arg in args {
314 line.push(' ');
315 line.push_str(arg);
316 }
317 line
318 }
319 }
320 }
321 }
322
323 /// A resolved server definition, before connecting.
324 #[derive(Debug, Clone)]
325 struct ServerDef {
326 name: String,
327 transport: TransportDef,
328 }
329
330 impl ServerDef {
331 /// The `ed-mcp` spec for this server. An `Authorization: Bearer` header is
332 /// passed as the bearer token, which is how `rmcp` wants it.
333 fn spec(&self) -> ServerSpec {
334 match &self.transport {
335 TransportDef::Http { url, headers } => {
336 headers
337 .iter()
338 .fold(
339 ServerSpec::http(&self.name, url),
340 |spec, (key, value)| match value.strip_prefix("Bearer ") {
341 Some(token) if key.eq_ignore_ascii_case("authorization") => {
342 spec.with_bearer(token)
343 }
344 _ => spec.with_header(key, value),
345 },
346 )
347 }
348 TransportDef::Stdio { command, args, env } => {
349 ServerSpec::stdio(&self.name, command, args.clone(), env.clone())
350 }
351 }
352 }
353
354 fn is_stdio(&self) -> bool {
355 matches!(self.transport, TransportDef::Stdio { .. })
356 }
357 }
358
359 /// Config files to read, in priority order (later wins on a name clash):
360 /// global `$SIGIT_CONFIG_DIR/mcp.toml`, then project-local `<cwd>/.sigit/mcp.toml`.
361 fn config_paths() -> Vec<PathBuf> {
362 let mut paths = Vec::new();
363 if let Some(dir) = sigit_config_dir() {
364 paths.push(dir.join("mcp.toml"));
365 }
366 if let Ok(cwd) = std::env::current_dir() {
367 paths.push(cwd.join(".sigit").join("mcp.toml"));
368 }
369 paths
370 }
371
372 fn sigit_config_dir() -> Option<PathBuf> {
373 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
374 && !dir.is_empty()
375 {
376 return Some(PathBuf::from(dir));
377 }
378 std::env::var("HOME")
379 .ok()
380 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
381 }
382
383 /// Global escape hatch: `SIGIT_MCP=off` disables MCP entirely, including
384 /// servers an ACP client supplies per session.
385 fn disabled_by_env() -> bool {
386 std::env::var("SIGIT_MCP").is_ok_and(|value| {
387 matches!(
388 value.trim().to_ascii_lowercase().as_str(),
389 "off" | "0" | "false" | "no" | "disabled"
390 )
391 })
392 }
393
394 /// Resolve the full set of servers to connect to: the baked-in official server
395 /// (unless opted out) plus any from `mcp.toml`. Project-local entries override
396 /// global ones, and a user entry named `sigit` overrides the official default.
397 fn load_configs() -> Vec<ServerDef> {
398 if disabled_by_env() {
399 log::info!("mcp: disabled via SIGIT_MCP");
400 return Vec::new();
401 }
402
403 let mut include_official = true;
404 let mut include_smbcloud = true;
405 let mut include_ascapi = true;
406 // De-duplicated by sanitized name; a later config file overrides an earlier
407 // one for the same name (project-local wins over global).
408 let mut defs: Vec<ServerDef> = Vec::new();
409
410 for path in config_paths() {
411 let Ok(contents) = std::fs::read_to_string(&path) else {
412 continue;
413 };
414 let parsed: McpFile = match toml::from_str(&contents) {
415 Ok(parsed) => parsed,
416 Err(error) => {
417 log::warn!("mcp: ignoring {}: {error}", path.display());
418 continue;
419 }
420 };
421 if let Some(official) = parsed.official {
422 include_official = official;
423 }
424 if let Some(smbcloud) = parsed.smbcloud {
425 include_smbcloud = smbcloud;
426 }
427 if let Some(ascapi) = parsed.ascapi {
428 include_ascapi = ascapi;
429 }
430 for entry in parsed.server {
431 if entry.enabled == Some(false) {
432 continue;
433 }
434 let name = sanitize(&entry.name);
435 if name.is_empty() {
436 log::warn!("mcp: skipping server with empty name in {}", path.display());
437 continue;
438 }
439 let transport = match entry.transport_def() {
440 Ok(transport) => transport,
441 Err(error) => {
442 log::warn!(
443 "mcp: skipping server '{name}' in {}: {error}",
444 path.display()
445 );
446 continue;
447 }
448 };
449 upsert(&mut defs, ServerDef { name, transport });
450 }
451 }
452
453 // The official server can also be disabled with SIGIT_MCP_OFFICIAL=off.
454 if let Ok(value) = std::env::var("SIGIT_MCP_OFFICIAL")
455 && matches!(
456 value.trim().to_ascii_lowercase().as_str(),
457 "off" | "0" | "false" | "no"
458 )
459 {
460 include_official = false;
461 }
462
463 // Add the baked-in official server, but never clobber a user-defined entry
464 // named `sigit` — an explicit config (e.g. a custom URL or headers) wins.
465 if include_official && !defs.iter().any(|d| d.name == OFFICIAL_SERVER_NAME) {
466 let mut headers = Vec::new();
467 if let Some(token) = crate::credentials::load_token() {
468 headers.push(("Authorization".to_string(), format!("Bearer {token}")));
469 }
470 defs.push(ServerDef {
471 name: OFFICIAL_SERVER_NAME.to_string(),
472 transport: TransportDef::Http {
473 url: official_url(),
474 headers,
475 },
476 });
477 }
478
479 // The smbCloud server can also be disabled with SIGIT_MCP_SMBCLOUD=off.
480 if let Ok(value) = std::env::var("SIGIT_MCP_SMBCLOUD")
481 && matches!(
482 value.trim().to_ascii_lowercase().as_str(),
483 "off" | "0" | "false" | "no"
484 )
485 {
486 include_smbcloud = false;
487 }
488
489 // Add the baked-in smbCloud CLI server, with the same never-clobber rule as
490 // the official one. Only when `smb` is actually installed: a hardwired
491 // entry for a binary most users don't have would surface a spawn failure
492 // in `/mcp` instead of just staying out of the way.
493 if include_smbcloud && !defs.iter().any(|d| d.name == SMBCLOUD_SERVER_NAME) {
494 if on_path(SMBCLOUD_COMMAND) {
495 defs.push(ServerDef {
496 name: SMBCLOUD_SERVER_NAME.to_string(),
497 transport: TransportDef::Stdio {
498 command: SMBCLOUD_COMMAND.to_string(),
499 args: vec!["--mcp".to_string()],
500 env: Vec::new(),
501 },
502 });
503 } else {
504 log::debug!("mcp: `{SMBCLOUD_COMMAND}` not on PATH; skipping the smbcloud server");
505 }
506 }
507
508 // The Ascapi server can also be disabled with SIGIT_MCP_ASCAPI=off.
509 if let Ok(value) = std::env::var("SIGIT_MCP_ASCAPI")
510 && matches!(
511 value.trim().to_ascii_lowercase().as_str(),
512 "off" | "0" | "false" | "no"
513 )
514 {
515 include_ascapi = false;
516 }
517
518 // Add the baked-in Ascapi server (`asc --mcp`), with the same
519 // never-clobber rule as the others. Only when `asc` is actually
520 // installed: a hardwired entry for a binary most users don't have
521 // would surface a spawn failure in `/mcp` instead of just staying out
522 // of the way.
523 if include_ascapi && !defs.iter().any(|d| d.name == ASCAPI_SERVER_NAME) {
524 if on_path(ASCAPI_COMMAND) {
525 defs.push(ServerDef {
526 name: ASCAPI_SERVER_NAME.to_string(),
527 transport: TransportDef::Stdio {
528 command: ASCAPI_COMMAND.to_string(),
529 args: vec!["--mcp".to_string()],
530 env: Vec::new(),
531 },
532 });
533 } else {
534 log::debug!("mcp: `{ASCAPI_COMMAND}` not on PATH; skipping the Ascapi server");
535 }
536 }
537
538 defs
539 }
540
541 /// Whether `binary` resolves to an executable file on `PATH` (with the
542 /// platform's executable suffix, `.exe` on Windows).
543 fn on_path(binary: &str) -> bool {
544 let Some(path) = std::env::var_os("PATH") else {
545 return false;
546 };
547 let file = format!("{binary}{}", std::env::consts::EXE_SUFFIX);
548 std::env::split_paths(&path).any(|dir| !dir.as_os_str().is_empty() && dir.join(&file).is_file())
549 }
550
551 /// Insert `def`, replacing any existing entry with the same name.
552 fn upsert(defs: &mut Vec<ServerDef>, def: ServerDef) {
553 if let Some(slot) = defs.iter_mut().find(|d| d.name == def.name) {
554 *slot = def;
555 } else {
556 defs.push(def);
557 }
558 }
559
560 /// Sanitize a name into the `[a-zA-Z0-9_-]` set tool names are restricted to,
561 /// collapsing anything else to `_`.
562 fn sanitize(raw: &str) -> String {
563 raw.trim()
564 .chars()
565 .map(|c| {
566 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
567 c
568 } else {
569 '_'
570 }
571 })
572 .collect()
573 }
574
575 // ── Startup / discovery ─────────────────────────────────────────────────────
576
577 /// Connect to every configured server and cache the tools they expose. Idempotent
578 /// and best-effort: a server that can't be reached is recorded with its error and
579 /// contributes no tools. Safe to call from either entry point; only the first
580 /// call does work.
581 pub async fn init() {
582 if MCP.get().is_some() {
583 return;
584 }
585
586 // Contact servers concurrently so one slow/unreachable host doesn't serialize
587 // the rest. Each handshake is bounded by HANDSHAKE_TIMEOUT.
588 let servers = futures::future::join_all(load_configs().into_iter().map(connect)).await;
589
590 for server in &servers {
591 match &server.error {
592 Some(error) => log::warn!("mcp: server '{}' unavailable: {error}", server.name),
593 None => log::info!(
594 "mcp: server '{}' ready, {} tool(s)",
595 server.name,
596 server.tools.len()
597 ),
598 }
599 }
600
601 let _ = MCP.set(Mcp { servers });
602 }
603
604 /// Sent to every server as `clientInfo`.
605 fn client_info() -> ClientInfo {
606 ClientInfo::new("sigit", env!("CARGO_PKG_VERSION"))
607 }
608
609 /// Run the handshake against one server and collect its tools. Always returns a
610 /// `ServerConn`; failures land in its `error` field rather than propagating.
611 async fn connect(def: ServerDef) -> ServerConn {
612 let endpoint = def.transport.endpoint();
613 let (conn, tools, error) =
614 match Connection::connect(&def.spec(), &client_info(), HANDSHAKE_TIMEOUT).await {
615 Ok(conn) => {
616 let tools = server_tools(&def.name, &conn);
617 (Some(Arc::new(conn)), tools, None)
618 }
619 Err(error) => (None, Vec::new(), Some(format!("{error:#}"))),
620 };
621 ServerConn {
622 name: def.name.clone(),
623 endpoint,
624 def,
625 conn: RwLock::new(conn),
626 tools,
627 error,
628 }
629 }
630
631 /// The tools `conn` listed, in siGit's flattened form.
632 fn server_tools(server: &str, conn: &Connection) -> Vec<McpTool> {
633 conn.tools()
634 .iter()
635 .map(|tool| {
636 let remote_name = tool.name.to_string();
637 let full_name = format!("{MCP_PREFIX}{server}__{}", sanitize(&remote_name));
638 if full_name.chars().count() > 64 {
639 log::warn!(
640 "mcp: tool name '{full_name}' exceeds 64 chars; some backends may reject it"
641 );
642 }
643 let remote_desc = tool.description.as_deref().unwrap_or("").trim();
644 let description = if remote_desc.is_empty() {
645 format!("[MCP server '{server}'] {remote_name}")
646 } else {
647 format!("[MCP server '{server}'] {remote_desc}")
648 };
649 let mut schema = Value::Object((*tool.input_schema).clone());
650 if schema.get("type").is_none() {
651 schema["type"] = json!("object");
652 }
653 McpTool {
654 full_name,
655 remote_name,
656 description,
657 parameters_schema: schema.to_string(),
658 }
659 })
660 .collect()
661 }
662
663 // ── Client-supplied servers (per session) ───────────────────────────────────
664
665 /// A server an ACP client passed in `mcpServers` on a session request.
666 #[derive(Debug, Clone)]
667 pub struct ClientServer {
668 pub name: String,
669 pub transport: ClientTransport,
670 }
671
672 /// How a client-supplied server is reached. stdio is the transport every ACP
673 /// agent must support; Streamable HTTP is the optional one siGit Code
674 /// advertises through `mcpCapabilities.http`.
675 #[derive(Debug, Clone)]
676 pub enum ClientTransport {
677 Stdio {
678 command: String,
679 args: Vec<String>,
680 env: Vec<(String, String)>,
681 },
682 Http {
683 url: String,
684 headers: Vec<(String, String)>,
685 },
686 }
687
688 /// The MCP servers one ACP session brought with it, connected.
689 ///
690 /// Unlike the startup servers in [`MCP`], these belong to a session: the
691 /// client names them on `session/new` (or load/fork) and they are offered to
692 /// that session only. Cheap to clone; the connections are shared. When the
693 /// last clone goes away the connections close, which stops stdio children.
694 #[derive(Clone, Default)]
695 pub struct SessionServers(Arc<Vec<ServerConn>>);
696
697 impl std::fmt::Debug for SessionServers {
698 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
699 f.debug_list()
700 .entries(self.0.iter().map(|server| &server.name))
701 .finish()
702 }
703 }
704
705 /// The live session's client-supplied servers. One process serves every
706 /// thread the editor has open, so like the roots in `workspace.rs` this
707 /// always belongs to the session `main.rs` has installed.
708 static SESSION_SERVERS: StdMutex<Option<SessionServers>> = StdMutex::new(None);
709
710 /// Connect to the servers a client supplied for one session. Best effort, like
711 /// [`init`]: a server that fails its handshake is kept with its error so `/mcp`
712 /// can show it, and contributes no tools.
713 ///
714 /// A name already taken by a startup server (or by an earlier entry in the same
715 /// list) is not connected. Its tools would share the `mcp__<server>__` prefix
716 /// with the other server's, and a call could not be routed.
717 pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServers {
718 if servers.is_empty() {
719 return SessionServers::default();
720 }
721 if disabled_by_env() {
722 log::info!(
723 "mcp: ignoring {} client-supplied server(s), disabled via SIGIT_MCP",
724 servers.len()
725 );
726 return SessionServers::default();
727 }
728
729 let mut taken: Vec<String> = MCP
730 .get()
731 .map(|mcp| mcp.servers.iter().map(|s| s.name.clone()).collect())
732 .unwrap_or_default();
733
734 let mut clashes = Vec::new();
735 let mut defs = Vec::new();
736 for server in servers {
737 let name = sanitize(&server.name);
738 let transport = match server.transport {
739 ClientTransport::Stdio { command, args, env } => {
740 TransportDef::Stdio { command, args, env }
741 }
742 ClientTransport::Http { url, headers } => TransportDef::Http { url, headers },
743 };
744 if name.is_empty() || taken.contains(&name) {
745 clashes.push(ServerConn {
746 endpoint: transport.endpoint(),
747 error: Some(if name.is_empty() {
748 "the client sent a server with no name".to_string()
749 } else {
750 format!("another MCP server is already named '{name}'")
751 }),
752 def: ServerDef {
753 name: name.clone(),
754 transport,
755 },
756 conn: RwLock::new(None),
757 tools: Vec::new(),
758 name,
759 });
760 continue;
761 }
762 taken.push(name.clone());
763 defs.push(ServerDef { name, transport });
764 }
765
766 let mut connected = futures::future::join_all(defs.into_iter().map(connect)).await;
767 connected.extend(clashes);
768
769 for server in &connected {
770 match &server.error {
771 Some(error) => log::warn!(
772 "mcp: client-supplied server '{}' unavailable: {error}",
773 server.name
774 ),
775 None => log::info!(
776 "mcp: client-supplied server '{}' ready, {} tool(s)",
777 server.name,
778 server.tools.len()
779 ),
780 }
781 }
782
783 SessionServers(Arc::new(connected))
784 }
785
786 /// Make `servers` the live session's set. Called whenever `main.rs` installs a
787 /// session, so a thread never sees another thread's servers.
788 pub fn set_session_servers(servers: SessionServers) {
789 *SESSION_SERVERS.lock().unwrap() = Some(servers);
790 }
791
792 fn session_servers() -> SessionServers {
793 SESSION_SERVERS.lock().unwrap().clone().unwrap_or_default()
794 }
795
796 // ── Tool exposure + dispatch ────────────────────────────────────────────────
797
798 /// Whether a tool name belongs to MCP. The dispatch in `tools::execute_tool`
799 /// uses this to route a call here.
800 pub fn is_mcp_tool(name: &str) -> bool {
801 name.starts_with(MCP_PREFIX)
802 }
803
804 /// All discovered MCP tools as agent [`ToolSpec`]s, ready to append to the
805 /// built-in tool list: the startup servers' tools, then the ones the live
806 /// session's client supplied. Empty when no server exposed any.
807 pub fn tool_specs() -> Vec<ToolSpec> {
808 let session = session_servers();
809 let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
810 let mut specs = Vec::new();
811 for server in startup.iter().chain(session.0.iter()) {
812 for tool in &server.tools {
813 specs.push(ToolSpec {
814 name: tool.full_name.clone(),
815 description: tool.description.clone(),
816 parameters_schema: tool.parameters_schema.clone(),
817 });
818 }
819 }
820 specs
821 }
822
823 /// Execute an MCP tool call by name, returning text to feed back to the model.
824 /// Errors are returned as plain strings (never panics) so a failing tool degrades
825 /// to a message the model can react to, exactly like the built-in tools.
826 pub async fn call_tool(full_name: &str, arguments: &str) -> String {
827 let session = session_servers();
828 let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
829 if MCP.get().is_none() && session.0.is_empty() {
830 return "Error: MCP is not initialized.".to_string();
831 }
832
833 let Some((server, tool)) = startup.iter().chain(session.0.iter()).find_map(|s| {
834 s.tools
835 .iter()
836 .find(|t| t.full_name == full_name)
837 .map(|t| (s, t))
838 }) else {
839 return format!("Error: unknown MCP tool \"{full_name}\".");
840 };
841
842 // Arguments arrive as a JSON-encoded string; an empty/blank string means no
843 // arguments. Anything that isn't a JSON object is a model mistake.
844 let args: Value = if arguments.trim().is_empty() {
845 json!({})
846 } else {
847 match serde_json::from_str(arguments) {
848 Ok(value @ Value::Object(_)) => value,
849 Ok(_) => return "Error: tool arguments must be a JSON object.".to_string(),
850 Err(error) => return format!("Error: failed to parse arguments: {error}"),
851 }
852 };
853
854 match call_server(server, &tool.remote_name, args).await {
855 Ok(text) => truncate(text),
856 Err(error) => format!("Error: {error}"),
857 }
858 }
859
860 /// Send a `tools/call` and render the result into text. An HTTP server whose
861 /// call fails for any reason but a timeout is reconnected and the call retried
862 /// once: that is how a restarted server or an expired session recovers.
863 async fn call_server(
864 server: &ServerConn,
865 remote_name: &str,
866 args: Value,
867 ) -> Result<String, String> {
868 let timeout = if server.name == "xcode" {
869 XCODE_CALL_TIMEOUT
870 } else {
871 CALL_TIMEOUT
872 };
873 let kind = if server.def.is_stdio() {
874 "stdio server"
875 } else {
876 "server"
877 };
878 let Some(conn) = server.conn.read().await.clone() else {
879 return Err(format!("{kind} '{}' is not connected", server.name));
880 };
881 let result = match conn.call_tool(remote_name, args.clone(), timeout).await {
882 Ok(result) => result,
883 Err(error) if server.def.is_stdio() || error.to_string().contains("timed out") => {
884 return Err(format!("{kind} '{}': {error:#}", server.name));
885 }
886 Err(error) => {
887 log::info!("mcp: '{}' failed ({error:#}); reconnecting", server.name);
888 let fresh = Connection::connect(&server.def.spec(), &client_info(), HANDSHAKE_TIMEOUT)
889 .await
890 .map(Arc::new)
891 .map_err(|again| {
892 format!(
893 "server '{}': {error:#}; reconnecting failed: {again:#}",
894 server.name
895 )
896 })?;
897 *server.conn.write().await = Some(fresh.clone());
898 fresh
899 .call_tool(remote_name, args, timeout)
900 .await
901 .map_err(|error| format!("server '{}': {error:#}", server.name))?
902 }
903 };
904 Ok(render_tool_result(&result))
905 }
906
907 /// Flatten an MCP `tools/call` result into text. Joins text content blocks;
908 /// notes non-text blocks; honors `isError`.
909 fn render_tool_result(result: &Value) -> String {
910 let mut out = String::new();
911 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
912 for block in blocks {
913 match block.get("type").and_then(Value::as_str) {
914 Some("text") => {
915 if let Some(text) = block.get("text").and_then(Value::as_str) {
916 if !out.is_empty() {
917 out.push('\n');
918 }
919 out.push_str(text);
920 }
921 }
922 Some(other) => {
923 if !out.is_empty() {
924 out.push('\n');
925 }
926 out.push_str(&format!("[{other} content omitted]"));
927 }
928 None => {}
929 }
930 }
931 }
932
933 // Some servers return only `structuredContent`; surface it if there was no
934 // textual content.
935 if out.is_empty()
936 && let Some(structured) = result.get("structuredContent")
937 {
938 out = structured.to_string();
939 }
940
941 if out.is_empty() {
942 out = "(tool returned no content)".to_string();
943 }
944
945 if result.get("isError").and_then(Value::as_bool) == Some(true) {
946 format!("Tool reported an error:\n{out}")
947 } else {
948 out
949 }
950 }
951
952 /// Truncate tool output to the context-protecting limit, with a trailing note.
953 fn truncate(text: String) -> String {
954 if text.chars().count() <= RESULT_CHAR_LIMIT {
955 return text;
956 }
957 let kept: String = text.chars().take(RESULT_CHAR_LIMIT).collect();
958 format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]")
959 }
960
961 // ── Status reporting (`/mcp`) ────────────────────────────────────────────────
962
963 /// Human-readable summary of configured MCP servers and their tools, for the
964 /// `/mcp` slash command. Shows the URL for HTTP servers and the command line
965 /// for stdio servers.
966 pub fn status_summary() -> String {
967 let session = session_servers();
968 let Some(mcp) = MCP.get() else {
969 if session.0.is_empty() {
970 return "MCP is not initialized.".to_string();
971 }
972 return summarize_servers(&[], &session.0);
973 };
974 if mcp.servers.is_empty() && session.0.is_empty() {
975 return "No MCP servers configured. Add one in ~/.config/sigit/mcp.toml \
976 or .sigit/mcp.toml. See https://modelcontextprotocol.io."
977 .to_string();
978 }
979 summarize_servers(&mcp.servers, &session.0)
980 }
981
982 /// Counts for the launch checklist: configured startup servers, the tools they
983 /// expose, and how many could not be reached. `None` before [`init`] has run.
984 #[cfg(unix)]
985 pub struct Overview {
986 pub servers: usize,
987 pub tools: usize,
988 pub unavailable: usize,
989 }
990
991 #[cfg(unix)]
992 pub fn overview() -> Option<Overview> {
993 let mcp = MCP.get()?;
994 Some(Overview {
995 servers: mcp.servers.len(),
996 tools: mcp.servers.iter().map(|s| s.tools.len()).sum(),
997 unavailable: mcp.servers.iter().filter(|s| s.error.is_some()).count(),
998 })
999 }
1000
1001 /// The `/mcp` listing for the startup servers followed by the ones the live
1002 /// session's client supplied, which are marked as such.
1003 fn summarize_servers(startup: &[ServerConn], session: &[ServerConn]) -> String {
1004 let all = || startup.iter().chain(session.iter());
1005 let total_tools: usize = all().map(|s| s.tools.len()).sum();
1006 let mut lines = vec![format!(
1007 "{} MCP server(s), {total_tools} tool(s) available:",
1008 all().count()
1009 )];
1010 for (index, server) in all().enumerate() {
1011 let origin = if index >= startup.len() {
1012 ", from the editor"
1013 } else {
1014 ""
1015 };
1016 match &server.error {
1017 Some(error) => lines.push(format!(
1018 "- {} ({}{origin}) — unavailable: {error}",
1019 server.name, server.endpoint
1020 )),
1021 None => {
1022 lines.push(format!(
1023 "- {} ({}{origin}) — {} tool(s)",
1024 server.name,
1025 server.endpoint,
1026 server.tools.len()
1027 ));
1028 for tool in &server.tools {
1029 lines.push(format!(" • {}", tool.full_name));
1030 }
1031 }
1032 }
1033 }
1034 lines.join("\n")
1035 }
1036
1037 #[cfg(test)]
1038 mod tests {
1039 use super::*;
1040
1041 #[test]
1042 fn is_mcp_tool_detects_prefix() {
1043 assert!(is_mcp_tool("mcp__sigit__search"));
1044 assert!(!is_mcp_tool("read_file"));
1045 assert!(!is_mcp_tool("skill"));
1046 }
1047
1048 #[test]
1049 fn official_tool_name_matches_the_namespacing_convention() {
1050 assert_eq!(official_tool_name("list_issues"), "mcp__sigit__list_issues");
1051 assert_eq!(
1052 official_tool_name("get_pull_request"),
1053 "mcp__sigit__get_pull_request"
1054 );
1055 }
1056
1057 #[test]
1058 fn official_tool_suffix_strips_only_the_official_namespace() {
1059 assert_eq!(
1060 official_tool_suffix("mcp__sigit__list_issues"),
1061 Some("list_issues")
1062 );
1063 assert_eq!(official_tool_suffix("mcp__other__list_issues"), None);
1064 // `sigit` must be the whole server name, not a prefix of it.
1065 assert_eq!(official_tool_suffix("mcp__sigitx__list_issues"), None);
1066 assert_eq!(official_tool_suffix("list_issues"), None);
1067 assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
1068 }
1069
1070 #[test]
1071 fn smbcloud_tool_suffix_strips_only_the_smbcloud_namespace() {
1072 assert_eq!(
1073 smbcloud_tool_suffix("mcp__smbcloud__project_list"),
1074 Some("project_list")
1075 );
1076 assert_eq!(smbcloud_tool_suffix("mcp__sigit__project_list"), None);
1077 // `smbcloud` must be the whole server name, not a prefix of it.
1078 assert_eq!(smbcloud_tool_suffix("mcp__smbcloudx__project_list"), None);
1079 assert_eq!(smbcloud_tool_suffix("project_list"), None);
1080 assert_eq!(smbcloud_tool_suffix("mcp__smbcloud__"), Some(""));
1081 }
1082
1083 #[test]
1084 fn ascapi_tool_suffix_strips_only_the_ascapi_namespace() {
1085 assert_eq!(
1086 ascapi_tool_suffix("mcp__ascapi__app_list"),
1087 Some("app_list")
1088 );
1089 assert_eq!(ascapi_tool_suffix("mcp__sigit__app_list"), None);
1090 // `ascapi` must be the whole server name, not a prefix of it.
1091 assert_eq!(ascapi_tool_suffix("mcp__ascapix__app_list"), None);
1092 assert_eq!(ascapi_tool_suffix("app_list"), None);
1093 assert_eq!(ascapi_tool_suffix("mcp__ascapi__"), Some(""));
1094 }
1095
1096 #[test]
1097 fn sanitize_collapses_invalid_chars() {
1098 assert_eq!(sanitize("github"), "github");
1099 assert_eq!(sanitize("my server"), "my_server");
1100 assert_eq!(sanitize("a.b/c:d"), "a_b_c_d");
1101 assert_eq!(sanitize("keep-_ok9"), "keep-_ok9");
1102 }
1103
1104 #[test]
1105 fn parses_mcp_file_with_servers() {
1106 let toml = r#"
1107 official = false
1108
1109 [[server]]
1110 name = "github"
1111 url = "https://api.example.com/mcp"
1112
1113 [[server]]
1114 name = "disabled-one"
1115 url = "https://nope.example.com/mcp"
1116 enabled = false
1117
1118 [server.headers]
1119 Authorization = "Bearer xyz"
1120 "#;
1121 let parsed: McpFile = toml::from_str(toml).unwrap();
1122 assert_eq!(parsed.official, Some(false));
1123 assert_eq!(parsed.smbcloud, None);
1124 assert_eq!(parsed.server.len(), 2);
1125 assert_eq!(parsed.server[0].name, "github");
1126 assert_eq!(parsed.server[1].enabled, Some(false));
1127 assert_eq!(
1128 parsed.server[1]
1129 .headers
1130 .get("Authorization")
1131 .map(String::as_str),
1132 Some("Bearer xyz")
1133 );
1134 }
1135
1136 #[test]
1137 fn parses_smbcloud_opt_out_flag() {
1138 let parsed: McpFile = toml::from_str("smbcloud = false").unwrap();
1139 assert_eq!(parsed.smbcloud, Some(false));
1140 // Absent means "include" (the default stays true in load_configs).
1141 let parsed: McpFile = toml::from_str("").unwrap();
1142 assert_eq!(parsed.smbcloud, None);
1143 }
1144
1145 #[test]
1146 fn parses_ascapi_opt_out_flag() {
1147 let parsed: McpFile = toml::from_str("ascapi = false").unwrap();
1148 assert_eq!(parsed.ascapi, Some(false));
1149 // Absent means "include" (the default stays true in load_configs).
1150 let parsed: McpFile = toml::from_str("").unwrap();
1151 assert_eq!(parsed.ascapi, None);
1152 }
1153
1154 #[test]
1155 fn on_path_finds_real_binaries_only() {
1156 assert!(!on_path("definitely-not-a-real-binary-xyzzy"));
1157 #[cfg(unix)]
1158 assert!(on_path("sh"));
1159 }
1160
1161 #[test]
1162 fn parses_stdio_server_with_args_and_env() {
1163 let toml = r#"
1164 [[server]]
1165 name = "fs"
1166 command = "npx"
1167 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1168
1169 [server.env]
1170 LOG_LEVEL = "debug"
1171 TOKEN = "abc"
1172 "#;
1173 let parsed: McpFile = toml::from_str(toml).unwrap();
1174 assert_eq!(parsed.server.len(), 1);
1175 let entry = &parsed.server[0];
1176 assert_eq!(entry.command.as_deref(), Some("npx"));
1177 assert_eq!(entry.args.len(), 3);
1178 assert_eq!(
1179 entry.env.get("LOG_LEVEL").map(String::as_str),
1180 Some("debug")
1181 );
1182 assert_eq!(entry.env.get("TOKEN").map(String::as_str), Some("abc"));
1183
1184 let def = entry.transport_def().expect("valid stdio entry");
1185 match def {
1186 TransportDef::Stdio { command, args, env } => {
1187 assert_eq!(command, "npx");
1188 assert_eq!(args[0], "-y");
1189 assert_eq!(env.len(), 2);
1190 }
1191 TransportDef::Http { .. } => panic!("expected a stdio transport"),
1192 }
1193 }
1194
1195 #[test]
1196 fn entry_with_url_and_command_is_a_config_error() {
1197 let toml = r#"
1198 [[server]]
1199 name = "confused"
1200 url = "https://example.com/mcp"
1201 command = "npx"
1202 "#;
1203 let parsed: McpFile = toml::from_str(toml).unwrap();
1204 let error = parsed.server[0].transport_def().unwrap_err();
1205 assert!(error.contains("both"), "unexpected error: {error}");
1206 }
1207
1208 #[test]
1209 fn entry_with_neither_url_nor_command_is_a_config_error() {
1210 let toml = r#"
1211 [[server]]
1212 name = "empty"
1213 "#;
1214 let parsed: McpFile = toml::from_str(toml).unwrap();
1215 let error = parsed.server[0].transport_def().unwrap_err();
1216 assert!(error.contains("needs"), "unexpected error: {error}");
1217 }
1218
1219 #[test]
1220 fn blank_url_or_command_counts_as_absent() {
1221 let toml = r#"
1222 [[server]]
1223 name = "blank"
1224 url = " "
1225 command = "server-bin"
1226 "#;
1227 let parsed: McpFile = toml::from_str(toml).unwrap();
1228 // A blank url is treated as absent, so this resolves to stdio.
1229 match parsed.server[0].transport_def().expect("stdio") {
1230 TransportDef::Stdio { command, .. } => assert_eq!(command, "server-bin"),
1231 TransportDef::Http { .. } => panic!("expected stdio"),
1232 }
1233 }
1234
1235 #[test]
1236 fn endpoint_renders_url_or_command_line() {
1237 let http = TransportDef::Http {
1238 url: "https://example.com/mcp".into(),
1239 headers: vec![],
1240 };
1241 assert_eq!(http.endpoint(), "https://example.com/mcp");
1242
1243 let stdio = TransportDef::Stdio {
1244 command: "npx".into(),
1245 args: vec!["-y".into(), "server-fs".into()],
1246 env: vec![],
1247 };
1248 assert_eq!(stdio.endpoint(), "npx -y server-fs");
1249 }
1250
1251 #[test]
1252 fn upsert_replaces_same_name() {
1253 let mut defs = vec![ServerDef {
1254 name: "a".into(),
1255 transport: TransportDef::Http {
1256 url: "u1".into(),
1257 headers: vec![],
1258 },
1259 }];
1260 upsert(
1261 &mut defs,
1262 ServerDef {
1263 name: "a".into(),
1264 transport: TransportDef::Http {
1265 url: "u2".into(),
1266 headers: vec![],
1267 },
1268 },
1269 );
1270 assert_eq!(defs.len(), 1);
1271 assert_eq!(defs[0].transport.endpoint(), "u2");
1272 }
1273
1274 #[test]
1275 fn render_result_joins_text_blocks() {
1276 let result = json!({
1277 "content": [
1278 { "type": "text", "text": "line one" },
1279 { "type": "text", "text": "line two" }
1280 ]
1281 });
1282 assert_eq!(render_tool_result(&result), "line one\nline two");
1283 }
1284
1285 #[test]
1286 fn render_result_marks_errors_and_non_text() {
1287 let result = json!({
1288 "isError": true,
1289 "content": [
1290 { "type": "text", "text": "boom" },
1291 { "type": "image", "data": "..." }
1292 ]
1293 });
1294 let rendered = render_tool_result(&result);
1295 assert!(rendered.starts_with("Tool reported an error:"));
1296 assert!(rendered.contains("boom"));
1297 assert!(rendered.contains("[image content omitted]"));
1298 }
1299
1300 #[test]
1301 fn render_result_falls_back_to_structured_content() {
1302 let result = json!({ "structuredContent": { "value": 42 } });
1303 assert!(render_tool_result(&result).contains("42"));
1304 }
1305
1306 #[test]
1307 fn truncate_caps_long_output() {
1308 let long = "x".repeat(RESULT_CHAR_LIMIT + 100);
1309 let out = truncate(long);
1310 assert!(out.contains("[output truncated"));
1311 }
1312
1313 #[test]
1314 fn tool_specs_empty_before_init() {
1315 // Without init() the global is unset; this must not panic.
1316 assert!(super::tool_specs().is_empty() || MCP.get().is_some());
1317 }
1318 }