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