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:
1 parent
d5328667e4
commit
99ecceacb5
8 files changed
+115
No files matched your search
@@ -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<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 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user