fix(acp): connect the MCP servers a client passes to a session

ACP clients name MCP servers in mcpServers on session/new, session/load and session/fork, and agents must connect to the stdio ones. sigit read none of it: the field was accepted and dropped. The stdio client in mcp.rs already existed, but it only ran once at startup from mcp.toml and kept its servers in a process global. A client's servers belong to one session, so they get their own type, SessionServers, which is stored on SessionState and installed as the live set whenever main.rs installs a session, next to the roots. tool_specs, call_tool and /mcp read the startup servers first and then the live session's. A client-supplied server whose name is already used by a startup server is not connected, because both would claim the same mcp__<server>__ prefix and a call could not be routed. It shows up in /mcp as unavailable with that reason. HTTP and SSE entries are skipped with a warning, since mcpCapabilities is not advertised. SIGIT_MCP=off turns these servers off as well. StdioConn now kills its child when dropped, so a session's servers do not outlive the session state that held them. Fixes #135 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 5, 2026 at 00:03 UTC c3db491d13afea8a7ebee7f80117308ca87806ad
5 files changed +416 -80
.agents/AGENTS.md
+12 -1
@@ -225,6 +225,16 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
225 it's a remote server, the registry's URL-match rule forces a domain namespace (`si.sigit` ↔
226 `sigit.si`) verified by a DNS TXT record, not the GitHub-OIDC scheme `smbcloud-cli` uses for its
227 package listing.
228 + Separate from all of the above are the servers an ACP client passes in `mcpServers` on
229 + `session/new`, `session/load` and `session/fork`. ACP requires agents to connect to the stdio
230 + ones, and they belong to the session that named them, so they cannot live in the startup
231 + global: `connect_session_servers` returns a `SessionServers` that `main.rs` keeps on
232 + `SessionState` and installs with `set_session_servers` whenever a session becomes live, the
233 + same way it swaps the roots. `tool_specs`, `call_tool` and `/mcp` read the startup servers and
234 + then the live session's. A client-supplied server whose name is already taken by a startup
235 + server is not connected, since both would claim the same `mcp__<server>__` prefix. HTTP and
236 + SSE entries are skipped: `mcpCapabilities` is not advertised, so a conforming client never
237 + sends them.
238 - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
239 `decision_for` before executing: read-only tools always run; mutating tools (and all
240 `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
@@ -261,7 +271,8 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
271 read another session's roots from the global. Project-local
272 discovery reads it: skills, slash commands, subagent types, and instruction files all scan
273 every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
264 - any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell.
274 + any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
275 + client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
276 - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
277 `tokio::select!` to multiplex terminal events with streaming tokens.
278 - **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
CHANGELOG.md
+7
@@ -11,6 +11,13 @@
11
12 ### Fixed
13
14 +- **MCP servers the editor passes to a session are connected.** ACP clients
15 + can name MCP servers in `mcpServers` when they open a session, and agents
16 + are required to connect to the stdio ones. siGit Code accepted the field and
17 + ignored it. The servers are now spawned when the session opens, their tools
18 + are offered to that session only, and `/mcp` lists them as coming from the
19 + editor. `SIGIT_MCP=off` still turns all of it off (#135).
20 +
21 - **A relative `cwd` is rejected when a session starts.** ACP requires the
22 working directory and every `additionalDirectories` entry to be absolute.
23 `session/new`, `session/load` and `session/fork` used to accept a relative
src/main.rs
+52 -10
@@ -74,15 +74,16 @@ use agent_client_protocol::schema::v1::{
74 AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
75 ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
76 ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
77 - ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, Meta,
78 - NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, Plan, PlanEntry,
79 - PlanEntryPriority, PlanEntryStatus, PromptRequest, PromptResponse, RequestPermissionOutcome,
80 - RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
81 - SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
82 - SessionConfigValueId, SessionForkCapabilities, SessionId, SessionInfo, SessionListCapabilities,
83 - SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
84 - SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, ToolCallLocation,
85 - ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
77 + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpServer,
78 + Meta, NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, Plan,
79 + PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest, PromptResponse,
80 + RequestPermissionOutcome, RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities,
81 + SessionCapabilities, SessionConfigOption, SessionConfigOptionCategory,
82 + SessionConfigSelectOption, SessionConfigValueId, SessionForkCapabilities, SessionId,
83 + SessionInfo, SessionListCapabilities, SessionNotification, SessionUpdate,
84 + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
85 + ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
86 + ToolKind, UnstructuredCommandInput,
87 };
88 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
89 use onde::inference::{ChatEngine, GgufModelConfig};
@@ -863,6 +864,9 @@ struct SessionState {
864 /// project. Stale while the session is live: the backend holds the
865 /// current copy then.
866 conversation: Vec<serde_json::Value>,
867 + /// The MCP servers the client passed in `mcpServers` for this session,
868 + /// connected. Installed as the live set along with the roots.
869 + mcp_servers: mcp::SessionServers,
870 }
871
872 struct SiGitAgent {
@@ -1472,6 +1476,34 @@ impl SiGitAgent {
1476 }
1477 }
1478
1479 +/// The stdio servers out of a session request's `mcpServers`.
1480 +///
1481 +/// ACP requires every agent to connect to the stdio ones. HTTP and SSE are
1482 +/// optional and gated on `mcpCapabilities`, which siGit Code does not
1483 +/// advertise, so a client that sends one anyway is told in the log and the
1484 +/// entry is skipped.
1485 +fn client_mcp_servers(servers: &[McpServer]) -> Vec<mcp::ClientServer> {
1486 + servers
1487 + .iter()
1488 + .filter_map(|server| match server {
1489 + McpServer::Stdio(stdio) => Some(mcp::ClientServer {
1490 + name: stdio.name.clone(),
1491 + command: stdio.command.to_string_lossy().into_owned(),
1492 + args: stdio.args.clone(),
1493 + env: stdio
1494 + .env
1495 + .iter()
1496 + .map(|var| (var.name.clone(), var.value.clone()))
1497 + .collect(),
1498 + }),
1499 + other => {
1500 + log::warn!("ignoring a non-stdio MCP server from the client: {other:?}");
1501 + None
1502 + }
1503 + })
1504 + .collect()
1505 +}
1506 +
1507 /// Reject session roots that are not absolute paths.
1508 ///
1509 /// ACP requires `cwd` and every `additionalDirectories` entry to be absolute.
@@ -1607,6 +1639,7 @@ impl SiGitAgent {
1639 additional_roots: workspace::filter_additional_roots(cwd, additional),
1640 model_id,
1641 conversation: Vec::new(),
1642 + mcp_servers: mcp::SessionServers::default(),
1643 }
1644 }
1645
@@ -1707,12 +1740,14 @@ impl SiGitAgent {
1740 additional_roots,
1741 model_id: _,
1742 conversation,
1743 + mcp_servers,
1744 } = state;
1745
1746 if let Ok(mut guard) = self.session_cwd.lock() {
1747 *guard = Some(cwd.clone());
1748 }
1749 workspace::replace_additional_roots(additional_roots.clone());
1750 + mcp::set_session_servers(mcp_servers);
1751 if cwd.is_dir()
1752 && let Err(err) = std::env::set_current_dir(&cwd)
1753 {
@@ -1939,6 +1974,8 @@ impl SiGitAgent {
1974 }
1975
1976 state.conversation = backend::carryover_history(history);
1977 + state.mcp_servers =
1978 + mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
1979 log::info!(
1980 "load_session: restored {restored} message(s), replayed {replayed} update(s) for {}",
1981 args.session_id
@@ -2003,6 +2040,9 @@ impl SiGitAgent {
2040 state.model_id = model_id;
2041 }
2042 }
2043 + // A fork gets the servers named on its own request, not the source's.
2044 + state.mcp_servers =
2045 + mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
2046 let copied = state.conversation.len();
2047 let updates = history_replay_updates(&state.conversation);
2048 let replayed = updates.len();
@@ -2048,7 +2088,9 @@ impl SiGitAgent {
2088 .collect::<Vec<_>>()
2089 );
2090
2051 - let state = self.session_state(&args.cwd, &args.additional_directories);
2091 + let mut state = self.session_state(&args.cwd, &args.additional_directories);
2092 + state.mcp_servers =
2093 + mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
2094 self.open_session(&session_id, state).await;
2095
2096 let config_options = {
src/mcp.rs
+223 -69
@@ -401,17 +401,22 @@ fn sigit_config_dir() -> Option<PathBuf> {
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 }
@@ -745,6 +750,125 @@ async fn list_tools(http: &reqwest::Client, conn: &ServerConn) -> Result<Vec<Mcp
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`
@@ -754,13 +878,13 @@ pub fn is_mcp_tool(name: &str) -> bool {
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(),
@@ -776,11 +900,13 @@ pub fn tool_specs() -> Vec<ToolSpec> {
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)
@@ -801,62 +927,63 @@ pub async fn call_tool(full_name: &str, arguments: &str) -> String {
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 }
@@ -1063,6 +1190,18 @@ async fn stdio_reader(mut stdout: BufReader<ChildStdout>, shared: Arc<StdioShare
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.
@@ -1326,29 +1465,44 @@ fn parse_sse_response(body: &str) -> Option<Value> {
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()
tests/mcp_stdio.rs
+122
@@ -369,3 +369,125 @@ args = ["--fail"]
369 drop(agent);
370 let _ = std::fs::remove_dir_all(&scratch);
371 }
372 +
373 +// ── Servers the client supplies per session ─────────────────────────────────
374 +
375 +/// ACP clients pass MCP servers in `mcpServers` on `session/new`, and every
376 +/// agent has to connect to the stdio ones (issue #135). The server belongs to
377 +/// the session that named it: a second session opened without it must not be
378 +/// offered its tools.
379 +#[test]
380 +fn client_supplied_stdio_server_is_scoped_to_its_session() {
381 + let stub = env!("CARGO_BIN_EXE_mcp_stdio_stub");
382 +
383 + let endpoint = start_fake_endpoint(vec![
384 + // The session without the server: one plain answer.
385 + sse_text("nothing to call"),
386 + // The session with it: call the tool, then finish.
387 + sse_tool_call("call_1", "mcp__editor__echo", r#"{"text":"hello"}"#),
388 + sse_text("done"),
389 + ]);
390 +
391 + let scratch =
392 + std::env::temp_dir().join(format!("sigit_mcp_client_servers_{}", std::process::id()));
393 + let config_dir = scratch.join("config");
394 + let cwd = scratch.join("cwd");
395 + std::fs::create_dir_all(&config_dir).unwrap();
396 + std::fs::create_dir_all(&cwd).unwrap();
397 + // No startup servers at all, so every MCP tool seen below came from the
398 + // client.
399 + std::fs::write(
400 + config_dir.join("mcp.toml"),
401 + "official = false\nsmbcloud = false\n",
402 + )
403 + .unwrap();
404 +
405 + let mut agent = spawn_agent(endpoint.port, &config_dir, &cwd);
406 +
407 + let id = agent.request(
408 + "initialize",
409 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
410 + );
411 + agent.wait_for_response(id);
412 +
413 + let id = agent.request(
414 + "session/new",
415 + json!({
416 + "cwd": cwd,
417 + "mcpServers": [{
418 + "name": "editor",
419 + "command": stub,
420 + "args": [],
421 + "env": [{"name": "STUB_PREFIX", "value": "ed:"}],
422 + }],
423 + }),
424 + );
425 + let with_server = agent.wait_for_response(id)["result"]["sessionId"]
426 + .as_str()
427 + .expect("session id")
428 + .to_string();
429 +
430 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
431 + let without_server = agent.wait_for_response(id)["result"]["sessionId"]
432 + .as_str()
433 + .expect("session id")
434 + .to_string();
435 +
436 + let id = agent.request(
437 + "session/prompt",
438 + json!({
439 + "sessionId": without_server,
440 + "prompt": [{"type": "text", "text": "anything to call?"}],
441 + }),
442 + );
443 + agent.wait_for_response(id);
444 +
445 + // Switching back to the first session has to bring its server with it.
446 + let id = agent.request(
447 + "session/prompt",
448 + json!({
449 + "sessionId": with_server,
450 + "prompt": [{"type": "text", "text": "/mcp"}],
451 + }),
452 + );
453 + let (_, listing) = agent.wait_for_response_collecting_updates(id);
454 + assert!(
455 + listing.contains("mcp__editor__echo") && listing.contains("from the editor"),
456 + "/mcp must list the client-supplied server and say where it came from, got: {listing}"
457 + );
458 +
459 + let id = agent.request(
460 + "session/prompt",
461 + json!({
462 + "sessionId": with_server,
463 + "prompt": [{"type": "text", "text": "use the editor tool"}],
464 + }),
465 + );
466 + let response = agent.wait_for_response(id);
467 + assert_eq!(response["result"]["stopReason"], "end_turn");
468 +
469 + let requests = endpoint.requests.lock().unwrap();
470 + assert_eq!(requests.len(), 3, "expected exactly three completions");
471 +
472 + let tools = requests[0]["tools"].to_string();
473 + assert!(
474 + !tools.contains("mcp__editor__"),
475 + "a session must not be offered another session's server: {tools}"
476 + );
477 + let tools = requests[1]["tools"].to_string();
478 + assert!(
479 + tools.contains("mcp__editor__echo"),
480 + "the client-supplied server's tool missing from specs: {tools}"
481 + );
482 +
483 + // The call round-trips, and the env the client sent reached the child.
484 + let messages = requests[2]["messages"].as_array().expect("messages");
485 + let result = messages
486 + .iter()
487 + .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
488 + .expect("tool result for the echo call");
489 + assert_eq!(result["content"].as_str().unwrap_or_default(), "ed:hello");
490 +
491 + drop(agent);
492 + let _ = std::fs::remove_dir_all(&scratch);
493 +}