feat(mcp): prototype external MCP host tools (Sam & Codex)
Adds stdio external MCP server registry support to colibri-mcp with read-only discovery by default and explicit COLIBRI_MCP_EXTERNAL_CALL gating for proxying external tools. Also smooths the merged jail-spawn formatting/FreeBSD command parameter edge so repository gates pass.\n\nChecks: cargo test -p colibri-mcp --all-targets; cargo fmt --check; ./scripts/check-format.sh; git diff --check; fake stdio MCP server smoke via colibri-mcp --external-config --external-call
This commit is contained in:
parent
abc9174caf
commit
5ce93206b2
9 changed files with 527 additions and 42 deletions
25
README.md
25
README.md
|
|
@ -9,21 +9,22 @@ with cache-first cost discipline (byte-stable prompt prefixes, cache-hit meterin
|
|||
Next ISO integration plan: `docs/ISO-INTEGRATION-PLAN.md`.
|
||||
ISO acceptance runbook: `docs/ISO-ACCEPTANCE-RUNBOOK.md`.
|
||||
Clawdie Studio/Zed proposal: `docs/CLAWDIE-STUDIO-PROPOSAL.md`.
|
||||
External MCP host prototype: `docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md`.
|
||||
|
||||
## Workspace — 10 crates
|
||||
|
||||
| Crate | Role |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `colibri-mcp` | MCP bridge for editor integration (Zed, Claude Code) via stdio JSON-RPC |
|
||||
| `colibri-contracts` | JSON schema contracts (golden tests) |
|
||||
| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering |
|
||||
| `colibri-runtime` | Host status ingestion, runtime inventory |
|
||||
| `colibri-glasspane` | Agent 5-state machine (Pi events → state) |
|
||||
| `colibri-daemon` | Always-on Unix socket server, session lifecycle |
|
||||
| `colibri-client` | Typed Unix-socket client + operator CLI |
|
||||
| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) |
|
||||
| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) |
|
||||
| `colibri-skills` | Skills catalog crate |
|
||||
| Crate | Role |
|
||||
| ----------------------- | ----------------------------------------------------------------------- |
|
||||
| `colibri-mcp` | MCP bridge for editor integration (Zed, Claude Code) via stdio JSON-RPC |
|
||||
| `colibri-contracts` | JSON schema contracts (golden tests) |
|
||||
| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering |
|
||||
| `colibri-runtime` | Host status ingestion, runtime inventory |
|
||||
| `colibri-glasspane` | Agent 5-state machine (Pi events → state) |
|
||||
| `colibri-daemon` | Always-on Unix socket server, session lifecycle |
|
||||
| `colibri-client` | Typed Unix-socket client + operator CLI |
|
||||
| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) |
|
||||
| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) |
|
||||
| `colibri-skills` | Skills catalog crate |
|
||||
|
||||
## Build
|
||||
|
||||
|
|
|
|||
|
|
@ -649,7 +649,13 @@ mod jail_tests {
|
|||
user: Some("clawdie".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let (exe, a) = jail_wrap("pi", &argv(&["x"]), Some(&j), &PrivMode::None, DEFAULT_JAIL_HELPER);
|
||||
let (exe, a) = jail_wrap(
|
||||
"pi",
|
||||
&argv(&["x"]),
|
||||
Some(&j),
|
||||
&PrivMode::None,
|
||||
DEFAULT_JAIL_HELPER,
|
||||
);
|
||||
assert_eq!(exe, "jexec");
|
||||
assert_eq!(a, argv(&["-U", "clawdie", "pi0", "pi", "x"]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ colibri-daemon = { path = "../colibri-daemon" }
|
|||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "time"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
|
|
|
|||
261
crates/colibri-mcp/src/external.rs
Normal file
261
crates/colibri-mcp/src/external.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
//! Prototype MCP client/host support for connecting Colibri to external MCP servers.
|
||||
//!
|
||||
//! This is intentionally thin: stdio transport only, one process per request,
|
||||
//! no long-lived registry, and no production-grade policy engine yet.
|
||||
|
||||
use std::{collections::BTreeMap, path::Path, process::Stdio, time::Duration};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
process::Command,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::protocol::McpError;
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// External MCP server registry file.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ExternalMcpRegistry {
|
||||
#[serde(default)]
|
||||
pub servers: BTreeMap<String, ExternalMcpServer>,
|
||||
}
|
||||
|
||||
/// One stdio MCP server entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExternalMcpServer {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExternalMcpRegistry {
|
||||
pub async fn load(path: &Path) -> Result<Self, McpError> {
|
||||
let raw = tokio::fs::read_to_string(path).await.map_err(|e| {
|
||||
McpError::internal(format!(
|
||||
"failed to read external MCP config {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
serde_json::from_str(&raw).map_err(|e| {
|
||||
McpError::invalid_params(format!(
|
||||
"invalid external MCP config {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn summaries(&self) -> Value {
|
||||
let servers: Vec<Value> = self
|
||||
.servers
|
||||
.iter()
|
||||
.map(|(name, server)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"command": server.command,
|
||||
"args": server.args,
|
||||
"env_keys": server.env.keys().collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "servers": servers })
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a registry if configured and present. Missing config is not an error;
|
||||
/// it just means no external MCP servers are configured yet.
|
||||
pub async fn load_registry_if_present(path: &Path) -> Result<ExternalMcpRegistry, McpError> {
|
||||
match tokio::fs::try_exists(path).await {
|
||||
Ok(true) => ExternalMcpRegistry::load(path).await,
|
||||
Ok(false) => Ok(ExternalMcpRegistry::default()),
|
||||
Err(e) => Err(McpError::internal(format!(
|
||||
"failed to stat external MCP config {}: {e}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List tools from one external MCP server.
|
||||
pub async fn list_tools(server: &ExternalMcpServer) -> Result<Value, McpError> {
|
||||
let mut session = ExternalMcpSession::start(server).await?;
|
||||
session.initialize().await?;
|
||||
let result = session.request("tools/list", json!({})).await?;
|
||||
session.shutdown().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Call a tool on one external MCP server.
|
||||
pub async fn call_tool(
|
||||
server: &ExternalMcpServer,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, McpError> {
|
||||
let mut session = ExternalMcpSession::start(server).await?;
|
||||
session.initialize().await?;
|
||||
let result = session
|
||||
.request(
|
||||
"tools/call",
|
||||
json!({
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
session.shutdown().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
struct ExternalMcpSession {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout: BufReader<tokio::process::ChildStdout>,
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
impl ExternalMcpSession {
|
||||
async fn start(server: &ExternalMcpServer) -> Result<Self, McpError> {
|
||||
let mut cmd = Command::new(&server.command);
|
||||
cmd.args(&server.args)
|
||||
.envs(&server.env)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
McpError::internal(format!(
|
||||
"failed to spawn external MCP server {}: {e}",
|
||||
server.command
|
||||
))
|
||||
})?;
|
||||
let stdin = child.stdin.take().ok_or_else(|| {
|
||||
McpError::internal("external MCP server stdin was not piped".to_string())
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
McpError::internal("external MCP server stdout was not piped".to_string())
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout: BufReader::new(stdout),
|
||||
next_id: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn initialize(&mut self) -> Result<(), McpError> {
|
||||
self.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "colibri-mcp-host-prototype",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
self.notify("notifications/initialized", json!({})).await
|
||||
}
|
||||
|
||||
async fn request(&mut self, method: &str, params: Value) -> Result<Value, McpError> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
self.write_line(&line).await?;
|
||||
|
||||
let response = self.read_response().await?;
|
||||
if let Some(error) = response.get("error") {
|
||||
return Err(McpError::internal(format!(
|
||||
"external MCP server returned error for {method}: {error}"
|
||||
)));
|
||||
}
|
||||
response.get("result").cloned().ok_or_else(|| {
|
||||
McpError::internal(format!("external MCP response missing result for {method}"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn notify(&mut self, method: &str, params: Value) -> Result<(), McpError> {
|
||||
let line = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
self.write_line(&line).await
|
||||
}
|
||||
|
||||
async fn write_line(&mut self, value: &Value) -> Result<(), McpError> {
|
||||
let mut raw = serde_json::to_string(value)
|
||||
.map_err(|e| McpError::internal(format!("failed to encode MCP request: {e}")))?;
|
||||
raw.push('\n');
|
||||
timeout(
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
self.stdin.write_all(raw.as_bytes()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| McpError::internal("external MCP write timed out".to_string()))?
|
||||
.map_err(|e| McpError::internal(format!("external MCP write failed: {e}")))?;
|
||||
timeout(DEFAULT_REQUEST_TIMEOUT, self.stdin.flush())
|
||||
.await
|
||||
.map_err(|_| McpError::internal("external MCP flush timed out".to_string()))?
|
||||
.map_err(|e| McpError::internal(format!("external MCP flush failed: {e}")))
|
||||
}
|
||||
|
||||
async fn read_response(&mut self) -> Result<Value, McpError> {
|
||||
let mut raw = String::new();
|
||||
let read = timeout(DEFAULT_REQUEST_TIMEOUT, self.stdout.read_line(&mut raw))
|
||||
.await
|
||||
.map_err(|_| McpError::internal("external MCP read timed out".to_string()))?
|
||||
.map_err(|e| McpError::internal(format!("external MCP read failed: {e}")))?;
|
||||
if read == 0 {
|
||||
return Err(McpError::internal(
|
||||
"external MCP server closed stdout".to_string(),
|
||||
));
|
||||
}
|
||||
serde_json::from_str(raw.trim_end()).map_err(|e| {
|
||||
McpError::internal(format!(
|
||||
"external MCP response was not valid JSON: {e}: {raw}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) {
|
||||
let _ = self.child.kill().await;
|
||||
let _ = self.child.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_registry() {
|
||||
let raw = r#"
|
||||
{
|
||||
"servers": {
|
||||
"demo": {
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"env": { "DEMO": "1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let registry: ExternalMcpRegistry = serde_json::from_str(raw).unwrap();
|
||||
let demo = registry.servers.get("demo").unwrap();
|
||||
assert_eq!(demo.command, "node");
|
||||
assert_eq!(demo.args, vec!["server.js"]);
|
||||
assert_eq!(demo.env.get("DEMO").unwrap(), "1");
|
||||
}
|
||||
}
|
||||
|
|
@ -18,8 +18,10 @@
|
|||
//!
|
||||
//! Write tools require `COLIBRI_MCP_WRITE=1`.
|
||||
|
||||
mod external;
|
||||
mod protocol;
|
||||
|
||||
pub use external::{ExternalMcpRegistry, ExternalMcpServer};
|
||||
pub use protocol::{McpError, McpRequest, McpResponse, McpResult, McpValue, ToolHandler};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -46,15 +48,19 @@ pub fn resolve_socket_path() -> PathBuf {
|
|||
pub struct McpServerConfig {
|
||||
pub socket_path: PathBuf,
|
||||
pub write_enabled: bool,
|
||||
pub external_config_path: PathBuf,
|
||||
pub external_call_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for McpServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
socket_path: resolve_socket_path(),
|
||||
write_enabled: std::env::var("COLIBRI_MCP_WRITE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false),
|
||||
write_enabled: env_bool("COLIBRI_MCP_WRITE"),
|
||||
external_config_path: std::env::var("COLIBRI_MCP_EXTERNAL_CONFIG")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/usr/local/etc/colibri/external-mcp.json")),
|
||||
external_call_enabled: env_bool("COLIBRI_MCP_EXTERNAL_CALL"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,6 +145,37 @@ pub fn tool_list() -> Vec<Value> {
|
|||
"required": ["mode"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_external_mcp_servers",
|
||||
"List configured external MCP servers from COLIBRI_MCP_EXTERNAL_CONFIG",
|
||||
None,
|
||||
),
|
||||
json_tool(
|
||||
"colibri_external_mcp_list_tools",
|
||||
"List tools exposed by configured external MCP servers (stdio prototype)",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string",
|
||||
"description": "Optional server name. If omitted, lists tools for all configured servers."
|
||||
}
|
||||
}
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_external_mcp_call_tool",
|
||||
"Call a tool on an external MCP server (requires COLIBRI_MCP_EXTERNAL_CALL=1)",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string", "description": "External MCP server name" },
|
||||
"name": { "type": "string", "description": "External MCP tool name" },
|
||||
"arguments": { "type": "object", "description": "Tool arguments" }
|
||||
},
|
||||
"required": ["server", "name"]
|
||||
})),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -241,6 +278,62 @@ pub async fn dispatch_tool(
|
|||
"note": "Cost mode change is runtime-only/status-intent until live config mutation exists."
|
||||
})))
|
||||
}
|
||||
"colibri_external_mcp_servers" => {
|
||||
let registry = external::load_registry_if_present(&config.external_config_path).await?;
|
||||
Ok(tool_text(serde_json::json!({
|
||||
"config_path": config.external_config_path,
|
||||
"external_call_enabled": config.external_call_enabled,
|
||||
"registry": registry.summaries()
|
||||
})))
|
||||
}
|
||||
"colibri_external_mcp_list_tools" => {
|
||||
let registry = external::load_registry_if_present(&config.external_config_path).await?;
|
||||
let filter = optional_string(arguments, "server");
|
||||
let mut servers = Vec::new();
|
||||
for (server_name, server) in registry.servers.iter() {
|
||||
if filter
|
||||
.as_deref()
|
||||
.is_some_and(|wanted| wanted != server_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let tools = external::list_tools(server).await?;
|
||||
servers.push(serde_json::json!({
|
||||
"server": server_name,
|
||||
"tools": tools.get("tools").cloned().unwrap_or(tools)
|
||||
}));
|
||||
}
|
||||
if let Some(wanted) = filter {
|
||||
if servers.is_empty() {
|
||||
return Err(McpError::not_found(format!(
|
||||
"external MCP server not configured: {wanted}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(tool_text(serde_json::json!({
|
||||
"config_path": config.external_config_path,
|
||||
"servers": servers
|
||||
})))
|
||||
}
|
||||
"colibri_external_mcp_call_tool" => {
|
||||
require_external_call(config)?;
|
||||
let server_name = require_string(arguments, "server")?;
|
||||
let tool_name = require_string(arguments, "name")?;
|
||||
let tool_args = arguments
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let registry = external::load_registry_if_present(&config.external_config_path).await?;
|
||||
let server = registry.servers.get(&server_name).ok_or_else(|| {
|
||||
McpError::not_found(format!("external MCP server not configured: {server_name}"))
|
||||
})?;
|
||||
let data = external::call_tool(server, &tool_name, tool_args).await?;
|
||||
Ok(tool_text(serde_json::json!({
|
||||
"server": server_name,
|
||||
"tool": tool_name,
|
||||
"result": data
|
||||
})))
|
||||
}
|
||||
other => Err(McpError::not_found(format!("unknown tool: {other}"))),
|
||||
}
|
||||
}
|
||||
|
|
@ -249,6 +342,12 @@ pub async fn dispatch_tool(
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn env_bool(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn require_write(config: &McpServerConfig) -> Result<(), McpError> {
|
||||
if config.write_enabled {
|
||||
Ok(())
|
||||
|
|
@ -259,6 +358,16 @@ fn require_write(config: &McpServerConfig) -> Result<(), McpError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn require_external_call(config: &McpServerConfig) -> Result<(), McpError> {
|
||||
if config.external_call_enabled {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(McpError::permission(
|
||||
"external MCP tool calls require COLIBRI_MCP_EXTERNAL_CALL=1",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn require_string(args: &Value, key: &str) -> Result<String, McpError> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
//! COLIBRI_MCP_SOCKET — override daemon socket path
|
||||
//! COLIBRI_DAEMON_SOCKET — fallback daemon socket path
|
||||
//! COLIBRI_MCP_WRITE=1 — enable write tools (create/intake/set-cost-mode)
|
||||
//! COLIBRI_MCP_EXTERNAL_CONFIG — external MCP server registry JSON
|
||||
//! COLIBRI_MCP_EXTERNAL_CALL=1 — enable external MCP tools/call proxying
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
|
@ -26,6 +28,14 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
write: bool,
|
||||
|
||||
/// Path to external MCP server registry JSON
|
||||
#[arg(long)]
|
||||
external_config: Option<String>,
|
||||
|
||||
/// Enable calls to external MCP server tools
|
||||
#[arg(long)]
|
||||
external_call: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<SubCmd>,
|
||||
}
|
||||
|
|
@ -57,6 +67,12 @@ async fn main() -> ExitCode {
|
|||
if cli.write {
|
||||
config.write_enabled = true;
|
||||
}
|
||||
if let Some(ref p) = cli.external_config {
|
||||
config.external_config_path = std::path::PathBuf::from(p);
|
||||
}
|
||||
if cli.external_call {
|
||||
config.external_call_enabled = true;
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
Some(SubCmd::Tools) => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ fn config(socket: PathBuf, write_enabled: bool) -> McpServerConfig {
|
|||
McpServerConfig {
|
||||
socket_path: socket,
|
||||
write_enabled,
|
||||
external_config_path: PathBuf::from("/nonexistent/colibri-external-mcp-test.json"),
|
||||
external_call_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,5 +249,8 @@ fn tool_list_has_all_phase1_tools() {
|
|||
assert!(names.contains(&"colibri_create_task"));
|
||||
assert!(names.contains(&"colibri_intake_task"));
|
||||
assert!(names.contains(&"colibri_set_cost_mode"));
|
||||
assert_eq!(names.len(), 7);
|
||||
assert!(names.contains(&"colibri_external_mcp_servers"));
|
||||
assert!(names.contains(&"colibri_external_mcp_list_tools"));
|
||||
assert!(names.contains(&"colibri_external_mcp_call_tool"));
|
||||
assert_eq!(names.len(), 10);
|
||||
}
|
||||
|
|
|
|||
86
docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md
Normal file
86
docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Colibri external MCP host prototype
|
||||
|
||||
**Status:** prototype. `colibri-mcp` is still primarily an MCP server that exposes
|
||||
Colibri to editors/assistants. This prototype also lets that server act as a
|
||||
small MCP client/host for other stdio MCP servers.
|
||||
|
||||
## What it does now
|
||||
|
||||
`colibri-mcp` always exposes three prototype tools:
|
||||
|
||||
- `colibri_external_mcp_servers` — read the configured external server registry
|
||||
- `colibri_external_mcp_list_tools` — spawn configured servers and call `tools/list`
|
||||
- `colibri_external_mcp_call_tool` — call an external server tool; gated by
|
||||
`COLIBRI_MCP_EXTERNAL_CALL=1`
|
||||
|
||||
The default is read-only/discovery: listing configured servers and their tools is
|
||||
allowed, but calling external tools requires an explicit trusted profile.
|
||||
|
||||
## Registry file
|
||||
|
||||
By default, `colibri-mcp` looks for:
|
||||
|
||||
```text
|
||||
/usr/local/etc/colibri/external-mcp.json
|
||||
```
|
||||
|
||||
Override with:
|
||||
|
||||
```sh
|
||||
COLIBRI_MCP_EXTERNAL_CONFIG=/path/to/external-mcp.json colibri-mcp
|
||||
# or
|
||||
colibri-mcp --external-config /path/to/external-mcp.json
|
||||
```
|
||||
|
||||
Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"demo": {
|
||||
"command": "/usr/local/bin/demo-mcp-server",
|
||||
"args": ["--stdio"],
|
||||
"env": {
|
||||
"DEMO_MODE": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Trusted call mode
|
||||
|
||||
External tool calls are disabled unless explicitly enabled:
|
||||
|
||||
```sh
|
||||
COLIBRI_MCP_EXTERNAL_CALL=1 colibri-mcp
|
||||
# or
|
||||
colibri-mcp --external-call
|
||||
```
|
||||
|
||||
This is intentionally separate from `COLIBRI_MCP_WRITE=1`. A Colibri write tool
|
||||
and an external MCP tool are different trust surfaces.
|
||||
|
||||
## Quick smoke shape
|
||||
|
||||
From an MCP client:
|
||||
|
||||
```json
|
||||
{ "name": "colibri_external_mcp_servers", "arguments": {} }
|
||||
{ "name": "colibri_external_mcp_list_tools", "arguments": { "server": "demo" } }
|
||||
{ "name": "colibri_external_mcp_call_tool", "arguments": { "server": "demo", "name": "tool_name", "arguments": {} } }
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
This prototype is intentionally simple:
|
||||
|
||||
- stdio transport only
|
||||
- one external process per request
|
||||
- no long-lived connection pool
|
||||
- no server/tool allowlist beyond the registry file
|
||||
- no streaming tool results
|
||||
- no production-grade secret manager integration
|
||||
|
||||
That is enough for experimental ISO/operator work. A production host should add
|
||||
policy, lifecycle management, audit logs, and per-tool permission controls.
|
||||
|
|
@ -17,7 +17,7 @@ anyway). See the agent-harness consolidation notes.
|
|||
Colibri is the **supervisor**, already models `AgentRuntime{Pi, Zot}`, and —
|
||||
critically — **already spawns pi**: `crates/colibri-daemon/src/spawner.rs` runs
|
||||
agent subprocesses, captures their stdout JSONL, and hands it to glasspane.
|
||||
`socket.rs:345` even comments *"enables real Pi spawn."* Confinement is a
|
||||
`socket.rs:345` even comments _"enables real Pi spawn."_ Confinement is a
|
||||
supervisor concern and root-adjacent, so it belongs here. zot stays a clean
|
||||
upstream mirror, untouched.
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ SpawnAgent socket cmd (lib.rs:40)
|
|||
→ glasspane apply_pi_event (AgentRuntime::Pi, socket.rs:410)
|
||||
```
|
||||
|
||||
Everything except *what binary gets exec'd* is jail-agnostic and stays as-is.
|
||||
Everything except _what binary gets exec'd_ is jail-agnostic and stays as-is.
|
||||
|
||||
## Design
|
||||
|
||||
|
|
@ -109,34 +109,35 @@ pi shows up as `AgentRuntime::Pi` with zero glasspane changes.
|
|||
### 3. Teardown — `AgentHandle::kill` (spawner.rs:197)
|
||||
|
||||
Add a jail-aware branch:
|
||||
|
||||
- **jexec:** kill the **process group** (`-pid`); killing the jexec process
|
||||
alone does not reliably reap the in-jail child.
|
||||
- **ephemeral jail:** also `jail -r <name>` so jails are not leaked per spawn.
|
||||
|
||||
## Privilege model — the decision
|
||||
|
||||
Jail *attach* (`jexec`) and *create* (`jail`) are **root-only** in base FreeBSD;
|
||||
Jail _attach_ (`jexec`) and _create_ (`jail`) are **root-only** in base FreeBSD;
|
||||
there is no unprivileged path. But `colibri_daemon` runs as the unprivileged
|
||||
`colibri` user (`nologin`), so it cannot attach a jail by itself. Two ways to
|
||||
cross that line — and we pick **per deployment context**, matching the
|
||||
live-vs-deployed split.
|
||||
|
||||
The deciding fact: the ISO's mac_do rules are **identity** mappings, not command
|
||||
The deciding fact: the ISO's mac*do rules are **identity** mappings, not command
|
||||
filters — `security.mac.do.rules=gid=0>uid=0` (clawdie-iso `build.sh:1274`) means
|
||||
"wheel may become root." mac_do **cannot** restrict *which* command runs as root.
|
||||
"wheel may become root." mac_do **cannot** restrict \_which* command runs as root.
|
||||
|
||||
| | `mdo -u root` | setuid/Capsicum helper |
|
||||
|---|---|---|
|
||||
| New privileged binary to write+audit | none (reuses mac_do) | yes |
|
||||
| Kernel-enforced | yes | yes |
|
||||
| Non-interactive from daemon | yes (no password prompt) | yes |
|
||||
| Root blast radius if daemon is popped | **full root** | **just jexec-pi** |
|
||||
| Extra setup | one mac_do rule | helper + install |
|
||||
| | `mdo -u root` | setuid/Capsicum helper |
|
||||
| ------------------------------------- | ------------------------ | ---------------------- |
|
||||
| New privileged binary to write+audit | none (reuses mac_do) | yes |
|
||||
| Kernel-enforced | yes | yes |
|
||||
| Non-interactive from daemon | yes (no password prompt) | yes |
|
||||
| Root blast radius if daemon is popped | **full root** | **just jexec-pi** |
|
||||
| Extra setup | one mac_do rule | helper + install |
|
||||
|
||||
Because mac_do is command-blind, **wrapping mdo in a helper does NOT narrow it**:
|
||||
Because mac*do is command-blind, **wrapping mdo in a helper does NOT narrow it**:
|
||||
once `colibri` may `mdo -u root`, a compromise just runs `mdo -u root sh`. The
|
||||
helper is hygiene, not a boundary. Only a setuid/Capsicum helper (where colibri
|
||||
is *not* granted general root) is a true boundary.
|
||||
is \_not* granted general root) is a true boundary.
|
||||
|
||||
### Decision
|
||||
|
||||
|
|
@ -172,13 +173,13 @@ packaging stages `Helper`), so the same spawner serves both.
|
|||
|
||||
## Scope summary
|
||||
|
||||
| Piece | Effort | Where |
|
||||
|---|---|---|
|
||||
| `JailConfig` + field | trivial | spawner.rs:84, lib.rs:40 |
|
||||
| `jail_wrap` + call site | small | spawner.rs:341 |
|
||||
| jail-aware `kill` / `-r` | small | spawner.rs:197 |
|
||||
| `PrivMode` (mdo vs helper) selection | small | daemon config |
|
||||
| glasspane observation | none | already works |
|
||||
| zot changes | none | mirror untouched |
|
||||
| setuid `colibri-jail-spawn` helper | medium + security review | new (deploy lane) |
|
||||
| jail FS provisioning | medium (ops) | ISO / deploy packaging |
|
||||
| Piece | Effort | Where |
|
||||
| ------------------------------------ | ------------------------ | ------------------------ |
|
||||
| `JailConfig` + field | trivial | spawner.rs:84, lib.rs:40 |
|
||||
| `jail_wrap` + call site | small | spawner.rs:341 |
|
||||
| jail-aware `kill` / `-r` | small | spawner.rs:197 |
|
||||
| `PrivMode` (mdo vs helper) selection | small | daemon config |
|
||||
| glasspane observation | none | already works |
|
||||
| zot changes | none | mirror untouched |
|
||||
| setuid `colibri-jail-spawn` helper | medium + security review | new (deploy lane) |
|
||||
| jail FS provisioning | medium (ops) | ISO / deploy packaging |
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue