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