feat(claw): the daemon wires MCP into its agents

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.
This commit is contained in:
vhaudiquet committed 2026-11-04 23:13:12 +01:00
1 parent d5328667e4
commit 99ecceacb5
8 files changed
+115

No files matched your search

+7
View File
@@ -150,6 +150,12 @@ pub struct ClawConfig {
/// open on its interface; set it when the port is reachable from /// open on its interface; set it when the port is reachable from
/// anywhere but loopback. `/healthz` stays open either way. /// anywhere but loopback. `/healthz` stays open either way.
pub token: Option<String>, pub token: Option<String>,
/// 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<String>,
/// The scoped agent token the client tools hold: it answers only /// The scoped agent token the client tools hold: it answers only
/// the data routes the tool contract allowlists — watches, /// the data routes the tool contract allowlists — watches,
/// notifications, tasks — never consents, sessions, or ingest, so /// notifications, tasks — never consents, sessions, or ingest, so
@@ -195,6 +201,7 @@ impl Default for ClawConfig {
tools_bin: None, tools_bin: None,
ingest_token: None, ingest_token: None,
transcript: None, transcript: None,
disabled_mcp_servers: std::collections::BTreeSet::new(),
} }
} }
} }
+1
View File
@@ -246,6 +246,7 @@ impl ClawHandler {
.transcript .transcript
.then(rhema_transcript::TranscriptSettings::default); .then(rhema_transcript::TranscriptSettings::default);
config.shell_env = self.tool_env.clone(); config.shell_env = self.tool_env.clone();
crate::mcp::apply(&mut config);
config config
} }
+1
View File
@@ -207,6 +207,7 @@ impl CodingJobHandler {
config.transcript = self config.transcript = self
.transcript .transcript
.then(rhema_transcript::TranscriptSettings::default); .then(rhema_transcript::TranscriptSettings::default);
crate::mcp::apply(&mut config);
let stashed = self let stashed = self
.store .store
.session(&instance_name) .session(&instance_name)
+1
View File
@@ -80,6 +80,7 @@ pub mod interactive;
pub mod job_store; pub mod job_store;
pub mod job_tools; pub mod job_tools;
pub mod jobs; pub mod jobs;
pub mod mcp;
#[cfg(feature = "mock")] #[cfg(feature = "mock")]
pub mod mock; pub mod mock;
pub mod notifications; pub mod notifications;
+102
View File
@@ -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<rhema_mcp::Wiring>,
disabled: BTreeSet<String>,
}
static WIRED: OnceLock<Option<Wired>> = 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");
}
}
}
+1
View File
@@ -81,6 +81,7 @@ fn config(dir: &tempfile::TempDir) -> ClawConfig {
tools_bin: None, tools_bin: None,
transcript: None, transcript: None,
ingest_token: None, ingest_token: None,
disabled_mcp_servers: std::collections::BTreeSet::new(),
} }
} }
+1
View File
@@ -59,6 +59,7 @@ fn config(dir: &tempfile::TempDir) -> ClawConfig {
tools_bin: None, tools_bin: None,
transcript: None, transcript: None,
ingest_token: None, ingest_token: None,
disabled_mcp_servers: std::collections::BTreeSet::new(),
} }
} }
+1
View File
@@ -65,6 +65,7 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig {
tools_bin: None, tools_bin: None,
ingest_token: None, ingest_token: None,
transcript: None, transcript: None,
disabled_mcp_servers: std::collections::BTreeSet::new(),
} }
} }