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 //! Transport: the modern **Streamable HTTP** transport — a single HTTP endpoint
10 //! the client POSTs JSON-RPC 2.0 messages to. The server answers either with a
11 //! single `application/json` body or a `text/event-stream` (SSE) stream that
12 //! carries the JSON-RPC response. Both are handled here. stdio transport is not
13 //! supported (siGit Code never spawns child processes for inference).
14 //!
15 //! ## The official server
16 //!
17 //! siGit Code bakes in its official MCP server at `<cloud>/mcp` (default
18 //! `https://sigit.si/api/v1/mcp`, following `SIGIT_CLOUD_URL`). When the user is
19 //! signed in (`sigit login`) the cloud session token is sent as a bearer
20 //! credential. Additional servers are configured in `mcp.toml` (see
21 //! [`load_configs`]).
22 //!
23 //! ## Lifecycle
24 //!
25 //! Discovery is best-effort and happens once at startup via [`init`]: each
26 //! configured server is contacted concurrently (with a per-server timeout),
27 //! runs the `initialize` handshake, and has its `tools/list` cached. A server
28 //! that fails to connect is recorded with its error and simply contributes no
29 //! tools — it never blocks startup or the rest of the agent. The result is
30 //! stored in a process-global so the synchronous tool-spec builders
31 //! ([`tool_specs`]) and the async dispatch ([`call_tool`]) can both read it.
32 //!
33 //! Tools are namespaced `mcp__<server>__<tool>` so they never collide with
34 //! built-in tools or with each other across servers. This mirrors the
35 //! convention used by other MCP-aware agents.
36 //!
37 //! Like the rest of the backend seam, MCP is wired up only through the
38 //! interactive client and the ACP agent loop. On non-Unix targets a few helpers
39 //! are unused, so the dead-code lint is suppressed there only.
40 #![cfg_attr(not(unix), allow(dead_code))]
41
42 use std::collections::BTreeMap;
43 use std::path::PathBuf;
44 use std::sync::OnceLock;
45 use std::sync::atomic::{AtomicI64, Ordering};
46 use std::time::Duration;
47
48 use serde::Deserialize;
49 use serde_json::{Value, json};
50 use tokio::sync::Mutex;
51
52 use crate::backend::ToolSpec;
53
54 /// Prefix marking a tool as MCP-provided. The full name is
55 /// `mcp__<server>__<tool>`.
56 pub const MCP_PREFIX: &str = "mcp__";
57
58 /// Name of the baked-in official siGit Code server; its tools are namespaced
59 /// `mcp__sigit__<tool>`. A user-defined `mcp.toml` entry with this name
60 /// overrides the baked-in URL/headers but keeps the namespace, so callers of
61 /// [`official_tool_name`] reach whatever the user pointed `sigit` at.
62 pub const OFFICIAL_SERVER_NAME: &str = "sigit";
63
64 /// The full namespaced name of a tool on the official server, e.g.
65 /// `official_tool_name("list_issues")` → `mcp__sigit__list_issues`.
66 pub fn official_tool_name(tool: &str) -> String {
67 format!("{MCP_PREFIX}{OFFICIAL_SERVER_NAME}__{tool}")
68 }
69
70 /// The bare tool name when `name` belongs to the official server
71 /// (`mcp__sigit__list_issues` → `Some("list_issues")`), else `None`.
72 pub fn official_tool_suffix(name: &str) -> Option<&str> {
73 name.strip_prefix(MCP_PREFIX)?
74 .strip_prefix(OFFICIAL_SERVER_NAME)?
75 .strip_prefix("__")
76 }
77
78 /// JSON-RPC / MCP protocol version we advertise in the handshake.
79 const PROTOCOL_VERSION: &str = "2025-06-18";
80
81 /// Per-server budget for the connect + `initialize` + `tools/list` handshake at
82 /// startup. Bounds how long an unreachable server can delay startup; servers are
83 /// contacted concurrently, so this is the worst case for the whole set, not the
84 /// sum.
85 const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(8);
86
87 /// Overall request timeout for an individual `tools/call`. Generous, since an
88 /// MCP tool may do real work server-side.
89 const CALL_TIMEOUT: Duration = Duration::from_secs(120);
90
91 /// Cap on the characters returned from a single tool call, so a chatty server
92 /// can't blow up the model's context. Matches the spirit of the file-read cap.
93 const RESULT_CHAR_LIMIT: usize = 30_000;
94
95 // ── Public types ──────────────────────────────────────────────────────────────
96
97 /// A tool discovered on an MCP server, in siGit's flattened form.
98 #[derive(Debug, Clone)]
99 struct McpTool {
100 /// Namespaced name exposed to the model: `mcp__<server>__<tool>`.
101 full_name: String,
102 /// The tool's name as the server knows it (sent back in `tools/call`).
103 remote_name: String,
104 /// Human/model-facing description, prefixed with the server name.
105 description: String,
106 /// JSON Schema for the tool's arguments, encoded as a string.
107 parameters_schema: String,
108 }
109
110 /// A configured MCP server and its live connection state.
111 struct ServerConn {
112 /// Sanitized server name used in tool namespacing and the `/mcp` listing.
113 name: String,
114 /// Streamable HTTP endpoint (the single POST URL).
115 url: String,
116 /// Extra headers sent on every request (e.g. `Authorization`).
117 headers: Vec<(String, String)>,
118 /// Session id handed back by the server on `initialize`, echoed on every
119 /// later request via the `Mcp-Session-Id` header.
120 session_id: Mutex<Option<String>>,
121 /// Tools discovered at startup. Empty when the server failed to connect.
122 tools: Vec<McpTool>,
123 /// Connection error, if the handshake failed. Surfaced by `/mcp`.
124 error: Option<String>,
125 }
126
127 /// The process-global MCP state: a shared HTTP client plus every configured
128 /// server.
129 struct Mcp {
130 http: reqwest::Client,
131 servers: Vec<ServerConn>,
132 next_id: AtomicI64,
133 }
134
135 static MCP: OnceLock<Mcp> = OnceLock::new();
136
137 // ── Configuration ───────────────────────────────────────────────────────────
138
139 /// Default endpoint of the official siGit Code MCP server, derived from the
140 /// cloud base URL so `SIGIT_CLOUD_URL` (dev) carries over.
141 fn official_url() -> String {
142 format!(
143 "{}/mcp",
144 crate::provider::cloud_base_url().trim_end_matches('/')
145 )
146 }
147
148 /// A server entry as written in `mcp.toml`.
149 #[derive(Debug, Deserialize)]
150 struct ServerEntry {
151 name: String,
152 url: String,
153 /// Set `enabled = false` to keep an entry in the file but skip connecting.
154 #[serde(default)]
155 enabled: Option<bool>,
156 /// Static headers, e.g. `Authorization = "Bearer ..."`.
157 #[serde(default)]
158 headers: BTreeMap<String, String>,
159 }
160
161 /// The `mcp.toml` schema.
162 #[derive(Debug, Default, Deserialize)]
163 struct McpFile {
164 /// Include the baked-in official server. Defaults to `true`; set `false` to
165 /// opt out.
166 #[serde(default)]
167 official: Option<bool>,
168 #[serde(default)]
169 server: Vec<ServerEntry>,
170 }
171
172 /// A resolved server definition, before connecting.
173 #[derive(Debug, Clone)]
174 struct ServerDef {
175 name: String,
176 url: String,
177 headers: Vec<(String, String)>,
178 }
179
180 /// Config files to read, in priority order (later wins on a name clash):
181 /// global `$SIGIT_CONFIG_DIR/mcp.toml`, then project-local `<cwd>/.sigit/mcp.toml`.
182 fn config_paths() -> Vec<PathBuf> {
183 let mut paths = Vec::new();
184 if let Some(dir) = sigit_config_dir() {
185 paths.push(dir.join("mcp.toml"));
186 }
187 if let Ok(cwd) = std::env::current_dir() {
188 paths.push(cwd.join(".sigit").join("mcp.toml"));
189 }
190 paths
191 }
192
193 fn sigit_config_dir() -> Option<PathBuf> {
194 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
195 && !dir.is_empty()
196 {
197 return Some(PathBuf::from(dir));
198 }
199 std::env::var("HOME")
200 .ok()
201 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
202 }
203
204 /// Resolve the full set of servers to connect to: the baked-in official server
205 /// (unless opted out) plus any from `mcp.toml`. Project-local entries override
206 /// global ones, and a user entry named `sigit` overrides the official default.
207 fn load_configs() -> Vec<ServerDef> {
208 // Global escape hatch: `SIGIT_MCP=off` disables MCP entirely.
209 if let Ok(value) = std::env::var("SIGIT_MCP")
210 && matches!(
211 value.trim().to_ascii_lowercase().as_str(),
212 "off" | "0" | "false" | "no" | "disabled"
213 )
214 {
215 log::info!("mcp: disabled via SIGIT_MCP");
216 return Vec::new();
217 }
218
219 let mut include_official = true;
220 // De-duplicated by sanitized name; a later config file overrides an earlier
221 // one for the same name (project-local wins over global).
222 let mut defs: Vec<ServerDef> = Vec::new();
223
224 for path in config_paths() {
225 let Ok(contents) = std::fs::read_to_string(&path) else {
226 continue;
227 };
228 let parsed: McpFile = match toml::from_str(&contents) {
229 Ok(parsed) => parsed,
230 Err(error) => {
231 log::warn!("mcp: ignoring {}: {error}", path.display());
232 continue;
233 }
234 };
235 if let Some(official) = parsed.official {
236 include_official = official;
237 }
238 for entry in parsed.server {
239 if entry.enabled == Some(false) {
240 continue;
241 }
242 let name = sanitize(&entry.name);
243 if name.is_empty() || entry.url.trim().is_empty() {
244 log::warn!(
245 "mcp: skipping server with empty name/url in {}",
246 path.display()
247 );
248 continue;
249 }
250 let headers = entry.headers.into_iter().collect();
251 upsert(
252 &mut defs,
253 ServerDef {
254 name,
255 url: entry.url.trim().to_string(),
256 headers,
257 },
258 );
259 }
260 }
261
262 // The official server can also be disabled with SIGIT_MCP_OFFICIAL=off.
263 if let Ok(value) = std::env::var("SIGIT_MCP_OFFICIAL")
264 && matches!(
265 value.trim().to_ascii_lowercase().as_str(),
266 "off" | "0" | "false" | "no"
267 )
268 {
269 include_official = false;
270 }
271
272 // Add the baked-in official server, but never clobber a user-defined entry
273 // named `sigit` — an explicit config (e.g. a custom URL or headers) wins.
274 if include_official && !defs.iter().any(|d| d.name == OFFICIAL_SERVER_NAME) {
275 let mut headers = Vec::new();
276 if let Some(token) = crate::credentials::load_token() {
277 headers.push(("Authorization".to_string(), format!("Bearer {token}")));
278 }
279 defs.push(ServerDef {
280 name: OFFICIAL_SERVER_NAME.to_string(),
281 url: official_url(),
282 headers,
283 });
284 }
285
286 defs
287 }
288
289 /// Insert `def`, replacing any existing entry with the same name.
290 fn upsert(defs: &mut Vec<ServerDef>, def: ServerDef) {
291 if let Some(slot) = defs.iter_mut().find(|d| d.name == def.name) {
292 *slot = def;
293 } else {
294 defs.push(def);
295 }
296 }
297
298 /// Sanitize a name into the `[a-zA-Z0-9_-]` set tool names are restricted to,
299 /// collapsing anything else to `_`.
300 fn sanitize(raw: &str) -> String {
301 raw.trim()
302 .chars()
303 .map(|c| {
304 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
305 c
306 } else {
307 '_'
308 }
309 })
310 .collect()
311 }
312
313 // ── Startup / discovery ─────────────────────────────────────────────────────
314
315 /// Connect to every configured server and cache the tools they expose. Idempotent
316 /// and best-effort: a server that can't be reached is recorded with its error and
317 /// contributes no tools. Safe to call from either entry point; only the first
318 /// call does work.
319 pub async fn init() {
320 if MCP.get().is_some() {
321 return;
322 }
323
324 let defs = load_configs();
325 let http = reqwest::Client::builder()
326 .timeout(CALL_TIMEOUT)
327 .user_agent(concat!(
328 "sigit/",
329 env!("CARGO_PKG_VERSION"),
330 " (mcp-client)"
331 ))
332 .build()
333 .unwrap_or_default();
334
335 // Contact servers concurrently so one slow/unreachable host doesn't serialize
336 // the rest. Each handshake is bounded by HANDSHAKE_TIMEOUT.
337 let connects = defs.into_iter().map(|def| {
338 let http = http.clone();
339 async move { connect(&http, def).await }
340 });
341 let servers = futures::future::join_all(connects).await;
342
343 for server in &servers {
344 match &server.error {
345 Some(error) => log::warn!("mcp: server '{}' unavailable: {error}", server.name),
346 None => log::info!(
347 "mcp: server '{}' ready, {} tool(s)",
348 server.name,
349 server.tools.len()
350 ),
351 }
352 }
353
354 let _ = MCP.set(Mcp {
355 http,
356 servers,
357 next_id: AtomicI64::new(1),
358 });
359 }
360
361 /// Run the handshake against one server and collect its tools. Always returns a
362 /// `ServerConn`; failures land in its `error` field rather than propagating.
363 async fn connect(http: &reqwest::Client, def: ServerDef) -> ServerConn {
364 let mut conn = ServerConn {
365 name: def.name.clone(),
366 url: def.url.clone(),
367 headers: def.headers.clone(),
368 session_id: Mutex::new(None),
369 tools: Vec::new(),
370 error: None,
371 };
372
373 let handshake = tokio::time::timeout(HANDSHAKE_TIMEOUT, async {
374 // initialize → notifications/initialized → tools/list
375 initialize(http, &conn).await?;
376 notify_initialized(http, &conn).await?;
377 list_tools(http, &conn).await
378 })
379 .await;
380
381 match handshake {
382 Ok(Ok(tools)) => conn.tools = tools,
383 Ok(Err(error)) => conn.error = Some(error),
384 Err(_) => conn.error = Some(format!("timed out after {}s", HANDSHAKE_TIMEOUT.as_secs())),
385 }
386
387 conn
388 }
389
390 /// The `initialize` request: negotiate protocol version and capture the session
391 /// id from the response headers (handled inside [`post_rpc`]).
392 async fn initialize(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
393 let body = json!({
394 "jsonrpc": "2.0",
395 "id": 0,
396 "method": "initialize",
397 "params": {
398 "protocolVersion": PROTOCOL_VERSION,
399 "capabilities": {},
400 "clientInfo": { "name": "sigit", "version": env!("CARGO_PKG_VERSION") }
401 }
402 });
403 post_rpc(http, conn, &body, HANDSHAKE_TIMEOUT).await?;
404 Ok(())
405 }
406
407 /// The `notifications/initialized` notification. Servers expect it before
408 /// fielding requests; it carries no id and yields a 202 with no body.
409 async fn notify_initialized(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> {
410 let body = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
411 post_notification(http, conn, &body, HANDSHAKE_TIMEOUT).await
412 }
413
414 /// `tools/list`, following `nextCursor` pagination, mapped into [`McpTool`]s.
415 async fn list_tools(http: &reqwest::Client, conn: &ServerConn) -> Result<Vec<McpTool>, String> {
416 let mut tools = Vec::new();
417 let mut cursor: Option<String> = None;
418
419 loop {
420 let params = match &cursor {
421 Some(c) => json!({ "cursor": c }),
422 None => json!({}),
423 };
424 let body = json!({ "jsonrpc": "2.0", "id": 0, "method": "tools/list", "params": params });
425 let result = post_rpc(http, conn, &body, HANDSHAKE_TIMEOUT).await?;
426
427 for tool in result
428 .get("tools")
429 .and_then(Value::as_array)
430 .into_iter()
431 .flatten()
432 {
433 let Some(remote_name) = tool.get("name").and_then(Value::as_str) else {
434 continue;
435 };
436 let full_name = format!("{MCP_PREFIX}{}__{}", conn.name, sanitize(remote_name));
437 if full_name.chars().count() > 64 {
438 log::warn!(
439 "mcp: tool name '{full_name}' exceeds 64 chars; some backends may reject it"
440 );
441 }
442 let remote_desc = tool
443 .get("description")
444 .and_then(Value::as_str)
445 .unwrap_or("")
446 .trim();
447 let description = if remote_desc.is_empty() {
448 format!("[MCP server '{}'] {remote_name}", conn.name)
449 } else {
450 format!("[MCP server '{}'] {remote_desc}", conn.name)
451 };
452 // `inputSchema` is a JSON Schema object; default to a permissive
453 // object schema when a server omits it.
454 let parameters_schema = tool
455 .get("inputSchema")
456 .filter(|schema| schema.is_object())
457 .cloned()
458 .unwrap_or_else(|| json!({ "type": "object" }))
459 .to_string();
460
461 tools.push(McpTool {
462 full_name,
463 remote_name: remote_name.to_string(),
464 description,
465 parameters_schema,
466 });
467 }
468
469 cursor = result
470 .get("nextCursor")
471 .and_then(Value::as_str)
472 .map(str::to_string);
473 if cursor.is_none() {
474 break;
475 }
476 }
477
478 Ok(tools)
479 }
480
481 // ── Tool exposure + dispatch ────────────────────────────────────────────────
482
483 /// Whether a tool name belongs to MCP. The dispatch in `tools::execute_tool`
484 /// uses this to route a call here.
485 pub fn is_mcp_tool(name: &str) -> bool {
486 name.starts_with(MCP_PREFIX)
487 }
488
489 /// All discovered MCP tools as agent [`ToolSpec`]s, ready to append to the
490 /// built-in tool list. Empty when MCP is uninitialized or no server exposed any.
491 pub fn tool_specs() -> Vec<ToolSpec> {
492 let Some(mcp) = MCP.get() else {
493 return Vec::new();
494 };
495 let mut specs = Vec::new();
496 for server in &mcp.servers {
497 for tool in &server.tools {
498 specs.push(ToolSpec {
499 name: tool.full_name.clone(),
500 description: tool.description.clone(),
501 parameters_schema: tool.parameters_schema.clone(),
502 });
503 }
504 }
505 specs
506 }
507
508 /// Execute an MCP tool call by name, returning text to feed back to the model.
509 /// Errors are returned as plain strings (never panics) so a failing tool degrades
510 /// to a message the model can react to, exactly like the built-in tools.
511 pub async fn call_tool(full_name: &str, arguments: &str) -> String {
512 let Some(mcp) = MCP.get() else {
513 return "Error: MCP is not initialized.".to_string();
514 };
515
516 let Some((server, tool)) = mcp.servers.iter().find_map(|s| {
517 s.tools
518 .iter()
519 .find(|t| t.full_name == full_name)
520 .map(|t| (s, t))
521 }) else {
522 return format!("Error: unknown MCP tool \"{full_name}\".");
523 };
524
525 // Arguments arrive as a JSON-encoded string; an empty/blank string means no
526 // arguments. Anything that isn't a JSON object is a model mistake.
527 let args: Value = if arguments.trim().is_empty() {
528 json!({})
529 } else {
530 match serde_json::from_str(arguments) {
531 Ok(value @ Value::Object(_)) => value,
532 Ok(_) => return "Error: tool arguments must be a JSON object.".to_string(),
533 Err(error) => return format!("Error: failed to parse arguments: {error}"),
534 }
535 };
536
537 match mcp.call(server, &tool.remote_name, args).await {
538 Ok(text) => truncate(text),
539 Err(error) => format!("Error: {error}"),
540 }
541 }
542
543 impl Mcp {
544 /// Send a `tools/call` and render the result into text. Retries once after a
545 /// re-`initialize` if the session was dropped (HTTP 404), which is how
546 /// Streamable HTTP signals an expired session.
547 async fn call(
548 &self,
549 server: &ServerConn,
550 remote_name: &str,
551 args: Value,
552 ) -> Result<String, String> {
553 let body = json!({
554 "jsonrpc": "2.0",
555 "id": 0,
556 "method": "tools/call",
557 "params": { "name": remote_name, "arguments": args }
558 });
559
560 let result = match post_rpc(&self.http, server, &body, CALL_TIMEOUT).await {
561 Ok(result) => result,
562 Err(error) if error.contains("returned 404") => {
563 // Session expired — drop it, re-handshake, and retry once.
564 *server.session_id.lock().await = None;
565 initialize(&self.http, server).await?;
566 notify_initialized(&self.http, server).await?;
567 post_rpc(&self.http, server, &body, CALL_TIMEOUT).await?
568 }
569 Err(error) => return Err(error),
570 };
571
572 Ok(render_tool_result(&result))
573 }
574
575 fn next_id(&self) -> i64 {
576 self.next_id.fetch_add(1, Ordering::Relaxed)
577 }
578 }
579
580 /// Flatten an MCP `tools/call` result into text. Joins text content blocks;
581 /// notes non-text blocks; honors `isError`.
582 fn render_tool_result(result: &Value) -> String {
583 let mut out = String::new();
584 if let Some(blocks) = result.get("content").and_then(Value::as_array) {
585 for block in blocks {
586 match block.get("type").and_then(Value::as_str) {
587 Some("text") => {
588 if let Some(text) = block.get("text").and_then(Value::as_str) {
589 if !out.is_empty() {
590 out.push('\n');
591 }
592 out.push_str(text);
593 }
594 }
595 Some(other) => {
596 if !out.is_empty() {
597 out.push('\n');
598 }
599 out.push_str(&format!("[{other} content omitted]"));
600 }
601 None => {}
602 }
603 }
604 }
605
606 // Some servers return only `structuredContent`; surface it if there was no
607 // textual content.
608 if out.is_empty()
609 && let Some(structured) = result.get("structuredContent")
610 {
611 out = structured.to_string();
612 }
613
614 if out.is_empty() {
615 out = "(tool returned no content)".to_string();
616 }
617
618 if result.get("isError").and_then(Value::as_bool) == Some(true) {
619 format!("Tool reported an error:\n{out}")
620 } else {
621 out
622 }
623 }
624
625 /// Truncate tool output to the context-protecting limit, with a trailing note.
626 fn truncate(text: String) -> String {
627 if text.chars().count() <= RESULT_CHAR_LIMIT {
628 return text;
629 }
630 let kept: String = text.chars().take(RESULT_CHAR_LIMIT).collect();
631 format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]")
632 }
633
634 // ── Streamable HTTP JSON-RPC plumbing ───────────────────────────────────────
635
636 /// POST a JSON-RPC request and return its `result`. Handles both an
637 /// `application/json` body and a `text/event-stream` (SSE) reply, captures the
638 /// session id from the response headers, and maps a JSON-RPC `error` to `Err`.
639 async fn post_rpc(
640 http: &reqwest::Client,
641 conn: &ServerConn,
642 body: &Value,
643 timeout: Duration,
644 ) -> Result<Value, String> {
645 // Give every outbound request a fresh id; the on-the-wire id in `body` is a
646 // placeholder we overwrite so callers don't have to thread a counter.
647 let mut body = body.clone();
648 if body.get("id").is_some()
649 && let Some(mcp) = MCP.get()
650 {
651 body["id"] = json!(mcp.next_id());
652 }
653
654 let response = build_request(http, conn, &body, timeout)
655 .await
656 .send()
657 .await
658 .map_err(|error| format!("request to {} failed: {error}", conn.url))?;
659
660 // Persist the session id the server assigns on initialize.
661 if let Some(session) = response
662 .headers()
663 .get("mcp-session-id")
664 .and_then(|value| value.to_str().ok())
665 .map(str::to_string)
666 {
667 *conn.session_id.lock().await = Some(session);
668 }
669
670 let status = response.status();
671 let content_type = response
672 .headers()
673 .get(reqwest::header::CONTENT_TYPE)
674 .and_then(|value| value.to_str().ok())
675 .unwrap_or("")
676 .to_string();
677
678 if !status.is_success() {
679 let detail = response.text().await.unwrap_or_default();
680 let detail: String = detail.chars().take(500).collect();
681 return Err(format!(
682 "server '{}' returned {}: {detail}",
683 conn.name,
684 status.as_u16()
685 ));
686 }
687
688 let text = response
689 .text()
690 .await
691 .map_err(|error| format!("reading response from '{}': {error}", conn.name))?;
692
693 let message = if content_type.contains("text/event-stream") {
694 parse_sse_response(&text)
695 .ok_or_else(|| format!("no JSON-RPC message in SSE reply from '{}'", conn.name))?
696 } else {
697 serde_json::from_str::<Value>(&text)
698 .map_err(|error| format!("parsing response from '{}': {error}", conn.name))?
699 };
700
701 if let Some(error) = message.get("error") {
702 let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
703 let msg = error
704 .get("message")
705 .and_then(Value::as_str)
706 .unwrap_or("unknown error");
707 return Err(format!("'{}' JSON-RPC error {code}: {msg}", conn.name));
708 }
709
710 message
711 .get("result")
712 .cloned()
713 .ok_or_else(|| format!("response from '{}' had no result", conn.name))
714 }
715
716 /// POST a JSON-RPC notification (no id, no response expected). A non-success
717 /// status is an error; an empty 202 body is the normal case.
718 async fn post_notification(
719 http: &reqwest::Client,
720 conn: &ServerConn,
721 body: &Value,
722 timeout: Duration,
723 ) -> Result<(), String> {
724 let response = build_request(http, conn, body, timeout)
725 .await
726 .send()
727 .await
728 .map_err(|error| format!("notification to {} failed: {error}", conn.url))?;
729 if !response.status().is_success() {
730 return Err(format!(
731 "server '{}' rejected notification: {}",
732 conn.name,
733 response.status().as_u16()
734 ));
735 }
736 Ok(())
737 }
738
739 /// Build a request carrying the MCP headers: the dual `Accept`, the JSON body,
740 /// the configured static headers, the negotiated protocol version, and the
741 /// session id once we have one.
742 async fn build_request(
743 http: &reqwest::Client,
744 conn: &ServerConn,
745 body: &Value,
746 timeout: Duration,
747 ) -> reqwest::RequestBuilder {
748 let mut request = http
749 .post(&conn.url)
750 .timeout(timeout)
751 .header(
752 reqwest::header::ACCEPT,
753 "application/json, text/event-stream",
754 )
755 .header("MCP-Protocol-Version", PROTOCOL_VERSION)
756 .json(body);
757
758 for (key, value) in &conn.headers {
759 request = request.header(key.as_str(), value.as_str());
760 }
761 if let Some(session) = conn.session_id.lock().await.as_ref() {
762 request = request.header("Mcp-Session-Id", session.as_str());
763 }
764 request
765 }
766
767 /// Extract the first JSON-RPC message from an SSE body. SSE frames are separated
768 /// by blank lines; each `data:` line contributes to the frame's payload. For a
769 /// single request/response exchange the server sends one `message` event whose
770 /// data is the JSON-RPC response.
771 fn parse_sse_response(body: &str) -> Option<Value> {
772 let mut data = String::new();
773 for line in body.lines() {
774 if let Some(rest) = line.strip_prefix("data:") {
775 if !data.is_empty() {
776 data.push('\n');
777 }
778 data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
779 } else if line.trim().is_empty() && !data.is_empty() {
780 // End of an event — try to parse it as a JSON-RPC message.
781 if let Ok(value) = serde_json::from_str::<Value>(&data)
782 && (value.get("result").is_some() || value.get("error").is_some())
783 {
784 return Some(value);
785 }
786 data.clear();
787 }
788 }
789 // Trailing event without a closing blank line.
790 if !data.is_empty()
791 && let Ok(value) = serde_json::from_str::<Value>(&data)
792 && (value.get("result").is_some() || value.get("error").is_some())
793 {
794 return Some(value);
795 }
796 None
797 }
798
799 // ── Status reporting (`/mcp`) ────────────────────────────────────────────────
800
801 /// Human-readable summary of configured MCP servers and their tools, for the
802 /// `/mcp` slash command.
803 pub fn status_summary() -> String {
804 let Some(mcp) = MCP.get() else {
805 return "MCP is not initialized.".to_string();
806 };
807 if mcp.servers.is_empty() {
808 return "No MCP servers configured. Add one in ~/.config/sigit/mcp.toml \
809 or .sigit/mcp.toml. See https://modelcontextprotocol.io."
810 .to_string();
811 }
812
813 let total_tools: usize = mcp.servers.iter().map(|s| s.tools.len()).sum();
814 let mut lines = vec![format!(
815 "{} MCP server(s), {total_tools} tool(s) available:",
816 mcp.servers.len()
817 )];
818 for server in &mcp.servers {
819 match &server.error {
820 Some(error) => lines.push(format!(
821 "- {} ({}) — unavailable: {error}",
822 server.name, server.url
823 )),
824 None => {
825 lines.push(format!(
826 "- {} ({}) — {} tool(s)",
827 server.name,
828 server.url,
829 server.tools.len()
830 ));
831 for tool in &server.tools {
832 lines.push(format!(" • {}", tool.full_name));
833 }
834 }
835 }
836 }
837 lines.join("\n")
838 }
839
840 #[cfg(test)]
841 mod tests {
842 use super::*;
843
844 #[test]
845 fn is_mcp_tool_detects_prefix() {
846 assert!(is_mcp_tool("mcp__sigit__search"));
847 assert!(!is_mcp_tool("read_file"));
848 assert!(!is_mcp_tool("skill"));
849 }
850
851 #[test]
852 fn official_tool_name_matches_the_namespacing_convention() {
853 assert_eq!(official_tool_name("list_issues"), "mcp__sigit__list_issues");
854 assert_eq!(
855 official_tool_name("get_pull_request"),
856 "mcp__sigit__get_pull_request"
857 );
858 }
859
860 #[test]
861 fn official_tool_suffix_strips_only_the_official_namespace() {
862 assert_eq!(
863 official_tool_suffix("mcp__sigit__list_issues"),
864 Some("list_issues")
865 );
866 assert_eq!(official_tool_suffix("mcp__other__list_issues"), None);
867 // `sigit` must be the whole server name, not a prefix of it.
868 assert_eq!(official_tool_suffix("mcp__sigitx__list_issues"), None);
869 assert_eq!(official_tool_suffix("list_issues"), None);
870 assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
871 }
872
873 #[test]
874 fn sanitize_collapses_invalid_chars() {
875 assert_eq!(sanitize("github"), "github");
876 assert_eq!(sanitize("my server"), "my_server");
877 assert_eq!(sanitize("a.b/c:d"), "a_b_c_d");
878 assert_eq!(sanitize("keep-_ok9"), "keep-_ok9");
879 }
880
881 #[test]
882 fn parses_mcp_file_with_servers() {
883 let toml = r#"
884 official = false
885
886 [[server]]
887 name = "github"
888 url = "https://api.example.com/mcp"
889
890 [[server]]
891 name = "disabled-one"
892 url = "https://nope.example.com/mcp"
893 enabled = false
894
895 [server.headers]
896 Authorization = "Bearer xyz"
897 "#;
898 let parsed: McpFile = toml::from_str(toml).unwrap();
899 assert_eq!(parsed.official, Some(false));
900 assert_eq!(parsed.server.len(), 2);
901 assert_eq!(parsed.server[0].name, "github");
902 assert_eq!(parsed.server[1].enabled, Some(false));
903 assert_eq!(
904 parsed.server[1]
905 .headers
906 .get("Authorization")
907 .map(String::as_str),
908 Some("Bearer xyz")
909 );
910 }
911
912 #[test]
913 fn upsert_replaces_same_name() {
914 let mut defs = vec![ServerDef {
915 name: "a".into(),
916 url: "u1".into(),
917 headers: vec![],
918 }];
919 upsert(
920 &mut defs,
921 ServerDef {
922 name: "a".into(),
923 url: "u2".into(),
924 headers: vec![],
925 },
926 );
927 assert_eq!(defs.len(), 1);
928 assert_eq!(defs[0].url, "u2");
929 }
930
931 #[test]
932 fn parse_sse_extracts_jsonrpc_response() {
933 let body =
934 "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n";
935 let value = parse_sse_response(body).expect("a message");
936 assert_eq!(value["result"]["ok"], json!(true));
937 }
938
939 #[test]
940 fn parse_sse_handles_no_trailing_blank_line() {
941 let body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}";
942 assert!(parse_sse_response(body).is_some());
943 }
944
945 #[test]
946 fn parse_sse_ignores_non_response_frames() {
947 // A lone notification (no result/error) shouldn't be mistaken for the response.
948 let body = "data: {\"jsonrpc\":\"2.0\",\"method\":\"ping\"}\n\n";
949 assert!(parse_sse_response(body).is_none());
950 }
951
952 #[test]
953 fn render_result_joins_text_blocks() {
954 let result = json!({
955 "content": [
956 { "type": "text", "text": "line one" },
957 { "type": "text", "text": "line two" }
958 ]
959 });
960 assert_eq!(render_tool_result(&result), "line one\nline two");
961 }
962
963 #[test]
964 fn render_result_marks_errors_and_non_text() {
965 let result = json!({
966 "isError": true,
967 "content": [
968 { "type": "text", "text": "boom" },
969 { "type": "image", "data": "..." }
970 ]
971 });
972 let rendered = render_tool_result(&result);
973 assert!(rendered.starts_with("Tool reported an error:"));
974 assert!(rendered.contains("boom"));
975 assert!(rendered.contains("[image content omitted]"));
976 }
977
978 #[test]
979 fn render_result_falls_back_to_structured_content() {
980 let result = json!({ "structuredContent": { "value": 42 } });
981 assert!(render_tool_result(&result).contains("42"));
982 }
983
984 #[test]
985 fn truncate_caps_long_output() {
986 let long = "x".repeat(RESULT_CHAR_LIMIT + 100);
987 let out = truncate(long);
988 assert!(out.contains("[output truncated"));
989 }
990
991 #[test]
992 fn tool_specs_empty_before_init() {
993 // Without init() the global is unset; this must not panic.
994 assert!(super::tool_specs().is_empty() || MCP.get().is_some());
995 }
996 }