@setoelkahfi / sigit / commits / 005429b

Add MCP client support and bake in the official siGit Code MCP server

Implement the client half of the Model Context Protocol so siGit Code can connect to MCP servers, discover the tools they expose, and offer them to the model alongside its built-in tools. Tool calls are forwarded to the owning server and the results fed back into the agent loop. - New src/mcp.rs: Streamable HTTP transport (single JSON-RPC POST endpoint, handling both application/json and text/event-stream replies), the initialize/tools-list handshake, tools/call dispatch, namespaced tool specs (mcp__<server>__<tool>), and a status summary for /mcp. - Bakes in the official server at <cloud>/mcp (default https://sigit.si/api/v1/mcp), authed with the cloud session token when signed in. Extra servers come from mcp.toml (global $SIGIT_CONFIG_DIR/mcp.toml and project-local .sigit/mcp.toml). - Discovery is best-effort at startup (mcp::init from both entry points), bounded by a per-server timeout and run concurrently, so an unreachable server never blocks startup — it just contributes no tools. - Wires MCP into both agent loops: appended in the spec builders and routed in tools::execute_tool. Adds a /mcp slash command (TUI + ACP), help text, and command advertisement. - SIGIT_MCP=off disables MCP entirely; SIGIT_MCP_OFFICIAL=off drops the baked-in server. Updates CLAUDE.md and CHANGELOG.md (Unreleased section). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsGodCvtg3Pdes1SUdxYSw

Claude committed Jun 30, 2026 at 20:45 UTC 005429b80a4afb1d314e49323c725e8afac7b001
6 files changed +1015 -2
CHANGELOG.md
+15
index ea6b81b..066d59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +Adds [Model Context Protocol](https://modelcontextprotocol.io) (MCP) client +support and bakes in the official siGit Code MCP server. + +### What changed + +- siGit Code is now an MCP client: it connects to MCP servers over the Streamable HTTP transport (a single JSON-RPC endpoint), discovers the tools they expose, and offers them to the model alongside the built-in tools. When the model calls one, the call is forwarded to the owning server and the result fed back into the agent loop +- Bakes in the official siGit Code MCP server at `https://sigit.si/api/v1/mcp` (follows `SIGIT_CLOUD_URL`). When you are signed in (`sigit login`), the cloud session token is sent as the bearer credential +- Configure additional servers in `mcp.toml` — global (`~/.config/sigit/mcp.toml`) or project-local (`.sigit/mcp.toml`). Each `[[server]]` has a `name`, `url`, optional `enabled`, and optional `[server.headers]`; set `official = false` to opt out of the baked-in server +- MCP tools are namespaced `mcp__<server>__<tool>` so they never collide with built-in tools or across servers; tool output is capped to protect the model's context +- Discovery is best-effort at startup and bounded by a per-server timeout, so an unreachable server never blocks startup — it just contributes no tools +- Added a `/mcp` slash command (TUI and ACP) that lists configured servers, their connection status, and the tools each exposes +- Disable MCP entirely with `SIGIT_MCP=off`, or just the official server with `SIGIT_MCP_OFFICIAL=off` + ## 1.3.0 Adds a Local Inference on/off toggle, the open [Agent Skills](https://agentskills.io)
CLAUDE.md
+13 -2
index 5fc3327..84817f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,16 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic, and only when at least one skill exists. +- **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Connects to + MCP servers over the **Streamable HTTP** transport (one JSON-RPC POST endpoint; replies are + `application/json` or SSE), runs the `initialize`/`tools/list` handshake, and forwards `tools/call`. + Discovery is best-effort at startup (`mcp::init`, called from both branches of `main()`) and cached + in a process-global so the synchronous spec builders (`mcp::tool_specs`) and the async dispatch + (`mcp::call_tool`) can both read it. Tools are namespaced `mcp__<server>__<tool>`, appended in the + `*_as_specs`/`build_tool_specs` layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. The + official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in and authed with the + cloud session token; extra servers live in `mcp.toml` (global `$SIGIT_CONFIG_DIR/mcp.toml` and + project-local `.sigit/mcp.toml`). stdio transport is not supported. - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills. Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`, walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it), @@ -89,7 +99,7 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix). - **`src/models.rs`** — model-picker types shared across platforms. -Slash commands (`/help`, `/models`, `/skills`, `/login`, `/logout`, `/whoami`, `/reload`, +Slash commands (`/help`, `/models`, `/skills`, `/mcp`, `/login`, `/logout`, `/whoami`, `/reload`, `/clear`, `/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the TUI and ACP sessions. @@ -111,7 +121,8 @@ verbosity with `RUST_LOG`. `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base, default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`), -`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`. +`SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in +server), `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`. ## Releasing
src/chat.rs
+11
index 680de85..b0a7a01 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -738,6 +738,8 @@ mod tui { Local(Option<bool>), /// List discovered Agent Skills. Skills, + /// List configured MCP servers and their tools. + Mcp, /// explicitly load the selected (or default) on-device model Load, /// `/login <email> <password>` — the raw argument, parsed when executed. @@ -763,6 +765,7 @@ mod tui { "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())), "/local" => SlashCommand::Local(parse_on_off(arg)), "/skills" => SlashCommand::Skills, + "/mcp" => SlashCommand::Mcp, "/load" => SlashCommand::Load, "/login" => SlashCommand::Login(arg.map(str::to_string)), "/logout" => SlashCommand::Logout, @@ -1359,6 +1362,7 @@ mod tui { /models N — switch to model N\n\ /local [on|off]— toggle on-device inference mode\n\ /skills — list available Agent Skills\n\ + /mcp — list MCP servers and their tools\n\ /load — load the selected on-device model\n\ /login E P — sign in to siGit Code Cloud\n\ /logout — sign out\n\ @@ -1388,6 +1392,10 @@ mod tui { app.messages .push(ChatMessage::system(crate::skills::format_skills_list())); } + SlashCommand::Mcp => { + app.messages + .push(ChatMessage::system(crate::mcp::status_summary())); + } SlashCommand::Models(selection) => match selection { None => { app.open_model_picker(&engine); @@ -1533,6 +1541,9 @@ mod tui { }); } + // Tools discovered from configured MCP servers (incl. the official one). + specs.extend(crate::mcp::tool_specs()); + specs }
src/main.rs
+19
index 79c041d..77c2f35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,7 @@ mod backend; mod chat; mod credentials; mod instructions; +mod mcp; mod models; mod provider; mod settings; @@ -275,6 +276,9 @@ fn agent_tools_as_specs() -> Vec<ToolSpec> { }); } + // Tools discovered from configured MCP servers (incl. the official one). + specs.extend(mcp::tool_specs()); + specs } @@ -717,6 +721,7 @@ impl SiGitAgent { "on|off (optional)", ), AvailableCommand::new("skills", "List available Agent Skills"), + AvailableCommand::new("mcp", "List MCP servers and their tools"), AvailableCommand::new("load", "Load the selected on-device model"), with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"), AvailableCommand::new("logout", "Sign out of siGit Code Cloud"), @@ -1962,6 +1967,8 @@ enum SlashCommand { Local(Option<bool>), /// List discovered Agent Skills. Skills, + /// List configured MCP servers and their tools. + Mcp, /// Explicitly load the selected (or default) on-device model. Load, /// `/login <email> <password>` — the raw argument, parsed when executed. @@ -1989,6 +1996,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())), "/local" => SlashCommand::Local(parse_on_off(argument)), "/skills" => SlashCommand::Skills, + "/mcp" => SlashCommand::Mcp, "/load" => SlashCommand::Load, "/login" => SlashCommand::Login(argument.map(str::to_string)), "/logout" => SlashCommand::Logout, @@ -2096,6 +2104,7 @@ async fn exec_slash_acp( /models N - switch to model N\n\ /local [on|off]- toggle on-device inference mode\n\ /skills - list available Agent Skills\n\ + /mcp - list MCP servers and their tools\n\ /load - load the selected on-device model\n\ /login E P - sign in to siGit Code Cloud\n\ /logout - sign out\n\ @@ -2143,6 +2152,11 @@ async fn exec_slash_acp( .send_assistant_message(cx, session_id, skills::format_skills_list()) .ok(); } + SlashCommand::Mcp => { + agent + .send_assistant_message(cx, session_id, mcp::status_summary()) + .ok(); + } SlashCommand::Models(Some(number)) => { let items = models::build_model_picker_items(); let index = number.saturating_sub(1); @@ -2775,6 +2789,9 @@ async fn main() -> anyhow::Result<()> { let (tty, cleanup_tty) = redirect_output_to_log()?; init_logging(true); setup::setup_shared_model_cache(); + // Best-effort: discover MCP servers (incl. the official one) before + // the first turn so their tools are offered to the model. + mcp::init().await; run_interactive(tty, cleanup_tty).await } #[cfg(not(unix))] @@ -2786,6 +2803,8 @@ async fn main() -> anyhow::Result<()> { // Logs already go to stderr via `init_logging(false)`. init_logging(false); setup::setup_shared_model_cache(); + // Best-effort MCP discovery (incl. the official server) before serving. + mcp::init().await; log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION")); run_acp_server().await }
src/mcp.rs
+954
new file mode 100644 index 0000000..ca6f7c3 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,954 @@ +//! Model Context Protocol (MCP) client for siGit Code. +//! +//! Implements the client half of the [Model Context Protocol](https://modelcontextprotocol.io): +//! siGit Code connects to one or more MCP servers, discovers the tools they +//! expose, and surfaces those tools to the model alongside its built-in ones. +//! When the model calls an MCP tool, the call is forwarded to the owning server +//! and the result fed back into the agent loop. +//! +//! Transport: the modern **Streamable HTTP** transport — a single HTTP endpoint +//! the client POSTs JSON-RPC 2.0 messages to. The server answers either with a +//! single `application/json` body or a `text/event-stream` (SSE) stream that +//! carries the JSON-RPC response. Both are handled here. stdio transport is not +//! supported (siGit Code never spawns child processes for inference). +//! +//! ## The official server +//! +//! siGit Code bakes in its official MCP server at `<cloud>/mcp` (default +//! `https://sigit.si/api/v1/mcp`, following `SIGIT_CLOUD_URL`). When the user is +//! signed in (`sigit login`) the cloud session token is sent as a bearer +//! credential. Additional servers are configured in `mcp.toml` (see +//! [`load_configs`]). +//! +//! ## Lifecycle +//! +//! Discovery is best-effort and happens once at startup via [`init`]: each +//! configured server is contacted concurrently (with a per-server timeout), +//! runs the `initialize` handshake, and has its `tools/list` cached. A server +//! that fails to connect is recorded with its error and simply contributes no +//! tools — it never blocks startup or the rest of the agent. The result is +//! stored in a process-global so the synchronous tool-spec builders +//! ([`tool_specs`]) and the async dispatch ([`call_tool`]) can both read it. +//! +//! Tools are namespaced `mcp__<server>__<tool>` so they never collide with +//! built-in tools or with each other across servers. This mirrors the +//! convention used by other MCP-aware agents. +//! +//! Like the rest of the backend seam, MCP is wired up only through the +//! interactive client and the ACP agent loop. On non-Unix targets a few helpers +//! are unused, so the dead-code lint is suppressed there only. +#![cfg_attr(not(unix), allow(dead_code))] + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::sync::Mutex; + +use crate::backend::ToolSpec; + +/// Prefix marking a tool as MCP-provided. The full name is +/// `mcp__<server>__<tool>`. +pub const MCP_PREFIX: &str = "mcp__"; + +/// JSON-RPC / MCP protocol version we advertise in the handshake. +const PROTOCOL_VERSION: &str = "2025-06-18"; + +/// Per-server budget for the connect + `initialize` + `tools/list` handshake at +/// startup. Bounds how long an unreachable server can delay startup; servers are +/// contacted concurrently, so this is the worst case for the whole set, not the +/// sum. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(8); + +/// Overall request timeout for an individual `tools/call`. Generous, since an +/// MCP tool may do real work server-side. +const CALL_TIMEOUT: Duration = Duration::from_secs(120); + +/// Cap on the characters returned from a single tool call, so a chatty server +/// can't blow up the model's context. Matches the spirit of the file-read cap. +const RESULT_CHAR_LIMIT: usize = 30_000; + +// ── Public types ────────────────────────────────────────────────────────────── + +/// A tool discovered on an MCP server, in siGit's flattened form. +#[derive(Debug, Clone)] +struct McpTool { + /// Namespaced name exposed to the model: `mcp__<server>__<tool>`. + full_name: String, + /// The tool's name as the server knows it (sent back in `tools/call`). + remote_name: String, + /// Human/model-facing description, prefixed with the server name. + description: String, + /// JSON Schema for the tool's arguments, encoded as a string. + parameters_schema: String, +} + +/// A configured MCP server and its live connection state. +struct ServerConn { + /// Sanitized server name used in tool namespacing and the `/mcp` listing. + name: String, + /// Streamable HTTP endpoint (the single POST URL). + url: String, + /// Extra headers sent on every request (e.g. `Authorization`). + headers: Vec<(String, String)>, + /// Session id handed back by the server on `initialize`, echoed on every + /// later request via the `Mcp-Session-Id` header. + session_id: Mutex<Option<String>>, + /// Tools discovered at startup. Empty when the server failed to connect. + tools: Vec<McpTool>, + /// Connection error, if the handshake failed. Surfaced by `/mcp`. + error: Option<String>, +} + +/// The process-global MCP state: a shared HTTP client plus every configured +/// server. +struct Mcp { + http: reqwest::Client, + servers: Vec<ServerConn>, + next_id: AtomicI64, +} + +static MCP: OnceLock<Mcp> = OnceLock::new(); + +// ── Configuration ─────────────────────────────────────────────────────────── + +/// Default endpoint of the official siGit Code MCP server, derived from the +/// cloud base URL so `SIGIT_CLOUD_URL` (dev) carries over. +fn official_url() -> String { + format!( + "{}/mcp", + crate::provider::cloud_base_url().trim_end_matches('/') + ) +} + +/// A server entry as written in `mcp.toml`. +#[derive(Debug, Deserialize)] +struct ServerEntry { + name: String, + url: String, + /// Set `enabled = false` to keep an entry in the file but skip connecting. + #[serde(default)] + enabled: Option<bool>, + /// Static headers, e.g. `Authorization = "Bearer ..."`. + #[serde(default)] + headers: BTreeMap<String, String>, +} + +/// The `mcp.toml` schema. +#[derive(Debug, Default, Deserialize)] +struct McpFile { + /// Include the baked-in official server. Defaults to `true`; set `false` to + /// opt out. + #[serde(default)] + official: Option<bool>, + #[serde(default)] + server: Vec<ServerEntry>, +} + +/// A resolved server definition, before connecting. +#[derive(Debug, Clone)] +struct ServerDef { + name: String, + url: String, + headers: Vec<(String, String)>, +} + +/// Config files to read, in priority order (later wins on a name clash): +/// global `$SIGIT_CONFIG_DIR/mcp.toml`, then project-local `<cwd>/.sigit/mcp.toml`. +fn config_paths() -> Vec<PathBuf> { + let mut paths = Vec::new(); + if let Some(dir) = sigit_config_dir() { + paths.push(dir.join("mcp.toml")); + } + if let Ok(cwd) = std::env::current_dir() { + paths.push(cwd.join(".sigit").join("mcp.toml")); + } + paths +} + +fn sigit_config_dir() -> Option<PathBuf> { + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") + && !dir.is_empty() + { + return Some(PathBuf::from(dir)); + } + std::env::var("HOME") + .ok() + .map(|home| PathBuf::from(home).join(".config").join("sigit")) +} + +/// Resolve the full set of servers to connect to: the baked-in official server +/// (unless opted out) plus any from `mcp.toml`. Project-local entries override +/// global ones, and a user entry named `sigit` overrides the official default. +fn load_configs() -> Vec<ServerDef> { + // Global escape hatch: `SIGIT_MCP=off` disables MCP entirely. + if let Ok(value) = std::env::var("SIGIT_MCP") + && matches!( + value.trim().to_ascii_lowercase().as_str(), + "off" | "0" | "false" | "no" | "disabled" + ) + { + log::info!("mcp: disabled via SIGIT_MCP"); + return Vec::new(); + } + + let mut include_official = true; + // De-duplicated by sanitized name; a later config file overrides an earlier + // one for the same name (project-local wins over global). + let mut defs: Vec<ServerDef> = Vec::new(); + + for path in config_paths() { + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + let parsed: McpFile = match toml::from_str(&contents) { + Ok(parsed) => parsed, + Err(error) => { + log::warn!("mcp: ignoring {}: {error}", path.display()); + continue; + } + }; + if let Some(official) = parsed.official { + include_official = official; + } + for entry in parsed.server { + if entry.enabled == Some(false) { + continue; + } + let name = sanitize(&entry.name); + if name.is_empty() || entry.url.trim().is_empty() { + log::warn!( + "mcp: skipping server with empty name/url in {}", + path.display() + ); + continue; + } + let headers = entry.headers.into_iter().collect(); + upsert( + &mut defs, + ServerDef { + name, + url: entry.url.trim().to_string(), + headers, + }, + ); + } + } + + // The official server can also be disabled with SIGIT_MCP_OFFICIAL=off. + if let Ok(value) = std::env::var("SIGIT_MCP_OFFICIAL") + && matches!( + value.trim().to_ascii_lowercase().as_str(), + "off" | "0" | "false" | "no" + ) + { + include_official = false; + } + + // Add the baked-in official server, but never clobber a user-defined entry + // named `sigit` — an explicit config (e.g. a custom URL or headers) wins. + if include_official && !defs.iter().any(|d| d.name == "sigit") { + let mut headers = Vec::new(); + if let Some(token) = crate::credentials::load_token() { + headers.push(("Authorization".to_string(), format!("Bearer {token}"))); + } + defs.push(ServerDef { + name: "sigit".to_string(), + url: official_url(), + headers, + }); + } + + defs +} + +/// Insert `def`, replacing any existing entry with the same name. +fn upsert(defs: &mut Vec<ServerDef>, def: ServerDef) { + if let Some(slot) = defs.iter_mut().find(|d| d.name == def.name) { + *slot = def; + } else { + defs.push(def); + } +} + +/// Sanitize a name into the `[a-zA-Z0-9_-]` set tool names are restricted to, +/// collapsing anything else to `_`. +fn sanitize(raw: &str) -> String { + raw.trim() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect() +} + +// ── Startup / discovery ───────────────────────────────────────────────────── + +/// Connect to every configured server and cache the tools they expose. Idempotent +/// and best-effort: a server that can't be reached is recorded with its error and +/// contributes no tools. Safe to call from either entry point; only the first +/// call does work. +pub async fn init() { + if MCP.get().is_some() { + return; + } + + let defs = load_configs(); + let http = reqwest::Client::builder() + .timeout(CALL_TIMEOUT) + .user_agent(concat!( + "sigit/", + env!("CARGO_PKG_VERSION"), + " (mcp-client)" + )) + .build() + .unwrap_or_default(); + + // Contact servers concurrently so one slow/unreachable host doesn't serialize + // the rest. Each handshake is bounded by HANDSHAKE_TIMEOUT. + let connects = defs.into_iter().map(|def| { + let http = http.clone(); + async move { connect(&http, def).await } + }); + let servers = futures::future::join_all(connects).await; + + for server in &servers { + match &server.error { + Some(error) => log::warn!("mcp: server '{}' unavailable: {error}", server.name), + None => log::info!( + "mcp: server '{}' ready, {} tool(s)", + server.name, + server.tools.len() + ), + } + } + + let _ = MCP.set(Mcp { + http, + servers, + next_id: AtomicI64::new(1), + }); +} + +/// Run the handshake against one server and collect its tools. Always returns a +/// `ServerConn`; failures land in its `error` field rather than propagating. +async fn connect(http: &reqwest::Client, def: ServerDef) -> ServerConn { + let mut conn = ServerConn { + name: def.name.clone(), + url: def.url.clone(), + headers: def.headers.clone(), + session_id: Mutex::new(None), + tools: Vec::new(), + error: None, + }; + + let handshake = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { + // initialize → notifications/initialized → tools/list + initialize(http, &conn).await?; + notify_initialized(http, &conn).await?; + list_tools(http, &conn).await + }) + .await; + + match handshake { + Ok(Ok(tools)) => conn.tools = tools, + Ok(Err(error)) => conn.error = Some(error), + Err(_) => conn.error = Some(format!("timed out after {}s", HANDSHAKE_TIMEOUT.as_secs())), + } + + conn +} + +/// The `initialize` request: negotiate protocol version and capture the session +/// id from the response headers (handled inside [`post_rpc`]). +async fn initialize(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> { + let body = json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "sigit", "version": env!("CARGO_PKG_VERSION") } + } + }); + post_rpc(http, conn, &body, HANDSHAKE_TIMEOUT).await?; + Ok(()) +} + +/// The `notifications/initialized` notification. Servers expect it before +/// fielding requests; it carries no id and yields a 202 with no body. +async fn notify_initialized(http: &reqwest::Client, conn: &ServerConn) -> Result<(), String> { + let body = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + post_notification(http, conn, &body, HANDSHAKE_TIMEOUT).await +} + +/// `tools/list`, following `nextCursor` pagination, mapped into [`McpTool`]s. +async fn list_tools(http: &reqwest::Client, conn: &ServerConn) -> Result<Vec<McpTool>, String> { + let mut tools = Vec::new(); + let mut cursor: Option<String> = None; + + loop { + let params = match &cursor { + Some(c) => json!({ "cursor": c }), + None => json!({}), + }; + let body = json!({ "jsonrpc": "2.0", "id": 0, "method": "tools/list", "params": params }); + let result = post_rpc(http, conn, &body, HANDSHAKE_TIMEOUT).await?; + + for tool in result + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(remote_name) = tool.get("name").and_then(Value::as_str) else { + continue; + }; + let full_name = format!("{MCP_PREFIX}{}__{}", conn.name, sanitize(remote_name)); + if full_name.chars().count() > 64 { + log::warn!( + "mcp: tool name '{full_name}' exceeds 64 chars; some backends may reject it" + ); + } + let remote_desc = tool + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let description = if remote_desc.is_empty() { + format!("[MCP server '{}'] {remote_name}", conn.name) + } else { + format!("[MCP server '{}'] {remote_desc}", conn.name) + }; + // `inputSchema` is a JSON Schema object; default to a permissive + // object schema when a server omits it. + let parameters_schema = tool + .get("inputSchema") + .filter(|schema| schema.is_object()) + .cloned() + .unwrap_or_else(|| json!({ "type": "object" })) + .to_string(); + + tools.push(McpTool { + full_name, + remote_name: remote_name.to_string(), + description, + parameters_schema, + }); + } + + cursor = result + .get("nextCursor") + .and_then(Value::as_str) + .map(str::to_string); + if cursor.is_none() { + break; + } + } + + Ok(tools) +} + +// ── Tool exposure + dispatch ──────────────────────────────────────────────── + +/// Whether a tool name belongs to MCP. The dispatch in `tools::execute_tool` +/// uses this to route a call here. +pub fn is_mcp_tool(name: &str) -> bool { + name.starts_with(MCP_PREFIX) +} + +/// All discovered MCP tools as agent [`ToolSpec`]s, ready to append to the +/// built-in tool list. Empty when MCP is uninitialized or no server exposed any. +pub fn tool_specs() -> Vec<ToolSpec> { + let Some(mcp) = MCP.get() else { + return Vec::new(); + }; + let mut specs = Vec::new(); + for server in &mcp.servers { + for tool in &server.tools { + specs.push(ToolSpec { + name: tool.full_name.clone(), + description: tool.description.clone(), + parameters_schema: tool.parameters_schema.clone(), + }); + } + } + specs +} + +/// Execute an MCP tool call by name, returning text to feed back to the model. +/// Errors are returned as plain strings (never panics) so a failing tool degrades +/// to a message the model can react to, exactly like the built-in tools. +pub async fn call_tool(full_name: &str, arguments: &str) -> String { + let Some(mcp) = MCP.get() else { + return "Error: MCP is not initialized.".to_string(); + }; + + let Some((server, tool)) = mcp.servers.iter().find_map(|s| { + s.tools + .iter() + .find(|t| t.full_name == full_name) + .map(|t| (s, t)) + }) else { + return format!("Error: unknown MCP tool \"{full_name}\"."); + }; + + // Arguments arrive as a JSON-encoded string; an empty/blank string means no + // arguments. Anything that isn't a JSON object is a model mistake. + let args: Value = if arguments.trim().is_empty() { + json!({}) + } else { + match serde_json::from_str(arguments) { + Ok(value @ Value::Object(_)) => value, + Ok(_) => return "Error: tool arguments must be a JSON object.".to_string(), + Err(error) => return format!("Error: failed to parse arguments: {error}"), + } + }; + + match mcp.call(server, &tool.remote_name, args).await { + Ok(text) => truncate(text), + Err(error) => format!("Error: {error}"), + } +} + +impl Mcp { + /// Send a `tools/call` and render the result into text. Retries once after a + /// re-`initialize` if the session was dropped (HTTP 404), which is how + /// Streamable HTTP signals an expired session. + async fn call( + &self, + server: &ServerConn, + remote_name: &str, + args: Value, + ) -> Result<String, String> { + let body = json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "tools/call", + "params": { "name": remote_name, "arguments": args } + }); + + let result = match post_rpc(&self.http, server, &body, CALL_TIMEOUT).await { + Ok(result) => result, + Err(error) if error.contains("returned 404") => { + // Session expired — drop it, re-handshake, and retry once. + *server.session_id.lock().await = None; + initialize(&self.http, server).await?; + notify_initialized(&self.http, server).await?; + post_rpc(&self.http, server, &body, CALL_TIMEOUT).await? + } + Err(error) => return Err(error), + }; + + Ok(render_tool_result(&result)) + } + + fn next_id(&self) -> i64 { + self.next_id.fetch_add(1, Ordering::Relaxed) + } +} + +/// Flatten an MCP `tools/call` result into text. Joins text content blocks; +/// notes non-text blocks; honors `isError`. +fn render_tool_result(result: &Value) -> String { + let mut out = String::new(); + if let Some(blocks) = result.get("content").and_then(Value::as_array) { + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); + } + } + Some(other) => { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&format!("[{other} content omitted]")); + } + None => {} + } + } + } + + // Some servers return only `structuredContent`; surface it if there was no + // textual content. + if out.is_empty() + && let Some(structured) = result.get("structuredContent") + { + out = structured.to_string(); + } + + if out.is_empty() { + out = "(tool returned no content)".to_string(); + } + + if result.get("isError").and_then(Value::as_bool) == Some(true) { + format!("Tool reported an error:\n{out}") + } else { + out + } +} + +/// Truncate tool output to the context-protecting limit, with a trailing note. +fn truncate(text: String) -> String { + if text.chars().count() <= RESULT_CHAR_LIMIT { + return text; + } + let kept: String = text.chars().take(RESULT_CHAR_LIMIT).collect(); + format!("{kept}\n\n[output truncated to {RESULT_CHAR_LIMIT} characters]") +} + +// ── Streamable HTTP JSON-RPC plumbing ─────────────────────────────────────── + +/// POST a JSON-RPC request and return its `result`. Handles both an +/// `application/json` body and a `text/event-stream` (SSE) reply, captures the +/// session id from the response headers, and maps a JSON-RPC `error` to `Err`. +async fn post_rpc( + http: &reqwest::Client, + conn: &ServerConn, + body: &Value, + timeout: Duration, +) -> Result<Value, String> { + // Give every outbound request a fresh id; the on-the-wire id in `body` is a + // placeholder we overwrite so callers don't have to thread a counter. + let mut body = body.clone(); + if body.get("id").is_some() + && let Some(mcp) = MCP.get() + { + body["id"] = json!(mcp.next_id()); + } + + let response = build_request(http, conn, &body, timeout) + .await + .send() + .await + .map_err(|error| format!("request to {} failed: {error}", conn.url))?; + + // Persist the session id the server assigns on initialize. + if let Some(session) = response + .headers() + .get("mcp-session-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + { + *conn.session_id.lock().await = Some(session); + } + + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + + if !status.is_success() { + let detail = response.text().await.unwrap_or_default(); + let detail: String = detail.chars().take(500).collect(); + return Err(format!( + "server '{}' returned {}: {detail}", + conn.name, + status.as_u16() + )); + } + + let text = response + .text() + .await + .map_err(|error| format!("reading response from '{}': {error}", conn.name))?; + + let message = if content_type.contains("text/event-stream") { + parse_sse_response(&text) + .ok_or_else(|| format!("no JSON-RPC message in SSE reply from '{}'", conn.name))? + } else { + serde_json::from_str::<Value>(&text) + .map_err(|error| format!("parsing response from '{}': {error}", conn.name))? + }; + + if let Some(error) = message.get("error") { + let code = error.get("code").and_then(Value::as_i64).unwrap_or(0); + let msg = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error"); + return Err(format!("'{}' JSON-RPC error {code}: {msg}", conn.name)); + } + + message + .get("result") + .cloned() + .ok_or_else(|| format!("response from '{}' had no result", conn.name)) +} + +/// POST a JSON-RPC notification (no id, no response expected). A non-success +/// status is an error; an empty 202 body is the normal case. +async fn post_notification( + http: &reqwest::Client, + conn: &ServerConn, + body: &Value, + timeout: Duration, +) -> Result<(), String> { + let response = build_request(http, conn, body, timeout) + .await + .send() + .await + .map_err(|error| format!("notification to {} failed: {error}", conn.url))?; + if !response.status().is_success() { + return Err(format!( + "server '{}' rejected notification: {}", + conn.name, + response.status().as_u16() + )); + } + Ok(()) +} + +/// Build a request carrying the MCP headers: the dual `Accept`, the JSON body, +/// the configured static headers, the negotiated protocol version, and the +/// session id once we have one. +async fn build_request( + http: &reqwest::Client, + conn: &ServerConn, + body: &Value, + timeout: Duration, +) -> reqwest::RequestBuilder { + let mut request = http + .post(&conn.url) + .timeout(timeout) + .header( + reqwest::header::ACCEPT, + "application/json, text/event-stream", + ) + .header("MCP-Protocol-Version", PROTOCOL_VERSION) + .json(body); + + for (key, value) in &conn.headers { + request = request.header(key.as_str(), value.as_str()); + } + if let Some(session) = conn.session_id.lock().await.as_ref() { + request = request.header("Mcp-Session-Id", session.as_str()); + } + request +} + +/// Extract the first JSON-RPC message from an SSE body. SSE frames are separated +/// by blank lines; each `data:` line contributes to the frame's payload. For a +/// single request/response exchange the server sends one `message` event whose +/// data is the JSON-RPC response. +fn parse_sse_response(body: &str) -> Option<Value> { + let mut data = String::new(); + for line in body.lines() { + if let Some(rest) = line.strip_prefix("data:") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(rest.strip_prefix(' ').unwrap_or(rest)); + } else if line.trim().is_empty() && !data.is_empty() { + // End of an event — try to parse it as a JSON-RPC message. + if let Ok(value) = serde_json::from_str::<Value>(&data) + && (value.get("result").is_some() || value.get("error").is_some()) + { + return Some(value); + } + data.clear(); + } + } + // Trailing event without a closing blank line. + if !data.is_empty() + && let Ok(value) = serde_json::from_str::<Value>(&data) + && (value.get("result").is_some() || value.get("error").is_some()) + { + return Some(value); + } + None +} + +// ── Status reporting (`/mcp`) ──────────────────────────────────────────────── + +/// Human-readable summary of configured MCP servers and their tools, for the +/// `/mcp` slash command. +pub fn status_summary() -> String { + let Some(mcp) = MCP.get() else { + return "MCP is not initialized.".to_string(); + }; + if mcp.servers.is_empty() { + return "No MCP servers configured. Add one in ~/.config/sigit/mcp.toml \ + or .sigit/mcp.toml. See https://modelcontextprotocol.io." + .to_string(); + } + + let total_tools: usize = mcp.servers.iter().map(|s| s.tools.len()).sum(); + let mut lines = vec![format!( + "{} MCP server(s), {total_tools} tool(s) available:", + mcp.servers.len() + )]; + for server in &mcp.servers { + match &server.error { + Some(error) => lines.push(format!( + "- {} ({}) — unavailable: {error}", + server.name, server.url + )), + None => { + lines.push(format!( + "- {} ({}) — {} tool(s)", + server.name, + server.url, + server.tools.len() + )); + for tool in &server.tools { + lines.push(format!(" • {}", tool.full_name)); + } + } + } + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_mcp_tool_detects_prefix() { + assert!(is_mcp_tool("mcp__sigit__search")); + assert!(!is_mcp_tool("read_file")); + assert!(!is_mcp_tool("skill")); + } + + #[test] + fn sanitize_collapses_invalid_chars() { + assert_eq!(sanitize("github"), "github"); + assert_eq!(sanitize("my server"), "my_server"); + assert_eq!(sanitize("a.b/c:d"), "a_b_c_d"); + assert_eq!(sanitize("keep-_ok9"), "keep-_ok9"); + } + + #[test] + fn parses_mcp_file_with_servers() { + let toml = r#" + official = false + + [[server]] + name = "github" + url = "https://api.example.com/mcp" + + [[server]] + name = "disabled-one" + url = "https://nope.example.com/mcp" + enabled = false + + [server.headers] + Authorization = "Bearer xyz" + "#; + let parsed: McpFile = toml::from_str(toml).unwrap(); + assert_eq!(parsed.official, Some(false)); + assert_eq!(parsed.server.len(), 2); + assert_eq!(parsed.server[0].name, "github"); + assert_eq!(parsed.server[1].enabled, Some(false)); + assert_eq!( + parsed.server[1] + .headers + .get("Authorization") + .map(String::as_str), + Some("Bearer xyz") + ); + } + + #[test] + fn upsert_replaces_same_name() { + let mut defs = vec![ServerDef { + name: "a".into(), + url: "u1".into(), + headers: vec![], + }]; + upsert( + &mut defs, + ServerDef { + name: "a".into(), + url: "u2".into(), + headers: vec![], + }, + ); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].url, "u2"); + } + + #[test] + fn parse_sse_extracts_jsonrpc_response() { + let body = + "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; + let value = parse_sse_response(body).expect("a message"); + assert_eq!(value["result"]["ok"], json!(true)); + } + + #[test] + fn parse_sse_handles_no_trailing_blank_line() { + let body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}"; + assert!(parse_sse_response(body).is_some()); + } + + #[test] + fn parse_sse_ignores_non_response_frames() { + // A lone notification (no result/error) shouldn't be mistaken for the response. + let body = "data: {\"jsonrpc\":\"2.0\",\"method\":\"ping\"}\n\n"; + assert!(parse_sse_response(body).is_none()); + } + + #[test] + fn render_result_joins_text_blocks() { + let result = json!({ + "content": [ + { "type": "text", "text": "line one" }, + { "type": "text", "text": "line two" } + ] + }); + assert_eq!(render_tool_result(&result), "line one\nline two"); + } + + #[test] + fn render_result_marks_errors_and_non_text() { + let result = json!({ + "isError": true, + "content": [ + { "type": "text", "text": "boom" }, + { "type": "image", "data": "..." } + ] + }); + let rendered = render_tool_result(&result); + assert!(rendered.starts_with("Tool reported an error:")); + assert!(rendered.contains("boom")); + assert!(rendered.contains("[image content omitted]")); + } + + #[test] + fn render_result_falls_back_to_structured_content() { + let result = json!({ "structuredContent": { "value": 42 } }); + assert!(render_tool_result(&result).contains("42")); + } + + #[test] + fn truncate_caps_long_output() { + let long = "x".repeat(RESULT_CHAR_LIMIT + 100); + let out = truncate(long); + assert!(out.contains("[output truncated")); + } + + #[test] + fn tool_specs_empty_before_init() { + // Without init() the global is unset; this must not panic. + assert!(super::tool_specs().is_empty() || MCP.get().is_some()); + } +}
src/tools.rs
+3
index 0c1052d..e48d9ec 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -254,6 +254,9 @@ pub async fn execute_tool(name: &str, arguments: &str) -> String { "delete_file" => exec_delete_file(arguments), "run_command" => exec_run_command(arguments), "skill" => crate::skills::activate_skill(arguments), + // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>` + // and forwarded to the owning server. + _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await, _ => format!("Unknown tool: {name}"), } }