From 99ecceacb516516164b3ee8f25a6b04e2a5cc382 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 4 Nov 2026 23:05:12 +0100 Subject: [PATCH] feat(claw): the daemon wires MCP into its agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claw's side of §10: one bridge per process, established at first agent build and never re-read — the claw is single-instance and its servers are stateful across calls, so a mid-process rewire would orphan running sessions' socket paths. The global `mcp.yaml` is the claw's config scope (no profiles here); the off switch is `disabled_mcp_servers` beside the rest of claw.yaml, and both agent construction sites — chat turns and `coding_agent` jobs — layer the wiring on: the init file and socket and token into `shell_env`, the post-filter lines into the prompt's `mcp` section, the persisted set into the config. A frontend without wiring changes nothing, so the daemon without `mcp.yaml` behaves exactly as before. The test configs gain the new field; the fd-limit discoveries from the workspace run are already handled by the harness runs above. --- crates/rhema-claw/src/config.rs | 7 ++ crates/rhema-claw/src/handler.rs | 1 + crates/rhema-claw/src/jobs.rs | 1 + crates/rhema-claw/src/lib.rs | 1 + crates/rhema-claw/src/mcp.rs | 102 ++++++++++++++++++++++++++ crates/rhema-claw/tests/continuity.rs | 1 + crates/rhema-claw/tests/e2e.rs | 1 + crates/rhema-claw/tests/http.rs | 1 + 8 files changed, 115 insertions(+) create mode 100644 crates/rhema-claw/src/mcp.rs diff --git a/crates/rhema-claw/src/config.rs b/crates/rhema-claw/src/config.rs index cb3f581..3e73bcc 100644 --- a/crates/rhema-claw/src/config.rs +++ b/crates/rhema-claw/src/config.rs @@ -150,6 +150,12 @@ pub struct ClawConfig { /// open on its interface; set it when the port is reachable from /// anywhere but loopback. `/healthz` stays open either way. pub token: Option, + /// MCP server names held back from the claw's agents — the + /// off switch (the global `mcp.yaml` is the claw's config scope; + /// there are no profiles here). The wiring step filters before + /// anything spawns and the prompt carries only what survives. + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub disabled_mcp_servers: std::collections::BTreeSet, /// The scoped agent token the client tools hold: it answers only /// the data routes the tool contract allowlists — watches, /// notifications, tasks — never consents, sessions, or ingest, so @@ -195,6 +201,7 @@ impl Default for ClawConfig { tools_bin: None, ingest_token: None, transcript: None, + disabled_mcp_servers: std::collections::BTreeSet::new(), } } } diff --git a/crates/rhema-claw/src/handler.rs b/crates/rhema-claw/src/handler.rs index 569a08f..a1acb32 100644 --- a/crates/rhema-claw/src/handler.rs +++ b/crates/rhema-claw/src/handler.rs @@ -246,6 +246,7 @@ impl ClawHandler { .transcript .then(rhema_transcript::TranscriptSettings::default); config.shell_env = self.tool_env.clone(); + crate::mcp::apply(&mut config); config } diff --git a/crates/rhema-claw/src/jobs.rs b/crates/rhema-claw/src/jobs.rs index 48bf87a..0f02a18 100644 --- a/crates/rhema-claw/src/jobs.rs +++ b/crates/rhema-claw/src/jobs.rs @@ -207,6 +207,7 @@ impl CodingJobHandler { config.transcript = self .transcript .then(rhema_transcript::TranscriptSettings::default); + crate::mcp::apply(&mut config); let stashed = self .store .session(&instance_name) diff --git a/crates/rhema-claw/src/lib.rs b/crates/rhema-claw/src/lib.rs index 7183fc7..bc03fb8 100644 --- a/crates/rhema-claw/src/lib.rs +++ b/crates/rhema-claw/src/lib.rs @@ -80,6 +80,7 @@ pub mod interactive; pub mod job_store; pub mod job_tools; pub mod jobs; +pub mod mcp; #[cfg(feature = "mock")] pub mod mock; pub mod notifications; diff --git a/crates/rhema-claw/src/mcp.rs b/crates/rhema-claw/src/mcp.rs new file mode 100644 index 0000000..6f352ba --- /dev/null +++ b/crates/rhema-claw/src/mcp.rs @@ -0,0 +1,102 @@ +//! The claw's MCP wiring: one bridge for the daemon process, its env +//! merged into every coding-agent session the claw runs — chat turns +//! and `coding_agent` jobs alike. The global `mcp.yaml` is the +//! claw's config scope (there are no profiles here); the off switch +//! is the claw config's `disabled_mcp_servers`. + +#![forbid(unsafe_code)] + +use std::collections::BTreeSet; +use std::sync::{Arc, OnceLock}; + +use rhema_coding_agent::CodingAgentConfig; + +/// What one wiring produced, held for the process. +struct Wired { + wiring: Arc, + disabled: BTreeSet, +} + +static WIRED: OnceLock> = OnceLock::new(); + +/// The daemon's wiring, established at first use and never re-read: +/// the claw is single-instance, its servers are stateful across +/// calls, and a rewire mid-process would orphan running sessions' +/// socket paths. `None` when nothing is configured, everything is +/// muted, or the config file cannot load. +fn wired() -> Option<&'static Wired> { + WIRED + .get_or_init(|| { + let claw = ClawConfig::load_from(&ClawConfig::default_path().ok()?).ok()?; + let mcp = rhema_config::mcp::McpConfig::load(); + if mcp.servers.is_empty() { + return None; + } + let dir = std::env::temp_dir().join(format!("rhema-claw-mcp-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok()?; + let wiring = rhema_mcp::wire( + &mcp, + &claw.disabled_mcp_servers, + &dir, + "claw", + std::env::var_os("BASH_ENV"), + ) + .ok()?; + for notice in &wiring.notices { + eprintln!("· {notice}"); + } + if wiring.lines.is_empty() { + // Everything muted: drop the wiring (its own Drop + // stops the bridge) and record the answer as none. + drop(wiring); + return None; + } + Some(Wired { + wiring: Arc::new(wiring), + disabled: claw.disabled_mcp_servers, + }) + }) + .as_ref() +} + +/// Layers the wiring onto one agent config: the shell env map (the +/// init file, the socket, the token), the prompt's `mcp` lines, and +/// the persisted off switch. A frontend without wiring changes +/// nothing. +pub(crate) fn apply(config: &mut CodingAgentConfig) { + let Some(wired) = wired() else { + return; + }; + config.shell_env.extend(wired.wiring.shell_env.clone()); + config.mcp_servers = wired.wiring.lines.clone(); + config.disabled_mcp_servers = wired.disabled.clone(); +} + +use crate::config::ClawConfig; + +#[cfg(test)] +mod tests { + use super::*; + + /// A first `apply` is indistinguishable from a second: the + /// extension must be idempotent at the level the agent sees (the + /// wiring map's values are constants, so extending twice is the + /// same map). + #[test] + fn apply_without_any_wiring_leaves_the_config_alone() { + let mut config = CodingAgentConfig::new("m", std::path::Path::new("/tmp")); + let before = config.shell_env.clone(); + apply(&mut config); + // The wiring only appears when the machine's global mcp.yaml + // has servers; a bare environment must not invent any. + let after_has_mcp_vars = config + .shell_env + .keys() + .any(|key| key.starts_with("RHEMA_MCP")); + if after_has_mcp_vars { + assert!(config.shell_env.len() > before.len()); + } else { + assert_eq!(config.shell_env, before, "no wiring, no change"); + } + } +} diff --git a/crates/rhema-claw/tests/continuity.rs b/crates/rhema-claw/tests/continuity.rs index fee3c1f..43090e9 100644 --- a/crates/rhema-claw/tests/continuity.rs +++ b/crates/rhema-claw/tests/continuity.rs @@ -81,6 +81,7 @@ fn config(dir: &tempfile::TempDir) -> ClawConfig { tools_bin: None, transcript: None, ingest_token: None, + disabled_mcp_servers: std::collections::BTreeSet::new(), } } diff --git a/crates/rhema-claw/tests/e2e.rs b/crates/rhema-claw/tests/e2e.rs index 93a81bd..627433c 100644 --- a/crates/rhema-claw/tests/e2e.rs +++ b/crates/rhema-claw/tests/e2e.rs @@ -59,6 +59,7 @@ fn config(dir: &tempfile::TempDir) -> ClawConfig { tools_bin: None, transcript: None, ingest_token: None, + disabled_mcp_servers: std::collections::BTreeSet::new(), } } diff --git a/crates/rhema-claw/tests/http.rs b/crates/rhema-claw/tests/http.rs index 3387fff..2af0737 100644 --- a/crates/rhema-claw/tests/http.rs +++ b/crates/rhema-claw/tests/http.rs @@ -65,6 +65,7 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig { tools_bin: None, ingest_token: None, transcript: None, + disabled_mcp_servers: std::collections::BTreeSet::new(), } }