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