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
|
/// 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
tools_bin: None,
|
||||||
transcript: None,
|
transcript: None,
|
||||||
ingest_token: None,
|
ingest_token: None,
|
||||||
|
disabled_mcp_servers: std::collections::BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user