diff --git a/Cargo.toml b/Cargo.toml index 01690d6b4..efe07b94b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,10 @@ members = [ "crates/astrid-types", "crates/astrid-hooks", "crates/astrid-integration-tests", + "crates/astrid-ipc-client", "crates/astrid-kernel", "crates/astrid-mcp", + "crates/astrid-mcp-bridge", "crates/astrid-openclaw", "crates/astrid-prelude", "crates/astrid-storage", @@ -46,8 +48,10 @@ astrid-core = { path = "crates/astrid-core", version = "0.6.0" } astrid-crypto = { path = "crates/astrid-crypto", version = "0.6.0" } astrid-events = { path = "crates/astrid-events", version = "0.6.0" } astrid-hooks = { path = "crates/astrid-hooks", version = "0.6.0" } +astrid-ipc-client = { path = "crates/astrid-ipc-client", version = "0.6.0" } astrid-kernel = { path = "crates/astrid-kernel", version = "0.6.0" } astrid-mcp = { path = "crates/astrid-mcp", version = "0.6.0" } +astrid-mcp-bridge = { path = "crates/astrid-mcp-bridge", version = "0.6.0" } astrid-openclaw = { path = "crates/astrid-openclaw", version = "0.6.0" } astrid-prelude = { path = "crates/astrid-prelude", version = "0.6.0" } astrid-storage = { path = "crates/astrid-storage", version = "0.6.0" } @@ -102,8 +106,9 @@ rand = "0.8" ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] } regex = "1.10" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] } -rmcp = { version = "0.15", features = ["client", "transport-child-process", "transport-io", "elicitation"] } +rmcp = { version = "0.15", features = ["client", "server", "macros", "transport-child-process", "transport-io", "elicitation"] } rustyline = { version = "15", features = ["derive"] } +schemars = "0.8" semver = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/astrid-cli/Cargo.toml b/crates/astrid-cli/Cargo.toml index 6d07e70b8..9b8250a82 100644 --- a/crates/astrid-cli/Cargo.toml +++ b/crates/astrid-cli/Cargo.toml @@ -29,6 +29,8 @@ astrid-config = { workspace = true } astrid-core = { workspace = true } blake3 = { workspace = true } astrid-events = { workspace = true } +astrid-ipc-client = { workspace = true } +astrid-mcp-bridge = { workspace = true } astrid-types = { workspace = true } astrid-daemon = { workspace = true } astrid-storage = { workspace = true, features = ["keychain"] } diff --git a/crates/astrid-cli/src/admin_client.rs b/crates/astrid-cli/src/admin_client.rs index c624f15ac..d0aaaeacb 100644 --- a/crates/astrid-cli/src/admin_client.rs +++ b/crates/astrid-cli/src/admin_client.rs @@ -22,7 +22,7 @@ use astrid_types::kernel::{ use serde_json::Value; use uuid::Uuid; -use crate::socket_client::SocketClient; +use astrid_ipc_client::socket_client::SocketClient; /// Topic prefix for admin requests sent by the CLI. const ADMIN_INPUT_PREFIX: &str = "astrid.v1.admin."; diff --git a/crates/astrid-cli/src/bootstrap.rs b/crates/astrid-cli/src/bootstrap.rs index 3c2e9cbfa..3420685b7 100644 --- a/crates/astrid-cli/src/bootstrap.rs +++ b/crates/astrid-cli/src/bootstrap.rs @@ -11,7 +11,7 @@ use anyhow::{Context, Result}; use crate::cli::Cli; use crate::commands; use crate::formatter::OutputFormat; -use crate::socket_client; +use astrid_ipc_client::socket_client; use crate::theme; /// Ensure `~/.astrid/` exists and run first-boot init if needed. diff --git a/crates/astrid-cli/src/cli.rs b/crates/astrid-cli/src/cli.rs index 7dae70812..c6a2ef791 100644 --- a/crates/astrid-cli/src/cli.rs +++ b/crates/astrid-cli/src/cli.rs @@ -11,7 +11,8 @@ use crate::commands::{ agent::AgentCommand, audit::AuditArgs, budget::BudgetCommand, caps::CapsCommand, capsule::config::ConfigArgs as CapsuleConfigArgs, capsule::show::ShowArgs as CapsuleShowArgs, completions::CompletionsArgs, doctor::DoctorArgs, gc::GcArgs, group::GroupCommand, - logs::LogsArgs, ps::PsArgs, quota::QuotaCommand, run::RunArgs, secret::SecretCommand, + logs::LogsArgs, mcp::McpCommand, ps::PsArgs, quota::QuotaCommand, run::RunArgs, + secret::SecretCommand, setup::SetupArgs, top::TopArgs, trust::TrustCommand, version::VersionArgs, voucher::VoucherCommand, who::WhoArgs, }; @@ -179,6 +180,12 @@ pub(crate) enum Commands { distro: String, }, + /// Bridge Astrid tools to external MCP clients (Claude Code, etc.) + Mcp { + #[command(subcommand)] + command: McpCommand, + }, + /// View resolved configuration, edit it in `$EDITOR`, or print paths. Config { #[command(subcommand)] diff --git a/crates/astrid-cli/src/commands/chat.rs b/crates/astrid-cli/src/commands/chat.rs index 4cc0177d3..9b0300332 100644 --- a/crates/astrid-cli/src/commands/chat.rs +++ b/crates/astrid-cli/src/commands/chat.rs @@ -9,7 +9,7 @@ use colored::Colorize; use crate::formatter::{OutputFormat, OutputFormatter, create_formatter}; use crate::repl::ReadlineEvent; -use crate::socket_client::SocketClient; +use astrid_ipc_client::socket_client::SocketClient; use crate::theme::Theme; /// Reason sent (and displayed) when the JSON REPL auto-denies an approval request. @@ -85,7 +85,8 @@ async fn run_json_chat( continue; } - client.send_input(input.to_string()).await?; + let caller = crate::context::active_agent()?.to_string(); + client.send_input(input.to_string(), caller).await?; if !drain_agent_response(client, session_id, &mut *formatter).await? { return Ok(()); diff --git a/crates/astrid-cli/src/commands/daemon.rs b/crates/astrid-cli/src/commands/daemon.rs index c577a530e..30702b218 100644 --- a/crates/astrid-cli/src/commands/daemon.rs +++ b/crates/astrid-cli/src/commands/daemon.rs @@ -3,7 +3,8 @@ use anyhow::{Context, Result}; use crate::bootstrap::find_companion_binary; -use crate::{socket_client, theme}; +use crate::theme; +use astrid_ipc_client::socket_client; /// Build a hint string pointing the user to the daemon log directory. fn log_hint() -> String { @@ -203,7 +204,7 @@ pub(crate) async fn handle_status() -> Result<()> { ) .await?; if let Some(astrid_types::kernel::KernelResponse::Status(status)) = - crate::socket_client::SocketClient::extract_kernel_response(&raw) + astrid_ipc_client::socket_client::SocketClient::extract_kernel_response(&raw) { let uptime_display = format_uptime(status.uptime_secs); println!( diff --git a/crates/astrid-cli/src/commands/doctor.rs b/crates/astrid-cli/src/commands/doctor.rs index b26113d5c..51c83cdbc 100644 --- a/crates/astrid-cli/src/commands/doctor.rs +++ b/crates/astrid-cli/src/commands/doctor.rs @@ -13,7 +13,7 @@ use clap::Args; use colored::Colorize; use uuid::Uuid; -use crate::socket_client::SocketClient; +use astrid_ipc_client::socket_client::SocketClient; use crate::theme::Theme; #[derive(Args, Debug, Clone)] diff --git a/crates/astrid-cli/src/commands/headless.rs b/crates/astrid-cli/src/commands/headless.rs index 9abd87ff1..fca04b00b 100644 --- a/crates/astrid-cli/src/commands/headless.rs +++ b/crates/astrid-cli/src/commands/headless.rs @@ -5,7 +5,8 @@ use std::io::IsTerminal; use anyhow::{Context, Result}; use super::daemon; -use crate::{formatter, socket_client, tui}; +use crate::{formatter, tui}; +use astrid_ipc_client::socket_client; /// Resolve the `--session` flag value into a [`uuid::Uuid`]. /// @@ -130,7 +131,8 @@ pub(crate) async fn run_headless( }; // Send the prompt and collect the streaming response - client.send_input(full_prompt).await?; + let caller = crate::context::active_agent()?.to_string(); + client.send_input(full_prompt, caller).await?; let (response_text, tool_calls) = collect_response(&mut client, &session_id, format, auto_approve).await?; diff --git a/crates/astrid-cli/src/commands/mcp.rs b/crates/astrid-cli/src/commands/mcp.rs new file mode 100644 index 000000000..3414a8c47 --- /dev/null +++ b/crates/astrid-cli/src/commands/mcp.rs @@ -0,0 +1,26 @@ +//! `astrid mcp` subcommand group — bridges Astrid capsule tools to +//! external MCP clients (e.g. Claude Code). + +use std::process::ExitCode; + +use anyhow::Result; +use clap::Subcommand; + +use astrid_mcp_bridge::{BridgeConfig, run_stdio}; + +#[derive(Subcommand)] +pub(crate) enum McpCommand { + /// Run the MCP bridge over stdio. Intended to be spawned by an + /// MCP client (e.g. Claude Code via .mcp.json) — not invoked + /// interactively. + Bridge, +} + +pub(crate) async fn run(command: McpCommand) -> Result { + match command { + McpCommand::Bridge => { + run_stdio(BridgeConfig::default()).await?; + Ok(ExitCode::SUCCESS) + }, + } +} diff --git a/crates/astrid-cli/src/commands/mod.rs b/crates/astrid-cli/src/commands/mod.rs index ca09ff5a1..5f25c184b 100644 --- a/crates/astrid-cli/src/commands/mod.rs +++ b/crates/astrid-cli/src/commands/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod group; pub(crate) mod headless; pub(crate) mod init; pub(crate) mod logs; +pub(crate) mod mcp; pub(crate) mod ps; pub(crate) mod quota; pub(crate) mod restart; diff --git a/crates/astrid-cli/src/commands/ps.rs b/crates/astrid-cli/src/commands/ps.rs index a75c68776..4b4bb9162 100644 --- a/crates/astrid-cli/src/commands/ps.rs +++ b/crates/astrid-cli/src/commands/ps.rs @@ -13,7 +13,7 @@ use colored::Colorize; use serde::Serialize; use uuid::Uuid; -use crate::socket_client::SocketClient; +use astrid_ipc_client::socket_client::SocketClient; use crate::theme::Theme; use crate::value_formatter::{ValueFormat, emit_structured}; @@ -38,7 +38,7 @@ pub(crate) struct CapsuleRow { /// Entry point for `astrid ps`. pub(crate) async fn run(args: PsArgs) -> Result { let format = ValueFormat::parse(&args.format); - let socket_path = crate::socket_client::proxy_socket_path(); + let socket_path = astrid_ipc_client::socket_client::proxy_socket_path(); if !socket_path.exists() { if format.is_pretty() { println!("{}", Theme::info("No Astrid daemon is running.")); @@ -66,7 +66,7 @@ pub(crate) async fn run(args: PsArgs) -> Result { std::time::Duration::from_secs(10), ) .await?; - let entries = match crate::socket_client::SocketClient::extract_kernel_response(&raw) { + let entries = match astrid_ipc_client::socket_client::SocketClient::extract_kernel_response(&raw) { Some(astrid_types::kernel::KernelResponse::CapsuleMetadata(list)) => list, _ => Vec::new(), }; diff --git a/crates/astrid-cli/src/commands/restart.rs b/crates/astrid-cli/src/commands/restart.rs index 0e2c78930..755a20bd6 100644 --- a/crates/astrid-cli/src/commands/restart.rs +++ b/crates/astrid-cli/src/commands/restart.rs @@ -12,7 +12,7 @@ use anyhow::Result; use tokio::time::sleep; use crate::commands::daemon; -use crate::socket_client; +use astrid_ipc_client::socket_client; use crate::theme::Theme; /// Entry point for `astrid restart`. diff --git a/crates/astrid-cli/src/commands/who.rs b/crates/astrid-cli/src/commands/who.rs index d9f9b43e7..870482b48 100644 --- a/crates/astrid-cli/src/commands/who.rs +++ b/crates/astrid-cli/src/commands/who.rs @@ -23,7 +23,7 @@ use serde::Serialize; use uuid::Uuid; use crate::commands::daemon; -use crate::socket_client::SocketClient; +use astrid_ipc_client::socket_client::SocketClient; use crate::theme::Theme; use crate::value_formatter::{ValueFormat, emit_structured}; @@ -47,7 +47,7 @@ pub(crate) struct Connection { /// Entry point for `astrid who`. pub(crate) async fn run(args: WhoArgs) -> Result { let format = ValueFormat::parse(&args.format); - let socket_path = crate::socket_client::proxy_socket_path(); + let socket_path = astrid_ipc_client::socket_client::proxy_socket_path(); if !socket_path.exists() { if format.is_pretty() { println!("{}", Theme::info("No Astrid daemon is running.")); @@ -78,7 +78,7 @@ pub(crate) async fn run(args: WhoArgs) -> Result { // Reuse the shared envelope extractor so this command tracks any // future change to the IPC response wrapper without re-implementing // the `{type, value}` unwrap inline (matches `ps` / `daemon` usage). - let status = match crate::socket_client::SocketClient::extract_kernel_response(&raw) { + let status = match astrid_ipc_client::socket_client::SocketClient::extract_kernel_response(&raw) { Some(astrid_types::kernel::KernelResponse::Status(s)) => Some(s), _ => None, }; diff --git a/crates/astrid-cli/src/dispatch.rs b/crates/astrid-cli/src/dispatch.rs index 7fd85e1da..0732e52e4 100644 --- a/crates/astrid-cli/src/dispatch.rs +++ b/crates/astrid-cli/src/dispatch.rs @@ -143,6 +143,7 @@ async fn dispatch_subcommand( commands::self_update::ensure_path_setup()?; Ok(ExitCode::SUCCESS) }, + Some(Commands::Mcp { command }) => commands::mcp::run(command).await, Some(Commands::Capsule { command }) => dispatch_capsule(command).await, Some(Commands::Distro { command }) => dispatch_distro(command).await, Some(Commands::Wit { command }) => dispatch_wit(&command), diff --git a/crates/astrid-cli/src/main.rs b/crates/astrid-cli/src/main.rs index f2caf08ae..5edfe92d5 100644 --- a/crates/astrid-cli/src/main.rs +++ b/crates/astrid-cli/src/main.rs @@ -37,8 +37,6 @@ mod context; mod dispatch; mod formatter; mod repl; -/// The socket client for interacting with the Kernel. -pub mod socket_client; mod theme; mod tui; mod value_formatter; diff --git a/crates/astrid-cli/src/tui/headless.rs b/crates/astrid-cli/src/tui/headless.rs index 8ea5b9554..d8d36599a 100644 --- a/crates/astrid-cli/src/tui/headless.rs +++ b/crates/astrid-cli/src/tui/headless.rs @@ -31,7 +31,7 @@ use ratatui::style::{Color, Modifier}; use super::render; use super::state::{App, MessageRole, UiState}; -use crate::socket_client::SocketClient; +use astrid_ipc_client::socket_client::SocketClient; /// Convert a ratatui `Color` to an ANSI SGR foreground code. fn fg_ansi(color: Color) -> Option { @@ -209,7 +209,10 @@ pub(crate) async fn run(cfg: HeadlessConfig<'_>) -> anyhow::Result<()> { start_time: Instant::now(), dots: 0, }; - cfg.client.send_input(cfg.prompt.to_string()).await?; + let caller = crate::context::active_agent()?.to_string(); + cfg.client + .send_input(cfg.prompt.to_string(), caller) + .await?; snapshot(&mut terminal, &mut app, "input_sent"); let timeout = Duration::from_secs(120); diff --git a/crates/astrid-cli/src/tui/mod.rs b/crates/astrid-cli/src/tui/mod.rs index 32eb76411..219b54e7b 100644 --- a/crates/astrid-cli/src/tui/mod.rs +++ b/crates/astrid-cli/src/tui/mod.rs @@ -26,7 +26,7 @@ use crossterm::{ }; use ratatui::{Terminal, backend::CrosstermBackend}; -use crate::socket_client::SocketClient; +use astrid_ipc_client::socket_client::SocketClient; use state::{App, MessageRole, PendingAction, UiState}; /// Type alias for our terminal. @@ -568,11 +568,23 @@ async fn handle_pending_actions( }; // Send to daemon. - if let Err(e) = client.send_input(content).await { - app.push_notice(&format!("Failed to send input: {e}")); - app.state = UiState::Error { - message: format!("Send failed: {e}"), - }; + match crate::context::active_agent() { + Ok(caller) => { + if let Err(e) = + client.send_input(content, caller.to_string()).await + { + app.push_notice(&format!("Failed to send input: {e}")); + app.state = UiState::Error { + message: format!("Send failed: {e}"), + }; + } + }, + Err(e) => { + app.push_notice(&format!("Failed to resolve active agent: {e}")); + app.state = UiState::Error { + message: format!("Active agent resolution failed: {e}"), + }; + }, } } }, diff --git a/crates/astrid-ipc-client/Cargo.toml b/crates/astrid-ipc-client/Cargo.toml new file mode 100644 index 000000000..c0459237d --- /dev/null +++ b/crates/astrid-ipc-client/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "astrid-ipc-client" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Astrid daemon socket client — handshake + length-prefixed JSON IpcMessage I/O." + +[dependencies] +anyhow = { workspace = true } +astrid-core = { workspace = true } +astrid-types = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util"] } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/astrid-ipc-client/src/lib.rs b/crates/astrid-ipc-client/src/lib.rs new file mode 100644 index 000000000..dfe4353ec --- /dev/null +++ b/crates/astrid-ipc-client/src/lib.rs @@ -0,0 +1,8 @@ +//! Astrid daemon socket client. +//! +//! Length-prefixed JSON `IpcMessage` I/O over the system Unix +//! socket with bearer-token handshake. Extracted from `astrid-cli` +//! for reuse by the MCP bridge. + +pub mod socket_client; +pub use socket_client::SocketClient; diff --git a/crates/astrid-cli/src/socket_client.rs b/crates/astrid-ipc-client/src/socket_client.rs similarity index 94% rename from crates/astrid-cli/src/socket_client.rs rename to crates/astrid-ipc-client/src/socket_client.rs index e443dd79e..f7afdd811 100644 --- a/crates/astrid-cli/src/socket_client.rs +++ b/crates/astrid-ipc-client/src/socket_client.rs @@ -140,8 +140,8 @@ impl SocketClient { } /// Read the next length-prefixed frame as raw bytes, without - /// attempting to deserialize. Used by [`crate::admin_client`] when - /// it needs to tolerate broadcast messages that don't deserialize + /// attempting to deserialize. Used by `admin_client` callers when + /// they need to tolerate broadcast messages that don't deserialize /// cleanly into [`IpcMessage`] (e.g. the kernel's /// `astrid.v1.capsules_loaded` payload, which serializes without a /// `type` discriminator on the inner JSON). @@ -251,27 +251,27 @@ impl SocketClient { /// Send a user input message to the Kernel. /// - /// Stamps the outbound `IpcMessage` with the operator's active - /// agent (set by `astrid agent switch`, queried via - /// [`crate::context::active_agent`]), so the kernel's - /// `resolve_caller` sees the right principal for session, KV, - /// home, secret, and quota scoping. Trust-the-uplink model - /// today; cryptographic per-connection auth lives in #658. + /// Stamps the outbound `IpcMessage` with the supplied caller + /// principal, so the kernel's `resolve_caller` sees the right + /// principal for session, KV, home, secret, and quota scoping. + /// Trust-the-uplink model today; cryptographic per-connection + /// auth lives in #658. + /// + /// The caller is supplied as a string by the embedder (the CLI + /// resolves it from its operator-local active-agent context; + /// other embedders like the MCP bridge pass their own). /// /// # Errors - /// Returns an error if the active agent context cannot be - /// resolved or the message cannot be sent. - pub async fn send_input(&mut self, text: String) -> Result<()> { + /// Returns an error if the message cannot be sent. + pub async fn send_input(&mut self, text: String, caller: String) -> Result<()> { let payload = IpcPayload::UserInput { text, session_id: self.session_id.0.to_string(), context: None, }; - let caller = - crate::context::active_agent().context("Failed to resolve active agent context")?; let msg = IpcMessage::new("user.v1.prompt", payload, self.session_id.0) - .with_principal(caller.to_string()); + .with_principal(caller); self.send_message(msg).await } diff --git a/crates/astrid-mcp-bridge/Cargo.toml b/crates/astrid-mcp-bridge/Cargo.toml new file mode 100644 index 000000000..4663b659c --- /dev/null +++ b/crates/astrid-mcp-bridge/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "astrid-mcp-bridge" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "MCP server bridge that lets external MCP clients (Claude Code) drive Astrid capsule tools." + +[dependencies] +anyhow = { workspace = true } +astrid-core = { workspace = true } +astrid-ipc-client = { workspace = true } +astrid-types = { workspace = true } +rmcp = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util", "sync", "macros", "rt-multi-thread"] } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/astrid-mcp-bridge/src/catalog.rs b/crates/astrid-mcp-bridge/src/catalog.rs new file mode 100644 index 000000000..d862b5831 --- /dev/null +++ b/crates/astrid-mcp-bridge/src/catalog.rs @@ -0,0 +1,332 @@ +//! Build the MCP tool catalog by calling capsule-system's +//! introspection tools (`list_capsules`, `inspect_capsule`) and +//! translating the responses into rmcp's [`Tool`] shape. +//! +//! # Observed response shapes (2026-05-24) +//! +//! - `list_capsules` returns a JSON-pretty array of `CapsuleSummary` +//! objects: `[{ "name": "astrid-capsule-shell", "version": "0.1.0", +//! "exports": [...], "imports": [...] }, ...]`. +//! - `inspect_capsule` returns a plain-text blob, **not** JSON: +//! `"=== Capsule.toml ===\n\n\n=== meta.json ===\n"`. +//! The tool list lives in the TOML's `[subscribe]` table (modern +//! manifests) or `[[interceptor]]` array (legacy manifests), keyed +//! by topics like `tool.v1.execute.`. + +use std::borrow::Cow; +use std::sync::Arc; +use std::time::Duration; + +use rmcp::model::Tool; +use serde_json::Value; + +use crate::daemon::DaemonConnection; +use crate::error::BridgeError; + +const CATALOG_TIMEOUT: Duration = Duration::from_secs(10); + +/// Capsule-name prefix stripped when building short capsule names. +/// `astrid-capsule-shell` -> `shell`. +const CAPSULE_NAME_PREFIX: &str = "astrid-capsule-"; + +/// Topic prefix that marks a subscribe/interceptor entry as a tool +/// invocation. We strip this to get the bare tool name. +const TOOL_EXECUTE_PREFIX: &str = "tool.v1.execute."; + +/// Topic suffix that marks an entry as a *result* publisher rather +/// than a tool invocation. Must be excluded. +const TOOL_RESULT_SUFFIX: &str = ".result"; + +/// Build the catalog by introspecting every installed capsule. +/// +/// # Errors +/// Propagates any [`BridgeError`] from the underlying +/// `call_tool_round_trip` calls (timeout, daemon disconnect, etc.). +pub async fn build_catalog(daemon: &mut DaemonConnection) -> Result, BridgeError> { + let list = daemon + .call_tool_round_trip( + "list_capsules", + serde_json::json!({}), + CATALOG_TIMEOUT, + deny_during_catalog, + ) + .await?; + let capsule_names = parse_capsule_names(&list.content); + + let mut tools = Vec::new(); + for name in capsule_names { + let inspect = daemon + .call_tool_round_trip( + "inspect_capsule", + serde_json::json!({ "name": &name }), + CATALOG_TIMEOUT, + deny_during_catalog, + ) + .await?; + tools.extend(parse_tools_from_inspect(&name, &inspect.content)); + } + Ok(tools) +} + +/// Approval policy for catalog-time introspection calls. +/// +/// Catalog construction happens at bridge startup, *before* the MCP +/// stdio handshake — there is no `Peer` yet to issue an elicitation +/// against, and the introspection tools (`list_capsules`, +/// `inspect_capsule` on capsule-system) are intentionally low-privilege +/// and should never trip the approval interceptor. If one ever does, +/// we deny rather than block forever or auto-approve into capabilities +/// the user never consented to. +async fn deny_during_catalog( + req: crate::daemon::ApprovalRequest, +) -> crate::daemon::ApprovalDecision { + tracing::warn!( + action = %req.action, + resource = %req.resource, + "approval required during catalog build; denying (no MCP client to elicit from yet)" + ); + crate::daemon::ApprovalDecision::Deny +} + +/// Extract capsule names from the JSON array returned by +/// `list_capsules`. +/// +/// `ToolCallResult.content` is itself a string; capsules return their +/// payload via `serde_json::to_string_pretty`, then the IPC layer +/// wraps it again. We strip up to two JSON-string layers before +/// expecting an array. +fn parse_capsule_names(content: &str) -> Vec { + let parsed = match unwrap_json_string_layers(content) { + Some(v) => v, + None => return Vec::new(), + }; + let arr = parsed + .as_array() + .or_else(|| parsed.get("capsules").and_then(Value::as_array)) + .cloned() + .unwrap_or_default(); + arr.into_iter() + .filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() +} + +/// Parse `content` as JSON. If the result is a JSON string (i.e. the +/// payload was wrapped one extra time by the IPC layer), parse the +/// inner string and return that. Returns `None` on parse failure. +fn unwrap_json_string_layers(content: &str) -> Option { + let mut value: Value = serde_json::from_str(content).ok()?; + // Unwrap up to two extra layers of string-encoding. + for _ in 0..2 { + match value { + Value::String(inner) => { + value = serde_json::from_str(&inner).ok()?; + } + other => return Some(other), + } + } + Some(value) +} + +/// Translate one `inspect_capsule` response into MCP [`Tool`] defs. +/// +/// The response is `=== Capsule.toml ===\n\n\n=== meta.json ===\n`. +/// We isolate the TOML chunk and harvest tool names from the +/// `[subscribe]` table or `[[interceptor]]` array. +fn parse_tools_from_inspect(capsule_name: &str, content: &str) -> Vec { + // inspect_capsule returns a plain-text blob, but the IPC layer + // wraps it as a JSON-encoded string. Unwrap one layer if needed. + let text = unwrap_string_layer(content); + let Some(toml_src) = extract_toml_section(&text) else { + return Vec::new(); + }; + let parsed: toml::Value = match toml::from_str(&toml_src) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let short = capsule_name + .strip_prefix(CAPSULE_NAME_PREFIX) + .unwrap_or(capsule_name); + + let mut tool_names: Vec = Vec::new(); + + // Modern manifests: [subscribe] table keyed by topic. + if let Some(subscribe) = parsed.get("subscribe").and_then(toml::Value::as_table) { + for topic in subscribe.keys() { + if let Some(name) = tool_name_from_topic(topic) { + tool_names.push(name); + } + } + } + + // Legacy manifests: [[interceptor]] array of tables, each with + // an `event` string. + if let Some(interceptors) = parsed.get("interceptor").and_then(toml::Value::as_array) { + for entry in interceptors { + if let Some(event) = entry.get("event").and_then(toml::Value::as_str) { + if let Some(name) = tool_name_from_topic(event) { + tool_names.push(name); + } + } + } + } + + tool_names + .into_iter() + .map(|raw| make_tool(short, &raw)) + .collect() +} + +/// If `content` parses as a JSON string, return the inner string; +/// otherwise return `content` unchanged. This undoes one layer of +/// IPC wrapping where a plain-text capsule response gets +/// JSON-encoded before transit. +fn unwrap_string_layer(content: &str) -> String { + match serde_json::from_str::(content) { + Ok(Value::String(s)) => s, + _ => content.to_owned(), + } +} + +/// Pull the `` substring from the inspect_capsule text blob. +/// Returns `None` if the markers aren't present. +fn extract_toml_section(content: &str) -> Option { + let after_header = content.split_once("=== Capsule.toml ===")?.1; + let toml_part = match after_header.split_once("=== meta.json ===") { + Some((before, _)) => before, + None => after_header, + }; + Some(toml_part.trim().to_owned()) +} + +/// Map a subscribe/interceptor topic like `tool.v1.execute.run_shell_command` +/// to its bare tool name (`run_shell_command`). Returns `None` for +/// non-tool topics (e.g. `.result` publishers, `tool.v1.request.describe`). +fn tool_name_from_topic(topic: &str) -> Option { + let rest = topic.strip_prefix(TOOL_EXECUTE_PREFIX)?; + // Reject empty, wildcards, or anything that's a result publisher. + // `react` subscribes to `tool.v1.execute.result` (the bare word + // "result" — the framework's result topic), so we filter that too, + // not just `.result` suffixes. + if rest.is_empty() || rest.contains('*') || rest == "result" || rest.ends_with(TOOL_RESULT_SUFFIX) { + return None; + } + Some(rest.to_owned()) +} + +/// Construct an `rmcp::Tool` with namespaced name `.`. +/// Input schema is left as a permissive `{"type": "object"}` until we +/// have a way to fetch real schemas (Task 7+). +fn make_tool(short_capsule: &str, raw_name: &str) -> Tool { + let mut schema = serde_json::Map::new(); + schema.insert("type".into(), Value::String("object".into())); + Tool { + name: Cow::Owned(format!("{short_capsule}.{raw_name}")), + title: None, + description: None, + input_schema: Arc::new(schema), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_capsule_names_extracts_from_array() { + let json = r#"[{"name": "astrid-capsule-shell", "version": "0.1.0"}, + {"name": "astrid-capsule-system", "version": "0.1.0"}]"#; + let names = parse_capsule_names(json); + assert_eq!(names, vec!["astrid-capsule-shell", "astrid-capsule-system"]); + } + + #[test] + fn extract_toml_section_handles_full_blob() { + let blob = "=== Capsule.toml ===\n[package]\nname = \"foo\"\n\n=== meta.json ===\n{}"; + let toml = extract_toml_section(blob).unwrap(); + assert!(toml.contains("[package]")); + assert!(!toml.contains("meta.json")); + } + + #[test] + fn tool_name_from_topic_filters_results_and_wildcards() { + assert_eq!( + tool_name_from_topic("tool.v1.execute.run_shell_command"), + Some("run_shell_command".into()) + ); + assert_eq!(tool_name_from_topic("tool.v1.execute.*.result"), None); + assert_eq!(tool_name_from_topic("tool.v1.execute.foo.result"), None); + assert_eq!(tool_name_from_topic("tool.v1.request.describe"), None); + // capsule-react subscribes to the bare `result` topic — it's the + // framework's tool-result fanout, not a tool of its own. + assert_eq!(tool_name_from_topic("tool.v1.execute.result"), None); + } + + #[test] + fn unwrap_json_string_layers_handles_double_encoded() { + // The IPC layer wraps capsule string responses one extra time: + // capsule returns `"[{...}]"`, transit gives us `"\"[{...}]\""`. + let double = "\"[{\\\"name\\\": \\\"x\\\"}]\""; + let v = unwrap_json_string_layers(double).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 1); + } + + #[test] + fn unwrap_string_layer_passes_through_plain_text() { + // `inspect_capsule` returns plain text; if it ever stops being + // JSON-string-wrapped, we still want it to flow through. + assert_eq!(unwrap_string_layer("hello"), "hello"); + assert_eq!(unwrap_string_layer("\"hello\""), "hello"); + } + + #[test] + fn parse_tools_modern_subscribe_table() { + let blob = r#"=== Capsule.toml === +[package] +name = "astrid-capsule-shell" + +[subscribe] +"tool.v1.execute.run_shell_command" = { handler = "x" } +"tool.v1.execute.kill_process" = { handler = "y" } +"tool.v1.request.describe" = { handler = "z" } + +=== meta.json === +{}"#; + let tools = parse_tools_from_inspect("astrid-capsule-shell", blob); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"shell.run_shell_command")); + assert!(names.contains(&"shell.kill_process")); + assert!(!names.iter().any(|n| n.contains("describe"))); + } + + #[test] + fn parse_tools_legacy_interceptor_array() { + let blob = r#"=== Capsule.toml === +[package] +name = "astrid-capsule-system" + +[[interceptor]] +event = "tool.v1.execute.list_capsules" +action = "tool_execute_list_capsules" + +[[interceptor]] +event = "tool.v1.execute.inspect_capsule" +action = "tool_execute_inspect_capsule" + +[[interceptor]] +event = "tool.v1.request.describe" +action = "tool_describe" + +=== meta.json === +{}"#; + let tools = parse_tools_from_inspect("astrid-capsule-system", blob); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"system.list_capsules")); + assert!(names.contains(&"system.inspect_capsule")); + assert!(!names.iter().any(|n| n.contains("describe"))); + } +} diff --git a/crates/astrid-mcp-bridge/src/daemon.rs b/crates/astrid-mcp-bridge/src/daemon.rs new file mode 100644 index 000000000..7d3f7353e --- /dev/null +++ b/crates/astrid-mcp-bridge/src/daemon.rs @@ -0,0 +1,211 @@ +//! Wraps `astrid-ipc-client::SocketClient` with bridge-specific +//! conveniences: typed send/receive of `IpcMessage`s with principal +//! attribution, plus a helper that builds messages stamped with the +//! connection's principal. + +use std::time::Duration; + +use astrid_core::SessionId; +use astrid_ipc_client::SocketClient; +use astrid_types::ipc::{IpcMessage, IpcPayload}; +use astrid_types::llm::ToolCallResult; +use tokio::time::timeout; +use uuid::Uuid; + +use crate::error::BridgeError; + +/// A capability-approval request surfaced mid-tool-call by the kernel. +/// +/// Mirrors [`IpcPayload::ApprovalRequired`](astrid_types::ipc::IpcPayload::ApprovalRequired) +/// minus the wire-level `request_id` (the daemon connection handles +/// correlation internally). Passed to the `on_approval` callback of +/// [`DaemonConnection::call_tool_round_trip`]. +#[derive(Debug, Clone)] +pub struct ApprovalRequest { + /// Action being requested (e.g. "git push"). + pub action: String, + /// Resource target (e.g. the full command string). + pub resource: String, + /// Justification supplied by the originating interceptor/capsule. + pub reason: String, +} + +/// The user-facing decision on an [`ApprovalRequest`]. +/// +/// Serializes to the `decision` string on +/// [`IpcPayload::ApprovalResponse`](astrid_types::ipc::IpcPayload::ApprovalResponse) +/// — either `"approve"` or `"deny"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalDecision { + Approve, + Deny, +} + +impl ApprovalDecision { + /// Wire-protocol string for the IPC `decision` field. + #[must_use] + pub fn as_wire(self) -> &'static str { + match self { + Self::Approve => "approve", + Self::Deny => "deny", + } + } +} + +/// A connected, handshake-complete daemon session. +/// +/// The bridge uses one of these per stdio run. All outbound messages +/// are stamped with the configured `principal` (v1: always "default") +/// so the kernel's `resolve_caller` sees the right scope for KV, +/// home, secrets, and quotas. +pub struct DaemonConnection { + client: SocketClient, + principal: String, +} + +impl DaemonConnection { + /// Connect to the daemon at `~/.astrid/run/system.sock` and + /// complete the bearer-token handshake. + /// + /// # Errors + /// Returns [`BridgeError::DaemonConnect`] if the socket is + /// missing, the handshake is rejected, or any IO fails. + pub async fn connect(principal: &str) -> Result { + let session_id = SessionId::new(); + let client = SocketClient::connect(session_id) + .await + .map_err(BridgeError::DaemonConnect)?; + Ok(Self { + client, + principal: principal.to_owned(), + }) + } + + /// Build an [`IpcMessage`] with the connection's principal and a + /// fresh source UUID. Caller picks the topic and payload. + #[must_use] + pub fn build_message(&self, topic: impl Into, payload: IpcPayload) -> IpcMessage { + IpcMessage::new(topic, payload, Uuid::new_v4()).with_principal(&self.principal) + } + + /// Send a single [`IpcMessage`] to the daemon. Used for everything + /// the bridge originates: `ToolExecuteRequest`s, `ApprovalResponse`s, + /// future `ToolListRequest`s, etc. + /// + /// # Errors + /// Returns [`BridgeError::Internal`] if serialization or IO fails. + pub async fn send(&mut self, msg: &IpcMessage) -> Result<(), BridgeError> { + self.client + .send_message(msg.clone()) + .await + .map_err(BridgeError::Internal) + } + + /// Block until the next [`IpcMessage`] arrives. Returns `None` on + /// graceful peer disconnect. + /// + /// # Errors + /// Returns [`BridgeError::Internal`] if the underlying read fails + /// with an unrecoverable error (over-large frame, mid-frame IO + /// failure). + pub async fn recv(&mut self) -> Result, BridgeError> { + self.client + .read_message() + .await + .map_err(BridgeError::Internal) + } + + /// Access the configured principal for this connection. + #[must_use] + pub fn principal(&self) -> &str { + &self.principal + } + + /// Send a `ToolExecuteRequest` for `tool_name` with `args`, then + /// block until the matching `ToolExecuteResult` arrives. + /// + /// Filters out messages we self-echo: `capsule-cli` re-broadcasts + /// our own forwarded `ToolExecuteRequest` back at us (Task 1 + /// limitation: cannot subscribe to mid-segment wildcards like + /// `tool.v1.execute.*.result`), so we discriminate by payload + /// variant and `call_id` match rather than by topic. + /// + /// Any [`IpcPayload::ApprovalRequired`] that arrives mid-call is + /// surfaced to the caller via the `on_approval` callback. The + /// caller's returned [`ApprovalDecision`] is translated into an + /// `ApprovalResponse` (`"approve"` or `"deny"`) on the bus, then + /// the loop resumes waiting for the tool result. A deny does NOT + /// short-circuit the round-trip — the capsule decides whether + /// denial means "fail the tool call" or "fall back to a safer + /// path"; either way we still wait for its `ToolExecuteResult`. + /// + /// # Errors + /// - [`BridgeError::DaemonDisconnected`] if the peer closes mid-call. + /// - [`BridgeError::ToolTimeout`] if `deadline` elapses without a + /// matching response. + pub async fn call_tool_round_trip( + &mut self, + tool_name: &str, + args: serde_json::Value, + deadline: Duration, + on_approval: F, + ) -> Result + where + F: Fn(ApprovalRequest) -> Fut + Send + Sync, + Fut: std::future::Future + Send, + { + let call_id = Uuid::new_v4().to_string(); + let topic = format!("tool.v1.execute.{tool_name}"); + let req = self.build_message( + &topic, + IpcPayload::ToolExecuteRequest { + call_id: call_id.clone(), + tool_name: tool_name.to_owned(), + arguments: args, + }, + ); + self.send(&req).await?; + + timeout(deadline, async { + loop { + let msg = self.recv().await?.ok_or(BridgeError::DaemonDisconnected)?; + match msg.payload { + IpcPayload::ToolExecuteResult { + call_id: got, + result, + } if got == call_id => { + return Ok::(result); + }, + IpcPayload::ApprovalRequired { + request_id, + action, + resource, + reason, + } => { + let req = ApprovalRequest { + action, + resource, + reason, + }; + let decision = on_approval(req).await; + let topic = format!("astrid.v1.approval.response.{request_id}"); + let response = self.build_message( + &topic, + IpcPayload::ApprovalResponse { + request_id: request_id.clone(), + decision: decision.as_wire().to_string(), + reason: Some("mcp-bridge: forwarded via elicitation".to_string()), + }, + ); + self.send(&response).await?; + }, + _ => { + // Ignore self-echoed requests and unrelated traffic. + }, + } + } + }) + .await + .map_err(|_| BridgeError::ToolTimeout(deadline))? + } +} diff --git a/crates/astrid-mcp-bridge/src/debug.rs b/crates/astrid-mcp-bridge/src/debug.rs new file mode 100644 index 000000000..d8b19e11e --- /dev/null +++ b/crates/astrid-mcp-bridge/src/debug.rs @@ -0,0 +1,55 @@ +//! File-based debug logging. +//! +//! Claude Code swallows MCP subprocess stderr, and we haven't set up +//! a `tracing_subscriber` in `run_stdio` (no good default for a +//! stdio-talking process — anything we write to stderr risks +//! interleaving with rmcp's framing). So during the v1 debug phase +//! we append to a file the user can `tail -f` from a separate +//! terminal. +//! +//! Set `ASTRID_BRIDGE_DEBUG=0` (or any non-"1" value) to disable. +//! Default path: `/tmp/astrid-bridge.log`. Override with +//! `ASTRID_BRIDGE_LOG_PATH=/some/other/path`. + +use std::fmt::Arguments; +use std::io::Write; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +static ENABLED: OnceLock = OnceLock::new(); +static PATH: OnceLock = OnceLock::new(); + +fn enabled() -> bool { + *ENABLED.get_or_init(|| { + std::env::var("ASTRID_BRIDGE_DEBUG") + .map(|v| v != "0" && !v.is_empty()) + .unwrap_or(true) // default ON during v1 debug phase + }) +} + +fn path() -> &'static str { + PATH.get_or_init(|| { + std::env::var("ASTRID_BRIDGE_LOG_PATH") + .unwrap_or_else(|_| "/tmp/astrid-bridge.log".into()) + }) +} + +/// Append one timestamped line to the bridge debug log. +pub fn log(args: Arguments<'_>) { + if !enabled() { + return; + } + let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path()) + else { + return; + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let secs = now.as_secs(); + let ms = now.subsec_millis(); + let _ = writeln!(f, "[{secs}.{ms:03}] {args}"); +} diff --git a/crates/astrid-mcp-bridge/src/error.rs b/crates/astrid-mcp-bridge/src/error.rs new file mode 100644 index 000000000..ca152d427 --- /dev/null +++ b/crates/astrid-mcp-bridge/src/error.rs @@ -0,0 +1,27 @@ +//! Bridge error type. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BridgeError { + #[error("not yet implemented: {0}")] + NotYetImplemented(&'static str), + + #[error("daemon connection failed: {0}")] + DaemonConnect(#[source] anyhow::Error), + + #[error("daemon disconnected mid-session")] + DaemonDisconnected, + + #[error("tool call timed out after {0:?}")] + ToolTimeout(std::time::Duration), + + #[error("tool '{name}' not found in catalog")] + UnknownTool { name: String }, + + #[error("MCP protocol error: {0}")] + Mcp(#[source] anyhow::Error), + + #[error("internal: {0}")] + Internal(#[source] anyhow::Error), +} diff --git a/crates/astrid-mcp-bridge/src/lib.rs b/crates/astrid-mcp-bridge/src/lib.rs new file mode 100644 index 000000000..3dcaaf833 --- /dev/null +++ b/crates/astrid-mcp-bridge/src/lib.rs @@ -0,0 +1,52 @@ +//! MCP server bridge: speaks MCP over stdio, translates to/from +//! Astrid IPC messages routed through `capsule-cli`'s external API. + +#![allow(clippy::missing_errors_doc)] + +pub mod catalog; +pub mod daemon; +pub mod debug; +pub mod error; +pub mod mcp; + +pub use error::BridgeError; + +/// Configuration for a single bridge run. +#[derive(Debug, Clone)] +pub struct BridgeConfig { + /// Principal to attribute tool calls to. v1: always "default". + pub principal: String, + /// Default per-tool-call timeout. + pub tool_timeout: std::time::Duration, +} + +impl Default for BridgeConfig { + fn default() -> Self { + Self { + principal: "default".into(), + tool_timeout: std::time::Duration::from_secs(60), + } + } +} + +/// Run the bridge: connect to the daemon, build the tool catalog by +/// introspecting installed capsules, then accept MCP traffic on stdio. +/// Returns when stdin closes or a fatal error occurs. +pub async fn run_stdio(config: BridgeConfig) -> Result<(), BridgeError> { + use rmcp::{ServiceExt, transport::stdio}; + + debug::log(format_args!("run_stdio: starting, principal={}", config.principal)); + let daemon = daemon::DaemonConnection::connect(&config.principal).await?; + debug::log(format_args!("run_stdio: daemon connected")); + let server = mcp::AstridMcpServer::new(daemon).await?; + debug::log(format_args!("run_stdio: catalog built, serving over stdio")); + let service = server + .serve(stdio()) + .await + .map_err(|e| BridgeError::Mcp(anyhow::anyhow!("serve: {e}")))?; + service + .waiting() + .await + .map_err(|e| BridgeError::Mcp(anyhow::anyhow!("waiting: {e}")))?; + Ok(()) +} diff --git a/crates/astrid-mcp-bridge/src/mcp.rs b/crates/astrid-mcp-bridge/src/mcp.rs new file mode 100644 index 000000000..017522387 --- /dev/null +++ b/crates/astrid-mcp-bridge/src/mcp.rs @@ -0,0 +1,275 @@ +//! MCP server-side implementation. +//! +//! Task 6: holds a connected `DaemonConnection` plus a catalog of +//! tools built at startup via capsule-system introspection. The +//! catalog is returned verbatim from `list_tools`. +//! +//! Task 7: `call_tool` translates the MCP request into an Astrid +//! `ToolExecuteRequest`, round-trips through capsule-cli to the +//! owning capsule, and maps the returned `ToolCallResult` to an +//! MCP `CallToolResult`. +//! +//! We implement `ServerHandler` manually rather than via the +//! `#[tool_handler]` macro because that macro always injects its own +//! `list_tools`/`call_tool`/`get_tool` that delegate to the +//! `tool_router` — we want our catalog from capsule introspection +//! instead, and dispatch routes through IPC, not the router. + +use std::sync::Arc; +use std::time::Duration; + +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResult, Content, CreateElicitationRequestParams, + ElicitationAction, ElicitationSchema, Implementation, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, + }, + service::RequestContext, +}; +use tokio::sync::Mutex; + +use crate::daemon::{ApprovalDecision, ApprovalRequest, DaemonConnection}; +use crate::error::BridgeError; + +/// Per-call timeout for forwarded tool execution. Mirrors the default +/// Claude Code expects; capsule-side operations longer than this will +/// surface as timeout errors back to the MCP client. +const TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(60); + +/// How long to wait for the MCP client (Claude Code) to respond to an +/// elicitation prompt before treating it as a denial. Generous because +/// the human at the keyboard may take a moment to read the request. +const APPROVAL_TIMEOUT: Duration = Duration::from_secs(120); + +/// MCP server exposed by `astrid mcp bridge`. +/// +/// Cloneable (`rmcp` requires `Clone` on the handler): the daemon +/// connection sits behind an `Arc>` so we can serialize +/// writes from concurrent tool calls, and the catalog is an +/// `Arc>` so clones share storage. +#[derive(Clone)] +pub struct AstridMcpServer { + catalog: Arc>, + daemon: Arc>, +} + +impl AstridMcpServer { + /// Connect to the daemon, build the catalog by introspecting + /// every installed capsule, then return a ready-to-serve handler. + /// + /// # Errors + /// Propagates any [`BridgeError`] from catalog construction + /// (timeout reaching capsule-system, malformed responses, etc.). + pub async fn new(mut daemon: DaemonConnection) -> Result { + let catalog = crate::catalog::build_catalog(&mut daemon).await?; + Ok(Self { + catalog: Arc::new(catalog), + daemon: Arc::new(Mutex::new(daemon)), + }) + } +} + +impl ServerHandler for AstridMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + server_info: Implementation { + name: "astrid-mcp-bridge".into(), + version: env!("CARGO_PKG_VERSION").into(), + ..Default::default() + }, + instructions: Some( + "Bridges Astrid OS capsule tools to MCP clients. \ + All tool calls go through Astrid's capability, \ + audit, and approval layer." + .into(), + ), + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _ctx: RequestContext, + ) -> Result { + Ok(ListToolsResult { + meta: None, + next_cursor: None, + tools: (*self.catalog).clone(), + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + ctx: RequestContext, + ) -> Result { + crate::debug::log(format_args!("call_tool: enter, name={}", request.name)); + // 1. Validate the tool exists in our cached catalog. The + // catalog exposes tools as `.` + // (built in catalog::build_catalog), so the MCP-facing + // name always carries that prefix. + let mcp_name = request.name.as_ref(); + if !self.catalog.iter().any(|t| t.name == mcp_name) { + crate::debug::log(format_args!("call_tool: unknown tool {mcp_name}")); + return Err(McpError::internal_error( + format!("unknown tool: {mcp_name}"), + None, + )); + } + + // 2. Recover the on-bus tool name by stripping the capsule + // prefix. The Astrid IPC topic is `tool.v1.execute.` + // and capsule tools register under just ``. + let internal_name = mcp_name.split_once('.').map(|(_, rest)| rest).ok_or_else(|| { + McpError::internal_error(format!("malformed catalog entry: {mcp_name}"), None) + })?; + + // 3. MCP gives us arguments as Option (a + // serde_json::Map); wrap as Value::Object for the IPC + // payload. Missing arguments → empty object (matches what + // capsules expect from their argument deserializers). + let args = serde_json::Value::Object(request.arguments.unwrap_or_default()); + + // 4. Round-trip through the daemon. Hold the lock only for + // the duration of the send + recv loop — other tool calls + // on the same connection serialize behind us. + // + // Any approval requests raised mid-call route to Claude + // Code via an MCP elicitation: we surface action/resource/ + // reason in a form-style prompt with a single boolean + // `approved` field. Decline, Cancel, timeout, or any + // protocol error all map to Deny — the bridge errs on the + // side of refusing dangerous operations when the UI loop + // can't confirm intent. + let peer = ctx.peer.clone(); + crate::debug::log(format_args!( + "call_tool: dispatching internal={internal_name}" + )); + let result = { + let mut daemon = self.daemon.lock().await; + daemon + .call_tool_round_trip(internal_name, args, TOOL_CALL_TIMEOUT, |req| { + let peer = peer.clone(); + async move { + crate::debug::log(format_args!( + "approval_callback: invoked, action={}, resource={}", + req.action, req.resource + )); + let decision = elicit_approval(&peer, req).await; + crate::debug::log(format_args!( + "approval_callback: decision={decision:?}" + )); + decision + } + }) + .await + } + .map_err(|e| { + crate::debug::log(format_args!("call_tool: round-trip error: {e}")); + McpError::internal_error(format!("{e}"), None) + })?; + crate::debug::log(format_args!( + "call_tool: success, is_error={}", + result.is_error + )); + + // 5. Map the Astrid ToolCallResult to MCP CallToolResult. + // The capsule already discriminated success vs error via + // is_error; preserve that flag verbatim. + Ok(CallToolResult { + content: vec![Content::text(result.content)], + structured_content: None, + is_error: Some(result.is_error), + meta: None, + }) + } + + fn get_tool(&self, name: &str) -> Option { + self.catalog.iter().find(|t| t.name == name).cloned() + } +} + +/// Surface an [`ApprovalRequest`] to the MCP client as an elicitation. +/// +/// We use a form-style elicitation with a single required boolean +/// (`approved`) so the client can render a simple yes/no prompt. The +/// human-readable details live in the `message` field — schemas can +/// only carry primitive properties (string/number/boolean/enum-string), +/// not arbitrary structured payloads. +/// +/// Returns [`ApprovalDecision::Approve`] only when the client returns +/// `action: Accept` AND `content.approved == true`. Decline, Cancel, +/// timeout, transport errors, malformed responses, schema build +/// failures, and missing `approved` field all map to Deny. +async fn elicit_approval( + peer: &rmcp::Peer, + req: ApprovalRequest, +) -> ApprovalDecision { + let Ok(schema) = ElicitationSchema::builder() + .required_bool("approved") + .build() + else { + tracing::error!("failed to build elicitation schema; denying by default"); + return ApprovalDecision::Deny; + }; + + let message = format!( + "Astrid is requesting approval:\n\n\ + Action: {}\n\ + Resource: {}\n\ + Reason: {}\n\n\ + Allow this operation?", + req.action, req.resource, req.reason, + ); + + let params = CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message, + requested_schema: schema, + }; + + crate::debug::log(format_args!( + "elicit_approval: issuing elicitation, timeout={APPROVAL_TIMEOUT:?}" + )); + let res = peer + .create_elicitation_with_timeout(params, Some(APPROVAL_TIMEOUT)) + .await; + crate::debug::log(format_args!( + "elicit_approval: elicitation returned: {}", + match &res { + Ok(r) => format!("Ok action={:?}", r.action), + Err(e) => format!("Err {e}"), + } + )); + + match res + { + Ok(result) => match result.action { + ElicitationAction::Accept => { + let approved = result + .content + .as_ref() + .and_then(|c| c.get("approved")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if approved { + ApprovalDecision::Approve + } else { + ApprovalDecision::Deny + } + }, + ElicitationAction::Decline | ElicitationAction::Cancel => ApprovalDecision::Deny, + }, + Err(err) => { + tracing::warn!( + error = %err, + action = %req.action, + "elicitation failed; denying approval by default" + ); + ApprovalDecision::Deny + }, + } +} diff --git a/crates/astrid-mcp-bridge/tests/integration.rs b/crates/astrid-mcp-bridge/tests/integration.rs new file mode 100644 index 000000000..ddc002268 --- /dev/null +++ b/crates/astrid-mcp-bridge/tests/integration.rs @@ -0,0 +1,165 @@ +//! End-to-end tests: spawn `astrid mcp bridge` as a subprocess and +//! drive it with an in-test rmcp client over its stdio. +//! +//! All tests below are `#[ignore]` because the bridge now connects to +//! the live daemon at startup (Task 6+) — there is no useful offline +//! mode left to assert against. Run with: +//! +//! cargo test -p astrid-mcp-bridge --test integration -- --ignored +//! +//! The `astrid` binary lives in `astrid-cli`, not this crate, so we +//! locate it via the workspace target directory derived from +//! `CARGO_MANIFEST_DIR`. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use rmcp::ServiceExt; +use rmcp::model::{CallToolRequestParams, RawContent}; +use tokio::process::Command; + +/// Locate the `astrid` binary in the workspace target directory. +fn find_astrid_binary() -> PathBuf { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest + .ancestors() + .find(|p| p.join("Cargo.toml").is_file() && p.join("target").is_dir()) + .expect("workspace root with target/ not found"); + let target = workspace_root.join("target"); + + let exe = if cfg!(windows) { "astrid.exe" } else { "astrid" }; + for profile in ["debug", "release"] { + let candidate = target.join(profile).join(exe); + if candidate.is_file() { + return candidate; + } + } + panic!( + "could not locate `astrid` binary under {}/{{debug,release}}. \ + Run `cargo build -p astrid` first.", + target.display() + ); +} + +#[tokio::test] +#[ignore = "requires running astrid daemon"] +async fn initialize_returns_server_info() -> anyhow::Result<()> { + let bin = find_astrid_binary(); + assert!( + Path::new(&bin).is_file(), + "astrid binary not found at {}", + bin.display() + ); + + let mut cmd = Command::new(&bin); + cmd.args(["mcp", "bridge"]) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .stderr(Stdio::inherit()); + + let transport = rmcp::transport::child_process::TokioChildProcess::new(cmd)?; + let service = ().serve(transport).await?; + + let server_info = service + .peer_info() + .expect("peer_info available after initialize"); + assert_eq!(server_info.server_info.name, "astrid-mcp-bridge"); + + let _ = service.cancel().await?; + Ok(()) +} + +#[tokio::test] +#[ignore = "requires running astrid daemon"] +async fn bridge_connects_to_daemon() -> anyhow::Result<()> { + let _conn = astrid_mcp_bridge::daemon::DaemonConnection::connect("default").await?; + Ok(()) +} + +#[tokio::test] +#[ignore = "requires running astrid daemon with capsule-system + capsule-shell installed"] +async fn live_tools_list_includes_shell() -> anyhow::Result<()> { + let bin = find_astrid_binary(); + + let mut cmd = Command::new(&bin); + cmd.args(["mcp", "bridge"]) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .stderr(Stdio::inherit()); + + let transport = rmcp::transport::child_process::TokioChildProcess::new(cmd)?; + let service = ().serve(transport).await?; + + let tools = service.list_tools(Option::default()).await?; + let names: Vec<&str> = tools.tools.iter().map(|t| t.name.as_ref()).collect(); + eprintln!("catalog: {names:?}"); + + assert!( + names.iter().any(|n| n.starts_with("shell.")), + "expected at least one shell.* tool; got: {names:?}" + ); + + let _ = service.cancel().await?; + Ok(()) +} + +#[tokio::test] +#[ignore = "requires running astrid daemon with capsule-shell installed"] +async fn live_call_shell_tool_denies_on_unhandled_elicitation() -> anyhow::Result<()> { + // After Task 8, the bridge no longer auto-approves. Approval + // requests from the kernel are forwarded to the MCP client as + // elicitations. The test client below (`()`) does NOT implement + // an elicitation handler, so rmcp will reply with an error / + // capability-not-supported — which the bridge maps to Deny. + // + // We expect the tool call to surface as `is_error == true`. The + // capsule may either fail outright or return a refusal payload; + // either way it's NOT the success path of Task 7. + let bin = find_astrid_binary(); + + let mut cmd = Command::new(&bin); + cmd.args(["mcp", "bridge"]) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .stderr(Stdio::inherit()); + + let transport = rmcp::transport::child_process::TokioChildProcess::new(cmd)?; + let service = ().serve(transport).await?; + + // Build argument map: { "command": "echo hello-astrid" } + let mut arguments = serde_json::Map::new(); + arguments.insert( + "command".into(), + serde_json::Value::String("echo hello-astrid".into()), + ); + + let result = service + .call_tool(CallToolRequestParams { + meta: None, + name: "shell.run_shell_command".into(), + arguments: Some(arguments), + task: None, + }) + .await?; + + let combined: String = result + .content + .iter() + .filter_map(|c| match &c.raw { + RawContent::Text(t) => Some(t.text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + + assert_eq!( + result.is_error, + Some(true), + "expected denial-induced error after Task 8 (no auto-approve); \ + got is_error={:?}, content={combined:?}", + result.is_error, + ); + + let _ = service.cancel().await?; + Ok(()) +} diff --git a/crates/astrid-types/src/ipc.rs b/crates/astrid-types/src/ipc.rs index 965c82a4c..73b29ba30 100644 --- a/crates/astrid-types/src/ipc.rs +++ b/crates/astrid-types/src/ipc.rs @@ -71,6 +71,23 @@ fn default_session_id() -> String { "default".into() } +/// Describes one tool an external client (e.g. an MCP bridge) can +/// invoke through the daemon. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolDescriptor { + /// Namespaced tool name exposed externally, e.g. + /// `shell.run_shell_command`. The part after the dot is the + /// internal tool_name used on the IPC bus. + pub name: String, + /// Human-readable description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema for the tool's input arguments. + pub input_schema: Value, + /// Which capsule hosts this tool (e.g. `astrid-capsule-shell`). + pub capsule: String, +} + /// Standardized cross-boundary payload schemas. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] @@ -174,6 +191,20 @@ pub enum IpcPayload { /// The call IDs of the tool invocations to cancel. call_ids: Vec, }, + /// External client requests the tool catalog. + ToolListRequest { + /// Correlation ID; echoed in the response. + request_id: String, + }, + + /// Response carrying the catalog of tools available to the + /// requesting principal. + ToolListResponse { + /// Matches the originating ToolListRequest's request_id. + request_id: String, + /// Tools available. + tools: Vec, + }, /// A capsule is requesting the user to select from a list of options. SelectionRequired { /// Opaque ID so the capsule can correlate the response. @@ -241,6 +272,8 @@ impl IpcPayload { | "tool_execute_request" | "tool_execute_result" | "tool_cancel_request" + | "tool_list_request" + | "tool_list_response" | "selection_required" | "elicit_request" | "elicit_response" @@ -448,7 +481,7 @@ mod tests { /// match arm *and* the representatives list below, this test fails. #[test] fn is_known_tag_covers_all_variants() { - const EXPECTED_VARIANT_COUNT: usize = 17; + const EXPECTED_VARIANT_COUNT: usize = 19; let representatives: Vec = vec![ IpcPayload::RawJson(serde_json::json!({"key": "val"})), @@ -516,6 +549,13 @@ mod tests { is_error: false, }, }, + IpcPayload::ToolListRequest { + request_id: String::new(), + }, + IpcPayload::ToolListResponse { + request_id: String::new(), + tools: vec![], + }, IpcPayload::SelectionRequired { request_id: String::new(), title: String::new(), @@ -745,4 +785,36 @@ mod tests { let payload = IpcPayload::from_json_value(data); assert!(matches!(payload, IpcPayload::UserInput { .. })); } + + #[test] + fn tool_list_request_round_trips() { + let p = IpcPayload::ToolListRequest { request_id: "req-1".into() }; + let json = serde_json::to_string(&p).unwrap(); + assert!(json.contains("\"type\":\"tool_list_request\"")); + assert!(json.contains("\"request_id\":\"req-1\"")); + let back: IpcPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(back, p); + } + + #[test] + fn tool_list_response_round_trips() { + let p = IpcPayload::ToolListResponse { + request_id: "req-1".into(), + tools: vec![ToolDescriptor { + name: "shell.run_shell_command".into(), + description: Some("Run a shell command".into()), + input_schema: serde_json::json!({"type": "object"}), + capsule: "astrid-capsule-shell".into(), + }], + }; + let json = serde_json::to_string(&p).unwrap(); + let back: IpcPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(back, p); + } + + #[test] + fn new_variants_are_known_tags() { + assert!(IpcPayload::is_known_tag("tool_list_request")); + assert!(IpcPayload::is_known_tag("tool_list_response")); + } } diff --git a/scripts/claude-astrid b/scripts/claude-astrid new file mode 100755 index 000000000..0593fb8d6 --- /dev/null +++ b/scripts/claude-astrid @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# claude-astrid: run `claude` with native tools disabled and the +# Astrid MCP bridge as the only tool provider. +# +# LIMITATIONS in v1: WebSearch, Glob, and Grep have no Astrid +# equivalent capsule yet, so they're disabled with no replacement. +# If Claude needs them, work around by spawning a shell command +# (shell.run_shell_command) until shim capsules ship. +# +set -euo pipefail + +# Ensure daemon is up +if ! astrid status >/dev/null 2>&1; then + echo "Starting Astrid daemon..." >&2 + astrid start + for _ in {1..30}; do + [[ -e "$HOME/.astrid/run/system.ready" ]] && break + sleep 0.2 + done +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec claude \ + --mcp-config "$SCRIPT_DIR/claude-astrid.mcp.json" \ + --disallowed-tools "Read,Write,Edit,Bash,WebFetch,WebSearch,Glob,Grep,NotebookEdit" \ + "$@" diff --git a/scripts/claude-astrid.mcp.json b/scripts/claude-astrid.mcp.json new file mode 100644 index 000000000..66443a5e7 --- /dev/null +++ b/scripts/claude-astrid.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "astrid": { + "command": "astrid", + "args": ["mcp", "bridge"] + } + } +} diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh new file mode 100755 index 000000000..abf4bf63d --- /dev/null +++ b/scripts/e2e-smoke.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAUDE_ASTRID="$SCRIPT_DIR/claude-astrid" + +pass=0 +fail=0 +report() { + if [[ $1 -eq 0 ]]; then + pass=$((pass + 1)) + echo " PASS: $2" + else + fail=$((fail + 1)) + echo " FAIL: $2" + fi +} + +echo "=== T1: shell tool roundtrip ===" +out=$("$CLAUDE_ASTRID" -p 'Use the shell.run_shell_command tool to run "echo hello-from-astrid". Print only the tool output, nothing else.' 2>&1) || true +echo "$out" | grep -q "hello-from-astrid" +report $? "shell tool returns expected output" + +echo "=== T2: astrid headless mode still works ===" +out=$(astrid -p 'echo' 2>&1) || true +[[ -n "$out" ]] +report $? "astrid -p still runs (any output)" + +echo "" +echo "Total: $((pass + fail)). Pass: $pass. Fail: $fail." +exit $fail