401
.map(|home| PathBuf::from(home).join(".config").join("sigit"))
402
}
403
404
+/// Global escape hatch: `SIGIT_MCP=off` disables MCP entirely, including
405
+/// servers an ACP client supplies per session.
406
+fn disabled_by_env() -> bool {
407
+ std::env::var("SIGIT_MCP").is_ok_and(|value| {
408
+ matches!(
409
+ value.trim().to_ascii_lowercase().as_str(),
410
+ "off" | "0" | "false" | "no" | "disabled"
411
+ )
412
+ })
413
+}
414
+
415
/// Resolve the full set of servers to connect to: the baked-in official server
416
/// (unless opted out) plus any from `mcp.toml`. Project-local entries override
417
/// global ones, and a user entry named `sigit` overrides the official default.
418
fn load_configs() -> Vec<ServerDef> {
408
- // Global escape hatch: `SIGIT_MCP=off` disables MCP entirely.
409
- if let Ok(value) = std::env::var("SIGIT_MCP")
410
- && matches!(
411
- value.trim().to_ascii_lowercase().as_str(),
412
- "off" | "0" | "false" | "no" | "disabled"
413
- )
414
- {
419
+ if disabled_by_env() {
420
log::info!("mcp: disabled via SIGIT_MCP");
421
return Vec::new();
422
}
750
Ok(tools)
751
}
752
753
+// ── Client-supplied servers (per session) ───────────────────────────────────
754
+
755
+/// A stdio server an ACP client passed in `mcpServers` on a session request.
756
+#[derive(Debug, Clone)]
757
+pub struct ClientServer {
758
+ pub name: String,
759
+ pub command: String,
760
+ pub args: Vec<String>,
761
+ pub env: Vec<(String, String)>,
762
+}
763
+
764
+/// The MCP servers one ACP session brought with it, connected.
765
+///
766
+/// Unlike the startup servers in [`MCP`], these belong to a session: the
767
+/// client names them on `session/new` (or load/fork) and they are offered to
768
+/// that session only. Cheap to clone; the connections are shared. When the
769
+/// last clone goes away the children's stdin closes and they are killed.
770
+#[derive(Clone, Default)]
771
+pub struct SessionServers(Arc<Vec<ServerConn>>);
772
+
773
+impl std::fmt::Debug for SessionServers {
774
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
775
+ f.debug_list()
776
+ .entries(self.0.iter().map(|server| &server.name))
777
+ .finish()
778
+ }
779
+}
780
+
781
+/// The live session's client-supplied servers. One process serves every
782
+/// thread the editor has open, so like the roots in `workspace.rs` this
783
+/// always belongs to the session `main.rs` has installed.
784
+static SESSION_SERVERS: StdMutex<Option<SessionServers>> = StdMutex::new(None);
785
+
786
+/// Connect to the servers a client supplied for one session. Best effort, like
787
+/// [`init`]: a server that fails its handshake is kept with its error so `/mcp`
788
+/// can show it, and contributes no tools.
789
+///
790
+/// A name already taken by a startup server (or by an earlier entry in the same
791
+/// list) is not connected. Its tools would share the `mcp__<server>__` prefix
792
+/// with the other server's, and a call could not be routed.
793
+pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServers {
794
+ if servers.is_empty() {
795
+ return SessionServers::default();
796
+ }
797
+ if disabled_by_env() {
798
+ log::info!(
799
+ "mcp: ignoring {} client-supplied server(s), disabled via SIGIT_MCP",
800
+ servers.len()
801
+ );
802
+ return SessionServers::default();
803
+ }
804
+
805
+ let http = MCP.get().map(|mcp| mcp.http.clone()).unwrap_or_default();
806
+ let mut taken: Vec<String> = MCP
807
+ .get()
808
+ .map(|mcp| mcp.servers.iter().map(|s| s.name.clone()).collect())
809
+ .unwrap_or_default();
810
+
811
+ let mut clashes = Vec::new();
812
+ let mut defs = Vec::new();
813
+ for server in servers {
814
+ let name = sanitize(&server.name);
815
+ let transport = TransportDef::Stdio {
816
+ command: server.command,
817
+ args: server.args,
818
+ env: server.env,
819
+ };
820
+ if name.is_empty() || taken.contains(&name) {
821
+ clashes.push(ServerConn {
822
+ endpoint: transport.endpoint(),
823
+ transport: None,
824
+ tools: Vec::new(),
825
+ error: Some(if name.is_empty() {
826
+ "the client sent a server with no name".to_string()
827
+ } else {
828
+ format!("another MCP server is already named '{name}'")
829
+ }),
830
+ name,
831
+ });
832
+ continue;
833
+ }
834
+ taken.push(name.clone());
835
+ defs.push(ServerDef { name, transport });
836
+ }
837
+
838
+ let connects = defs.into_iter().map(|def| {
839
+ let http = http.clone();
840
+ async move { connect(&http, def).await }
841
+ });
842
+ let mut connected = futures::future::join_all(connects).await;
843
+ connected.extend(clashes);
844
+
845
+ for server in &connected {
846
+ match &server.error {
847
+ Some(error) => log::warn!(
848
+ "mcp: client-supplied server '{}' unavailable: {error}",
849
+ server.name
850
+ ),
851
+ None => log::info!(
852
+ "mcp: client-supplied server '{}' ready, {} tool(s)",
853
+ server.name,
854
+ server.tools.len()
855
+ ),
856
+ }
857
+ }
858
+
859
+ SessionServers(Arc::new(connected))
860
+}
861
+
862
+/// Make `servers` the live session's set. Called whenever `main.rs` installs a
863
+/// session, so a thread never sees another thread's servers.
864
+pub fn set_session_servers(servers: SessionServers) {
865
+ *SESSION_SERVERS.lock().unwrap() = Some(servers);
866
+}
867
+
868
+fn session_servers() -> SessionServers {
869
+ SESSION_SERVERS.lock().unwrap().clone().unwrap_or_default()
870
+}
871
+
872
// ── Tool exposure + dispatch ────────────────────────────────────────────────
873
874
/// Whether a tool name belongs to MCP. The dispatch in `tools::execute_tool`
878
}
879
880
/// All discovered MCP tools as agent [`ToolSpec`]s, ready to append to the
757
-/// built-in tool list. Empty when MCP is uninitialized or no server exposed any.
881
+/// built-in tool list: the startup servers' tools, then the ones the live
882
+/// session's client supplied. Empty when no server exposed any.
883
pub fn tool_specs() -> Vec<ToolSpec> {
759
- let Some(mcp) = MCP.get() else {
760
- return Vec::new();
761
- };
884
+ let session = session_servers();
885
+ let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
886
let mut specs = Vec::new();
763
- for server in &mcp.servers {
887
+ for server in startup.iter().chain(session.0.iter()) {
888
for tool in &server.tools {
889
specs.push(ToolSpec {
890
name: tool.full_name.clone(),
900
/// Errors are returned as plain strings (never panics) so a failing tool degrades
901
/// to a message the model can react to, exactly like the built-in tools.
902
pub async fn call_tool(full_name: &str, arguments: &str) -> String {
779
- let Some(mcp) = MCP.get() else {
903
+ let session = session_servers();
904
+ let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
905
+ if MCP.get().is_none() && session.0.is_empty() {
906
return "Error: MCP is not initialized.".to_string();
781
- };
907
+ }
908
783
- let Some((server, tool)) = mcp.servers.iter().find_map(|s| {
909
+ let Some((server, tool)) = startup.iter().chain(session.0.iter()).find_map(|s| {
910
s.tools
911
.iter()
912
.find(|t| t.full_name == full_name)
927
}
928
};
929
804
- match mcp.call(server, &tool.remote_name, args).await {
930
+ let http = MCP.get().map(|mcp| mcp.http.clone()).unwrap_or_default();
931
+ match call_server(&http, server, &tool.remote_name, args).await {
932
Ok(text) => truncate(text),
933
Err(error) => format!("Error: {error}"),
934
}
935
}
936
810
-impl Mcp {
811
- /// Send a `tools/call` and render the result into text. On HTTP, retries
812
- /// once after a re-`initialize` if the session was dropped (HTTP 404),
813
- /// which is how Streamable HTTP signals an expired session.
814
- async fn call(
815
- &self,
816
- server: &ServerConn,
817
- remote_name: &str,
818
- args: Value,
819
- ) -> Result<String, String> {
820
- let params = json!({ "name": remote_name, "arguments": args });
821
- let result = match &server.transport {
822
- None => return Err(format!("server '{}' is not connected", server.name)),
823
- Some(Transport::Stdio(stdio)) => {
824
- let timeout = if server.name == "xcode" {
825
- XCODE_CALL_TIMEOUT
826
- } else {
827
- CALL_TIMEOUT
828
- };
829
- stdio.request("tools/call", params, timeout).await?
830
- }
831
- Some(Transport::Http(http_conn)) => {
832
- let body = json!({
833
- "jsonrpc": "2.0",
834
- "id": 0,
835
- "method": "tools/call",
836
- "params": params
837
- });
838
- let timeout = if server.name == "xcode" {
839
- XCODE_CALL_TIMEOUT
840
- } else {
841
- CALL_TIMEOUT
842
- };
843
- match post_rpc(&self.http, &server.name, http_conn, &body, timeout).await {
844
- Ok(result) => result,
845
- Err(error) if error.contains("returned 404") => {
846
- // Session expired — drop it, re-handshake, and retry once.
847
- *http_conn.session_id.lock().await = None;
848
- initialize(&self.http, server).await?;
849
- notify_initialized(&self.http, server).await?;
850
- post_rpc(&self.http, &server.name, http_conn, &body, timeout).await?
851
- }
852
- Err(error) => return Err(error),
937
+/// Send a `tools/call` and render the result into text. On HTTP, retries
938
+/// once after a re-`initialize` if the session was dropped (HTTP 404),
939
+/// which is how Streamable HTTP signals an expired session.
940
+async fn call_server(
941
+ http: &reqwest::Client,
942
+ server: &ServerConn,
943
+ remote_name: &str,
944
+ args: Value,
945
+) -> Result<String, String> {
946
+ let params = json!({ "name": remote_name, "arguments": args });
947
+ let result = match &server.transport {
948
+ None => return Err(format!("server '{}' is not connected", server.name)),
949
+ Some(Transport::Stdio(stdio)) => {
950
+ let timeout = if server.name == "xcode" {
951
+ XCODE_CALL_TIMEOUT
952
+ } else {
953
+ CALL_TIMEOUT
954
+ };
955
+ stdio.request("tools/call", params, timeout).await?
956
+ }
957
+ Some(Transport::Http(http_conn)) => {
958
+ let body = json!({
959
+ "jsonrpc": "2.0",
960
+ "id": 0,
961
+ "method": "tools/call",
962
+ "params": params
963
+ });
964
+ let timeout = if server.name == "xcode" {
965
+ XCODE_CALL_TIMEOUT
966
+ } else {
967
+ CALL_TIMEOUT
968
+ };
969
+ match post_rpc(http, &server.name, http_conn, &body, timeout).await {
970
+ Ok(result) => result,
971
+ Err(error) if error.contains("returned 404") => {
972
+ // Session expired — drop it, re-handshake, and retry once.
973
+ *http_conn.session_id.lock().await = None;
974
+ initialize(http, server).await?;
975
+ notify_initialized(http, server).await?;
976
+ post_rpc(http, &server.name, http_conn, &body, timeout).await?
977
}
978
+ Err(error) => return Err(error),
979
}
855
- };
980
+ }
981
+ };
982
857
- Ok(render_tool_result(&result))
858
- }
983
+ Ok(render_tool_result(&result))
984
+}
985
986
+impl Mcp {
987
fn next_id(&self) -> i64 {
988
self.next_id.fetch_add(1, Ordering::Relaxed)
989
}
1190
}
1191
}
1192
1193
+impl Drop for StdioConn {
1194
+ /// A connection nobody holds any more has no use for its child. Startup
1195
+ /// servers live in a static and never get here; a session's servers do
1196
+ /// once the session that brought them is replaced.
1197
+ fn drop(&mut self) {
1198
+ // `mark_dead` reaps on a spawned task, which needs a runtime.
1199
+ if tokio::runtime::Handle::try_current().is_ok() {
1200
+ self.shared.mark_dead("connection closed");
1201
+ }
1202
+ }
1203
+}
1204
+
1205
impl StdioConn {
1206
/// Send a JSON-RPC request and await its response, correlated by id. Fails
1207
/// fast (in-band, never panicking) when the child has died.
1465
/// `/mcp` slash command. Shows the URL for HTTP servers and the command line
1466
/// for stdio servers.
1467
pub fn status_summary() -> String {
1468
+ let session = session_servers();
1469
let Some(mcp) = MCP.get() else {
1330
- return "MCP is not initialized.".to_string();
1470
+ if session.0.is_empty() {
1471
+ return "MCP is not initialized.".to_string();
1472
+ }
1473
+ return summarize_servers(&[], &session.0);
1474
};
1332
- if mcp.servers.is_empty() {
1475
+ if mcp.servers.is_empty() && session.0.is_empty() {
1476
return "No MCP servers configured. Add one in ~/.config/sigit/mcp.toml \
1477
or .sigit/mcp.toml. See https://modelcontextprotocol.io."
1478
.to_string();
1479
}
1480
+ summarize_servers(&mcp.servers, &session.0)
1481
+}
1482
1338
- let total_tools: usize = mcp.servers.iter().map(|s| s.tools.len()).sum();
1483
+/// The `/mcp` listing for the startup servers followed by the ones the live
1484
+/// session's client supplied, which are marked as such.
1485
+fn summarize_servers(startup: &[ServerConn], session: &[ServerConn]) -> String {
1486
+ let all = || startup.iter().chain(session.iter());
1487
+ let total_tools: usize = all().map(|s| s.tools.len()).sum();
1488
let mut lines = vec![format!(
1489
"{} MCP server(s), {total_tools} tool(s) available:",
1341
- mcp.servers.len()
1490
+ all().count()
1491
)];
1343
- for server in &mcp.servers {
1492
+ for (index, server) in all().enumerate() {
1493
+ let origin = if index >= startup.len() {
1494
+ ", from the editor"
1495
+ } else {
1496
+ ""
1497
+ };
1498
match &server.error {
1499
Some(error) => lines.push(format!(
1346
- "- {} ({}) — unavailable: {error}",
1500
+ "- {} ({}{origin}) — unavailable: {error}",
1501
server.name, server.endpoint
1502
)),
1503
None => {
1504
lines.push(format!(
1351
- "- {} ({}) — {} tool(s)",
1505
+ "- {} ({}{origin}) — {} tool(s)",
1506
server.name,
1507
server.endpoint,
1508
server.tools.len()