From de35140ab4de9f21aba42e02f0bd07dc7afe39b6 Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 16:23:02 +0530 Subject: [PATCH 1/9] feat(types): add ToolDescriptor + ToolListRequest/Response variants Carrier types for the external tool API. The MCP bridge sends ToolListRequest to enumerate, capsule-cli forwards it on the bus, and a future aggregator (or the bridge itself, composing list_capsules + inspect_capsule) responds with ToolListResponse. --- crates/astrid-types/src/ipc.rs | 74 +++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) 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")); + } } From f13a4df9feb038b6edfacc2705ec0ee17cc47beb Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 16:34:10 +0530 Subject: [PATCH 2/9] feat: scaffold astrid-mcp-bridge crate + CLI subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts SocketClient from astrid-cli into a new astrid-ipc-client crate so the bridge can reuse it. Adds an empty astrid-mcp-bridge lib + the 'astrid mcp bridge' subcommand stub that prints a not-yet-implemented error. Real protocol handling in subsequent commits. SocketClient::send_input now takes the caller principal as a parameter (CLI resolves it via crate::context::active_agent; other embedders pass their own) — necessary to decouple astrid-ipc-client from CLI-local state. Also bumps rmcp workspace features to include server + macros (per the rmcp spike findings) and adds schemars to workspace dependencies. --- Cargo.toml | 7 +++- crates/astrid-cli/Cargo.toml | 2 ++ crates/astrid-cli/src/admin_client.rs | 2 +- crates/astrid-cli/src/bootstrap.rs | 2 +- crates/astrid-cli/src/cli.rs | 9 ++++- crates/astrid-cli/src/commands/chat.rs | 5 +-- crates/astrid-cli/src/commands/daemon.rs | 5 +-- crates/astrid-cli/src/commands/doctor.rs | 2 +- crates/astrid-cli/src/commands/headless.rs | 6 ++-- crates/astrid-cli/src/commands/mcp.rs | 26 +++++++++++++++ crates/astrid-cli/src/commands/mod.rs | 1 + crates/astrid-cli/src/commands/ps.rs | 6 ++-- crates/astrid-cli/src/commands/restart.rs | 2 +- crates/astrid-cli/src/commands/who.rs | 6 ++-- crates/astrid-cli/src/dispatch.rs | 1 + crates/astrid-cli/src/main.rs | 2 -- crates/astrid-cli/src/tui/headless.rs | 7 ++-- crates/astrid-cli/src/tui/mod.rs | 24 ++++++++++---- crates/astrid-ipc-client/Cargo.toml | 20 +++++++++++ crates/astrid-ipc-client/src/lib.rs | 8 +++++ .../src/socket_client.rs | 28 ++++++++-------- crates/astrid-mcp-bridge/Cargo.toml | 29 ++++++++++++++++ crates/astrid-mcp-bridge/src/error.rs | 27 +++++++++++++++ crates/astrid-mcp-bridge/src/lib.rs | 33 +++++++++++++++++++ 24 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 crates/astrid-cli/src/commands/mcp.rs create mode 100644 crates/astrid-ipc-client/Cargo.toml create mode 100644 crates/astrid-ipc-client/src/lib.rs rename crates/{astrid-cli => astrid-ipc-client}/src/socket_client.rs (94%) create mode 100644 crates/astrid-mcp-bridge/Cargo.toml create mode 100644 crates/astrid-mcp-bridge/src/error.rs create mode 100644 crates/astrid-mcp-bridge/src/lib.rs 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..9f244e0ab --- /dev/null +++ b/crates/astrid-mcp-bridge/Cargo.toml @@ -0,0 +1,29 @@ +[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 } +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/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..5f7f540eb --- /dev/null +++ b/crates/astrid-mcp-bridge/src/lib.rs @@ -0,0 +1,33 @@ +//! 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 error; +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: accept MCP traffic on stdio, translate to/from +/// the Astrid daemon. Returns when stdin closes or a fatal error +/// occurs. +pub async fn run_stdio(config: BridgeConfig) -> Result<(), BridgeError> { + let _ = config; + Err(BridgeError::NotYetImplemented("run_stdio")) +} From 14f7a654ffa755cc597c5f8805766c8ef032047c Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 16:52:29 +0530 Subject: [PATCH 3/9] feat(mcp-bridge): respond to initialize + empty tools/list Minimal MCP server scaffold using rmcp 0.15 server-side stdio transport. Returns correct server_info; tool catalog empty for Task 4. Tool dispatch lands in Task 6. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/astrid-mcp-bridge/src/lib.rs | 18 ++++- crates/astrid-mcp-bridge/src/mcp.rs | 62 +++++++++++++++ crates/astrid-mcp-bridge/tests/integration.rs | 79 +++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 crates/astrid-mcp-bridge/src/mcp.rs create mode 100644 crates/astrid-mcp-bridge/tests/integration.rs diff --git a/crates/astrid-mcp-bridge/src/lib.rs b/crates/astrid-mcp-bridge/src/lib.rs index 5f7f540eb..d3ffc3e69 100644 --- a/crates/astrid-mcp-bridge/src/lib.rs +++ b/crates/astrid-mcp-bridge/src/lib.rs @@ -4,6 +4,8 @@ #![allow(clippy::missing_errors_doc)] pub mod error; +pub mod mcp; + pub use error::BridgeError; /// Configuration for a single bridge run. @@ -28,6 +30,20 @@ impl Default for BridgeConfig { /// the Astrid daemon. Returns when stdin closes or a fatal error /// occurs. pub async fn run_stdio(config: BridgeConfig) -> Result<(), BridgeError> { + use rmcp::{ServiceExt, transport::stdio}; + + // `config` is used by later tasks (tool dispatch). For Task 4 the + // server is stateless beyond the handler struct. let _ = config; - Err(BridgeError::NotYetImplemented("run_stdio")) + + let server = mcp::AstridMcpServer::new(); + 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..79444be71 --- /dev/null +++ b/crates/astrid-mcp-bridge/src/mcp.rs @@ -0,0 +1,62 @@ +//! MCP server-side implementation. Stateless for Task 4 — returns +//! correct `server_info` and an empty tool catalog. Tool dispatch +//! lands in Task 6+. + +use rmcp::{ + ServerHandler, + handler::server::tool::ToolRouter, + model::{Implementation, ServerCapabilities, ServerInfo}, + tool_handler, tool_router, +}; + +/// MCP server exposed by `astrid mcp bridge`. +/// +/// Holds a `ToolRouter` populated by `#[tool_router]`. For +/// Task 4 the router is empty; later tasks will register one tool +/// method per capsule-exposed tool. +#[derive(Clone)] +pub struct AstridMcpServer { + tool_router: ToolRouter, +} + +impl AstridMcpServer { + #[must_use] + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +impl Default for AstridMcpServer { + fn default() -> Self { + Self::new() + } +} + +// Tool methods are added inside this impl in later tasks. +#[tool_router] +impl AstridMcpServer { + // (empty for Task 4) +} + +#[tool_handler] +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() + } + } +} diff --git a/crates/astrid-mcp-bridge/tests/integration.rs b/crates/astrid-mcp-bridge/tests/integration.rs new file mode 100644 index 000000000..bb19d0a25 --- /dev/null +++ b/crates/astrid-mcp-bridge/tests/integration.rs @@ -0,0 +1,79 @@ +//! End-to-end test: spawn `astrid mcp bridge` as a subprocess, drive +//! it with an in-test rmcp client over its stdio, verify +//! `initialize` returns the right server info and `tools/list` is empty. +//! +//! The `astrid` binary lives in `astrid-cli`, not this crate, so we +//! locate it via the workspace target directory derived from +//! `CARGO_MANIFEST_DIR` and the executable's own path. We try (in +//! order) the current build profile's directory, then `debug`, then +//! `release` — whichever has a recent `astrid` binary. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use rmcp::ServiceExt; +use tokio::process::Command; + +/// Locate the `astrid` binary in the workspace target directory. +/// +/// `CARGO_MANIFEST_DIR` points at this crate; walk up to the workspace +/// root (containing `Cargo.toml` + `target/`) and probe `target/debug` +/// and `target/release`. +fn find_astrid_binary() -> PathBuf { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // crates/astrid-mcp-bridge -> walk up to workspace root. + 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"); + + // Prefer debug (test default), fall back to release. + 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] +async fn initialize_returns_server_info_and_empty_tools_list() -> 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::null()); + + 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 tools = service.list_tools(Option::default()).await?; + assert!( + tools.tools.is_empty(), + "expected empty catalog, got {:?}", + tools.tools + ); + + let _ = service.cancel().await?; + Ok(()) +} From b149de41f7d3380e3b881fa9bfba36f074f290de Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 16:56:43 +0530 Subject: [PATCH 4/9] feat(mcp-bridge): connect to daemon and send/recv IpcMessage New DaemonConnection wraps SocketClient with bridge-friendly methods: connect+handshake, build_message stamped with the connection's principal, send, recv. SocketClient already exposed send_message(IpcMessage), so no extension was needed. Adds an ignored integration test that verifies the bridge can complete the bearer-token handshake against a running daemon. --- crates/astrid-mcp-bridge/src/daemon.rs | 81 +++++++++++++++++++ crates/astrid-mcp-bridge/src/lib.rs | 1 + crates/astrid-mcp-bridge/tests/integration.rs | 7 ++ 3 files changed, 89 insertions(+) create mode 100644 crates/astrid-mcp-bridge/src/daemon.rs diff --git a/crates/astrid-mcp-bridge/src/daemon.rs b/crates/astrid-mcp-bridge/src/daemon.rs new file mode 100644 index 000000000..96d80315e --- /dev/null +++ b/crates/astrid-mcp-bridge/src/daemon.rs @@ -0,0 +1,81 @@ +//! 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 astrid_core::SessionId; +use astrid_ipc_client::SocketClient; +use astrid_types::ipc::{IpcMessage, IpcPayload}; +use uuid::Uuid; + +use crate::error::BridgeError; + +/// 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 + } +} diff --git a/crates/astrid-mcp-bridge/src/lib.rs b/crates/astrid-mcp-bridge/src/lib.rs index d3ffc3e69..30ef380b9 100644 --- a/crates/astrid-mcp-bridge/src/lib.rs +++ b/crates/astrid-mcp-bridge/src/lib.rs @@ -3,6 +3,7 @@ #![allow(clippy::missing_errors_doc)] +pub mod daemon; pub mod error; pub mod mcp; diff --git a/crates/astrid-mcp-bridge/tests/integration.rs b/crates/astrid-mcp-bridge/tests/integration.rs index bb19d0a25..1241a4f3e 100644 --- a/crates/astrid-mcp-bridge/tests/integration.rs +++ b/crates/astrid-mcp-bridge/tests/integration.rs @@ -77,3 +77,10 @@ async fn initialize_returns_server_info_and_empty_tools_list() -> anyhow::Result 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(()) +} From 1a1526a09ae41245dafbcdab261e226d8dfcffc9 Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 17:19:30 +0530 Subject: [PATCH 5/9] feat(mcp-bridge): populate tools/list from live capsule introspection The bridge connects to the daemon at startup, calls list_capsules + inspect_capsule per capsule, parses the manifests to extract tool names, and caches the resulting Tool[] for the session. tools/list returns the cached catalog. Tool dispatch in Task 7. Surprises observed: - ToolCallResult.content is double-JSON-encoded for capsules returning serde_json::to_string_pretty payloads, and single-encoded for plain String returns; unwrap_json_string_layers + unwrap_string_layer handle both. - inspect_capsule returns a plain-text blob "=== Capsule.toml ===\n... \n\n=== meta.json ===\n..." rather than structured JSON. Tool names live in the TOML's [subscribe] table or [[interceptor]] array as topics like tool.v1.execute., not in meta.json. - capsule-react subscribes to "tool.v1.execute.result" (the framework fanout topic) which our naive prefix-strip would mistake for a tool called "result"; filter explicitly. The #[tool_handler] macro injects its own list_tools/call_tool/get_tool that delegate to the (empty) tool_router, so we now implement ServerHandler by hand to return our daemon-built catalog. Call_tool currently returns method_not_found; Task 7 will wire it to DaemonConnection::call_tool_round_trip and undo the short.tool prefix. --- crates/astrid-mcp-bridge/Cargo.toml | 1 + crates/astrid-mcp-bridge/src/catalog.rs | 306 ++++++++++++++++++ crates/astrid-mcp-bridge/src/daemon.rs | 54 ++++ crates/astrid-mcp-bridge/src/lib.rs | 14 +- crates/astrid-mcp-bridge/src/mcp.rs | 97 ++++-- crates/astrid-mcp-bridge/tests/integration.rs | 61 ++-- 6 files changed, 474 insertions(+), 59 deletions(-) create mode 100644 crates/astrid-mcp-bridge/src/catalog.rs diff --git a/crates/astrid-mcp-bridge/Cargo.toml b/crates/astrid-mcp-bridge/Cargo.toml index 9f244e0ab..4663b659c 100644 --- a/crates/astrid-mcp-bridge/Cargo.toml +++ b/crates/astrid-mcp-bridge/Cargo.toml @@ -18,6 +18,7 @@ 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 } diff --git a/crates/astrid-mcp-bridge/src/catalog.rs b/crates/astrid-mcp-bridge/src/catalog.rs new file mode 100644 index 000000000..3e63e4222 --- /dev/null +++ b/crates/astrid-mcp-bridge/src/catalog.rs @@ -0,0 +1,306 @@ +//! 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) + .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, + ) + .await?; + tools.extend(parse_tools_from_inspect(&name, &inspect.content)); + } + Ok(tools) +} + +/// 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 index 96d80315e..7ebeb3b0b 100644 --- a/crates/astrid-mcp-bridge/src/daemon.rs +++ b/crates/astrid-mcp-bridge/src/daemon.rs @@ -3,9 +3,13 @@ //! 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; @@ -78,4 +82,54 @@ impl DaemonConnection { 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. + /// + /// # 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, + ) -> Result { + 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)?; + if let IpcPayload::ToolExecuteResult { + call_id: got, + result, + } = msg.payload + { + if got == call_id { + return Ok::(result); + } + } + // Ignore self-echoed requests and unrelated traffic. + } + }) + .await + .map_err(|_| BridgeError::ToolTimeout(deadline))? + } } diff --git a/crates/astrid-mcp-bridge/src/lib.rs b/crates/astrid-mcp-bridge/src/lib.rs index 30ef380b9..6de47e8be 100644 --- a/crates/astrid-mcp-bridge/src/lib.rs +++ b/crates/astrid-mcp-bridge/src/lib.rs @@ -3,6 +3,7 @@ #![allow(clippy::missing_errors_doc)] +pub mod catalog; pub mod daemon; pub mod error; pub mod mcp; @@ -27,17 +28,14 @@ impl Default for BridgeConfig { } } -/// Run the bridge: accept MCP traffic on stdio, translate to/from -/// the Astrid daemon. Returns when stdin closes or a fatal error -/// occurs. +/// 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}; - // `config` is used by later tasks (tool dispatch). For Task 4 the - // server is stateless beyond the handler struct. - let _ = config; - - let server = mcp::AstridMcpServer::new(); + let daemon = daemon::DaemonConnection::connect(&config.principal).await?; + let server = mcp::AstridMcpServer::new(daemon).await?; let service = server .serve(stdio()) .await diff --git a/crates/astrid-mcp-bridge/src/mcp.rs b/crates/astrid-mcp-bridge/src/mcp.rs index 79444be71..a39e22e5c 100644 --- a/crates/astrid-mcp-bridge/src/mcp.rs +++ b/crates/astrid-mcp-bridge/src/mcp.rs @@ -1,46 +1,60 @@ -//! MCP server-side implementation. Stateless for Task 4 — returns -//! correct `server_info` and an empty tool catalog. Tool dispatch -//! lands in Task 6+. +//! 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`. Tool dispatch +//! (`call_tool` -> capsule IPC) lands in Task 7. +//! +//! 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 will route through IPC, not the router. + +use std::sync::Arc; use rmcp::{ - ServerHandler, - handler::server::tool::ToolRouter, - model::{Implementation, ServerCapabilities, ServerInfo}, - tool_handler, tool_router, + ErrorData as McpError, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, + }, + service::RequestContext, }; +use tokio::sync::Mutex; + +use crate::daemon::DaemonConnection; +use crate::error::BridgeError; /// MCP server exposed by `astrid mcp bridge`. /// -/// Holds a `ToolRouter` populated by `#[tool_router]`. For -/// Task 4 the router is empty; later tasks will register one tool -/// method per capsule-exposed tool. +/// 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 { - tool_router: ToolRouter, + catalog: Arc>, + #[allow(dead_code)] // Used by Task 7's call_tool implementation. + daemon: Arc>, } impl AstridMcpServer { - #[must_use] - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -impl Default for AstridMcpServer { - fn default() -> Self { - Self::new() + /// 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)), + }) } } -// Tool methods are added inside this impl in later tasks. -#[tool_router] -impl AstridMcpServer { - // (empty for Task 4) -} - -#[tool_handler] impl ServerHandler for AstridMcpServer { fn get_info(&self) -> ServerInfo { ServerInfo { @@ -59,4 +73,29 @@ impl ServerHandler for AstridMcpServer { ..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 { + // Tool dispatch is Task 7. Until then, surface a clear error. + Err(McpError::method_not_found::()) + } + + fn get_tool(&self, name: &str) -> Option { + self.catalog.iter().find(|t| t.name == name).cloned() + } } diff --git a/crates/astrid-mcp-bridge/tests/integration.rs b/crates/astrid-mcp-bridge/tests/integration.rs index 1241a4f3e..71bd44bc3 100644 --- a/crates/astrid-mcp-bridge/tests/integration.rs +++ b/crates/astrid-mcp-bridge/tests/integration.rs @@ -1,12 +1,15 @@ -//! End-to-end test: spawn `astrid mcp bridge` as a subprocess, drive -//! it with an in-test rmcp client over its stdio, verify -//! `initialize` returns the right server info and `tools/list` is empty. +//! 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` and the executable's own path. We try (in -//! order) the current build profile's directory, then `debug`, then -//! `release` — whichever has a recent `astrid` binary. +//! `CARGO_MANIFEST_DIR`. use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -15,20 +18,14 @@ use rmcp::ServiceExt; use tokio::process::Command; /// Locate the `astrid` binary in the workspace target directory. -/// -/// `CARGO_MANIFEST_DIR` points at this crate; walk up to the workspace -/// root (containing `Cargo.toml` + `target/`) and probe `target/debug` -/// and `target/release`. fn find_astrid_binary() -> PathBuf { let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - // crates/astrid-mcp-bridge -> walk up to workspace root. 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"); - // Prefer debug (test default), fall back to release. let exe = if cfg!(windows) { "astrid.exe" } else { "astrid" }; for profile in ["debug", "release"] { let candidate = target.join(profile).join(exe); @@ -36,7 +33,6 @@ fn find_astrid_binary() -> PathBuf { return candidate; } } - panic!( "could not locate `astrid` binary under {}/{{debug,release}}. \ Run `cargo build -p astrid` first.", @@ -45,7 +41,8 @@ fn find_astrid_binary() -> PathBuf { } #[tokio::test] -async fn initialize_returns_server_info_and_empty_tools_list() -> anyhow::Result<()> { +#[ignore = "requires running astrid daemon"] +async fn initialize_returns_server_info() -> anyhow::Result<()> { let bin = find_astrid_binary(); assert!( Path::new(&bin).is_file(), @@ -57,7 +54,7 @@ async fn initialize_returns_server_info_and_empty_tools_list() -> anyhow::Result cmd.args(["mcp", "bridge"]) .stdout(Stdio::piped()) .stdin(Stdio::piped()) - .stderr(Stdio::null()); + .stderr(Stdio::inherit()); let transport = rmcp::transport::child_process::TokioChildProcess::new(cmd)?; let service = ().serve(transport).await?; @@ -67,13 +64,6 @@ async fn initialize_returns_server_info_and_empty_tools_list() -> anyhow::Result .expect("peer_info available after initialize"); assert_eq!(server_info.server_info.name, "astrid-mcp-bridge"); - let tools = service.list_tools(Option::default()).await?; - assert!( - tools.tools.is_empty(), - "expected empty catalog, got {:?}", - tools.tools - ); - let _ = service.cancel().await?; Ok(()) } @@ -84,3 +74,30 @@ 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(()) +} From 8a2306070bfe99673f2a10659b76a2b60100e7a3 Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 17:28:37 +0530 Subject: [PATCH 6/9] feat(mcp-bridge): translate MCP tools/call to Astrid ToolExecuteRequest The MCP server's call_tool now looks up the named tool in the cached catalog, strips the short-capsule prefix, and round-trips through the daemon. ToolCallResult content + is_error map directly to MCP CallToolResult. To keep tool calls from stalling, the round-trip loop also auto-approves any ApprovalRequired that arrives mid-call by sending back an ApprovalResponse on astrid.v1.approval.response.{request_id}. Claude Code is itself the gating UI, so deferring to it is safe; future tasks can route these as MCP elicitations instead. --- crates/astrid-mcp-bridge/src/daemon.rs | 35 +++++++--- crates/astrid-mcp-bridge/src/mcp.rs | 68 ++++++++++++++++--- crates/astrid-mcp-bridge/tests/integration.rs | 53 +++++++++++++++ 3 files changed, 140 insertions(+), 16 deletions(-) diff --git a/crates/astrid-mcp-bridge/src/daemon.rs b/crates/astrid-mcp-bridge/src/daemon.rs index 7ebeb3b0b..02cbd1a2d 100644 --- a/crates/astrid-mcp-bridge/src/daemon.rs +++ b/crates/astrid-mcp-bridge/src/daemon.rs @@ -92,6 +92,12 @@ impl DaemonConnection { /// `tool.v1.execute.*.result`), so we discriminate by payload /// variant and `call_id` match rather than by topic. /// + /// Auto-approves any [`IpcPayload::ApprovalRequired`] that arrives + /// mid-call: the MCP bridge runs under a trusted shell session + /// (Claude Code is itself the gating UI), and a pending approval + /// would otherwise stall the tool indefinitely. Future tasks can + /// route these to the MCP client as elicitations instead. + /// /// # Errors /// - [`BridgeError::DaemonDisconnected`] if the peer closes mid-call. /// - [`BridgeError::ToolTimeout`] if `deadline` elapses without a @@ -117,16 +123,29 @@ impl DaemonConnection { timeout(deadline, async { loop { let msg = self.recv().await?.ok_or(BridgeError::DaemonDisconnected)?; - if let IpcPayload::ToolExecuteResult { - call_id: got, - result, - } = msg.payload - { - if got == call_id { + match msg.payload { + IpcPayload::ToolExecuteResult { + call_id: got, + result, + } if got == call_id => { return Ok::(result); - } + }, + IpcPayload::ApprovalRequired { request_id, .. } => { + let topic = format!("astrid.v1.approval.response.{request_id}"); + let response = self.build_message( + &topic, + IpcPayload::ApprovalResponse { + request_id: request_id.clone(), + decision: "approve".to_string(), + reason: Some("mcp-bridge auto-approve".to_string()), + }, + ); + self.send(&response).await?; + }, + _ => { + // Ignore self-echoed requests and unrelated traffic. + }, } - // Ignore self-echoed requests and unrelated traffic. } }) .await diff --git a/crates/astrid-mcp-bridge/src/mcp.rs b/crates/astrid-mcp-bridge/src/mcp.rs index a39e22e5c..65f76bbaf 100644 --- a/crates/astrid-mcp-bridge/src/mcp.rs +++ b/crates/astrid-mcp-bridge/src/mcp.rs @@ -2,21 +2,26 @@ //! //! 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`. Tool dispatch -//! (`call_tool` -> capsule IPC) lands in Task 7. +//! 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 will route through IPC, not the router. +//! 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, Implementation, ListToolsResult, + CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, }, service::RequestContext, @@ -26,6 +31,11 @@ use tokio::sync::Mutex; use crate::daemon::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); + /// MCP server exposed by `astrid mcp bridge`. /// /// Cloneable (`rmcp` requires `Clone` on the handler): the daemon @@ -35,7 +45,6 @@ use crate::error::BridgeError; #[derive(Clone)] pub struct AstridMcpServer { catalog: Arc>, - #[allow(dead_code)] // Used by Task 7's call_tool implementation. daemon: Arc>, } @@ -88,11 +97,54 @@ impl ServerHandler for AstridMcpServer { async fn call_tool( &self, - _request: CallToolRequestParams, + request: CallToolRequestParams, _ctx: RequestContext, ) -> Result { - // Tool dispatch is Task 7. Until then, surface a clear error. - Err(McpError::method_not_found::()) + // 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) { + 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. + let result = { + let mut daemon = self.daemon.lock().await; + daemon + .call_tool_round_trip(internal_name, args, TOOL_CALL_TIMEOUT) + .await + } + .map_err(|e| McpError::internal_error(format!("{e}"), None))?; + + // 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 { diff --git a/crates/astrid-mcp-bridge/tests/integration.rs b/crates/astrid-mcp-bridge/tests/integration.rs index 71bd44bc3..515b2e98d 100644 --- a/crates/astrid-mcp-bridge/tests/integration.rs +++ b/crates/astrid-mcp-bridge/tests/integration.rs @@ -15,6 +15,7 @@ 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. @@ -101,3 +102,55 @@ async fn live_tools_list_includes_shell() -> anyhow::Result<()> { let _ = service.cancel().await?; Ok(()) } + +#[tokio::test] +#[ignore = "requires running astrid daemon with capsule-shell installed"] +async fn live_call_shell_tool() -> 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?; + + // 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?; + + // Content is Vec where Content is Annotated; + // pattern-match the RawContent::Text variant via Deref. + let combined: String = result + .content + .iter() + .filter_map(|c| match &c.raw { + RawContent::Text(t) => Some(t.text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + + assert!( + combined.contains("hello-astrid"), + "expected hello-astrid in output; got: {combined:?}; is_error={:?}", + result.is_error, + ); + + let _ = service.cancel().await?; + Ok(()) +} From 9884387d4abc0352c26ccb87abd5d4db5171634b Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 17:36:56 +0530 Subject: [PATCH 7/9] feat(mcp-bridge): route approval prompts through MCP elicitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Task 7 auto-approve stub with a real human-in-the-loop flow. ApprovalRequired messages mid-tool-call are now surfaced to the MCP client (Claude Code) as form-style elicitations with a single boolean `approved` field; the user's decision is converted back into an ApprovalResponse on the IPC bus. - daemon.rs: `call_tool_round_trip` now takes an `on_approval` callback (`Fn(ApprovalRequest) -> Future`) and routes the decision to the right wire string. - mcp.rs: closure issues `peer.create_elicitation_with_timeout` with a 120s deadline; Decline / Cancel / transport errors / missing `approved` field all map to Deny (fail-closed). - catalog.rs: introspection calls run pre-MCP-handshake, so they pass a `deny_during_catalog` policy with a WARN log — the introspection tools shouldn't ever trip the approval interceptor, and if one does we refuse rather than auto-grant capabilities the user never consented to. - integration.rs: `live_call_shell_tool` renamed to `live_call_shell_tool_denies_on_unhandled_elicitation` and now asserts `is_error == true` (the test rmcp client doesn't register an elicitation handler, so the bridge maps the resulting error to Deny — exactly what we want to verify). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/astrid-mcp-bridge/src/catalog.rs | 28 +++++- crates/astrid-mcp-bridge/src/daemon.rs | 77 +++++++++++++-- crates/astrid-mcp-bridge/src/mcp.rs | 96 ++++++++++++++++++- crates/astrid-mcp-bridge/tests/integration.rs | 21 ++-- 4 files changed, 201 insertions(+), 21 deletions(-) diff --git a/crates/astrid-mcp-bridge/src/catalog.rs b/crates/astrid-mcp-bridge/src/catalog.rs index 3e63e4222..d862b5831 100644 --- a/crates/astrid-mcp-bridge/src/catalog.rs +++ b/crates/astrid-mcp-bridge/src/catalog.rs @@ -44,7 +44,12 @@ const TOOL_RESULT_SUFFIX: &str = ".result"; /// `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) + .call_tool_round_trip( + "list_capsules", + serde_json::json!({}), + CATALOG_TIMEOUT, + deny_during_catalog, + ) .await?; let capsule_names = parse_capsule_names(&list.content); @@ -55,6 +60,7 @@ pub async fn build_catalog(daemon: &mut DaemonConnection) -> Result, B "inspect_capsule", serde_json::json!({ "name": &name }), CATALOG_TIMEOUT, + deny_during_catalog, ) .await?; tools.extend(parse_tools_from_inspect(&name, &inspect.content)); @@ -62,6 +68,26 @@ pub async fn build_catalog(daemon: &mut DaemonConnection) -> Result, B 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`. /// diff --git a/crates/astrid-mcp-bridge/src/daemon.rs b/crates/astrid-mcp-bridge/src/daemon.rs index 02cbd1a2d..7d3f7353e 100644 --- a/crates/astrid-mcp-bridge/src/daemon.rs +++ b/crates/astrid-mcp-bridge/src/daemon.rs @@ -14,6 +14,44 @@ 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 @@ -92,22 +130,30 @@ impl DaemonConnection { /// `tool.v1.execute.*.result`), so we discriminate by payload /// variant and `call_id` match rather than by topic. /// - /// Auto-approves any [`IpcPayload::ApprovalRequired`] that arrives - /// mid-call: the MCP bridge runs under a trusted shell session - /// (Claude Code is itself the gating UI), and a pending approval - /// would otherwise stall the tool indefinitely. Future tasks can - /// route these to the MCP client as elicitations instead. + /// 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( + pub async fn call_tool_round_trip( &mut self, tool_name: &str, args: serde_json::Value, deadline: Duration, - ) -> Result { + 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( @@ -130,14 +176,25 @@ impl DaemonConnection { } if got == call_id => { return Ok::(result); }, - IpcPayload::ApprovalRequired { request_id, .. } => { + 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: "approve".to_string(), - reason: Some("mcp-bridge auto-approve".to_string()), + decision: decision.as_wire().to_string(), + reason: Some("mcp-bridge: forwarded via elicitation".to_string()), }, ); self.send(&response).await?; diff --git a/crates/astrid-mcp-bridge/src/mcp.rs b/crates/astrid-mcp-bridge/src/mcp.rs index 65f76bbaf..b8bde829b 100644 --- a/crates/astrid-mcp-bridge/src/mcp.rs +++ b/crates/astrid-mcp-bridge/src/mcp.rs @@ -21,14 +21,15 @@ use std::time::Duration; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult, + CallToolRequestParams, CallToolResult, Content, CreateElicitationRequestParams, + ElicitationAction, ElicitationSchema, Implementation, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, }, service::RequestContext, }; use tokio::sync::Mutex; -use crate::daemon::DaemonConnection; +use crate::daemon::{ApprovalDecision, ApprovalRequest, DaemonConnection}; use crate::error::BridgeError; /// Per-call timeout for forwarded tool execution. Mirrors the default @@ -36,6 +37,11 @@ use crate::error::BridgeError; /// 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 @@ -98,7 +104,7 @@ impl ServerHandler for AstridMcpServer { async fn call_tool( &self, request: CallToolRequestParams, - _ctx: RequestContext, + ctx: RequestContext, ) -> Result { // 1. Validate the tool exists in our cached catalog. The // catalog exposes tools as `.` @@ -128,10 +134,22 @@ impl ServerHandler for AstridMcpServer { // 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(); let result = { let mut daemon = self.daemon.lock().await; daemon - .call_tool_round_trip(internal_name, args, TOOL_CALL_TIMEOUT) + .call_tool_round_trip(internal_name, args, TOOL_CALL_TIMEOUT, |req| { + let peer = peer.clone(); + async move { elicit_approval(&peer, req).await } + }) .await } .map_err(|e| McpError::internal_error(format!("{e}"), None))?; @@ -151,3 +169,73 @@ impl ServerHandler for AstridMcpServer { 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, + }; + + match peer + .create_elicitation_with_timeout(params, Some(APPROVAL_TIMEOUT)) + .await + { + 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 index 515b2e98d..ddc002268 100644 --- a/crates/astrid-mcp-bridge/tests/integration.rs +++ b/crates/astrid-mcp-bridge/tests/integration.rs @@ -105,7 +105,16 @@ async fn live_tools_list_includes_shell() -> anyhow::Result<()> { #[tokio::test] #[ignore = "requires running astrid daemon with capsule-shell installed"] -async fn live_call_shell_tool() -> anyhow::Result<()> { +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); @@ -133,8 +142,6 @@ async fn live_call_shell_tool() -> anyhow::Result<()> { }) .await?; - // Content is Vec where Content is Annotated; - // pattern-match the RawContent::Text variant via Deref. let combined: String = result .content .iter() @@ -145,9 +152,11 @@ async fn live_call_shell_tool() -> anyhow::Result<()> { .collect::>() .join(""); - assert!( - combined.contains("hello-astrid"), - "expected hello-astrid in output; got: {combined:?}; is_error={:?}", + 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, ); From 7c92f792e9e30906d67599b822f7dc2d2b6e5e88 Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 17:42:05 +0530 Subject: [PATCH 8/9] feat(scripts): claude-astrid wrapper + e2e smoke tests Wraps 'claude' with --mcp-config pointing at the Astrid MCP bridge and --disallowed-tools blocking native Read/Write/Edit/Bash/etc. Result: Claude Code can only act through Astrid capsules. Smoke test verifies the shell.run_shell_command roundtrip + that the existing astrid -p path didn't regress. --- scripts/claude-astrid | 27 +++++++++++++++++++++++++++ scripts/claude-astrid.mcp.json | 8 ++++++++ scripts/e2e-smoke.sh | 31 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100755 scripts/claude-astrid create mode 100644 scripts/claude-astrid.mcp.json create mode 100755 scripts/e2e-smoke.sh 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 From a8c4bb67f4615524b78ad6e0d4fea5e5fed3fad6 Mon Sep 17 00:00:00 2001 From: Jamie Steiner Date: Sun, 24 May 2026 19:39:01 +0530 Subject: [PATCH 9/9] feat(mcp-bridge): file-based debug log for the elicitation/dispatch path Claude Code swallows MCP subprocess stderr in normal mode, so the bridge's tracing calls go nowhere visible during debugging. Add a small synchronous file appender (default /tmp/astrid-bridge.log) gated on ASTRID_BRIDGE_DEBUG (defaults on; set to "0" to silence; override path with ASTRID_BRIDGE_LOG_PATH). Instrumented at the key decision points: run_stdio boot, call_tool entry, dispatch, approval callback invocation + decision, elicitation issue + return, and final round-trip success/error. Just enough to localize "the call timed out" failures to a specific stage without needing to attach a debugger. Used this to verify the Claude Code 2.1.121 elicitation flow works end-to-end: log shows the elicitation prompt is issued; the user just needs to find Claude's UI for it (it's an unfamiliar element since few MCP servers exercise elicitation today). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/astrid-mcp-bridge/src/debug.rs | 55 +++++++++++++++++++++++++++ crates/astrid-mcp-bridge/src/lib.rs | 4 ++ crates/astrid-mcp-bridge/src/mcp.rs | 42 ++++++++++++++++++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 crates/astrid-mcp-bridge/src/debug.rs 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/lib.rs b/crates/astrid-mcp-bridge/src/lib.rs index 6de47e8be..3dcaaf833 100644 --- a/crates/astrid-mcp-bridge/src/lib.rs +++ b/crates/astrid-mcp-bridge/src/lib.rs @@ -5,6 +5,7 @@ pub mod catalog; pub mod daemon; +pub mod debug; pub mod error; pub mod mcp; @@ -34,8 +35,11 @@ impl Default for BridgeConfig { 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 diff --git a/crates/astrid-mcp-bridge/src/mcp.rs b/crates/astrid-mcp-bridge/src/mcp.rs index b8bde829b..017522387 100644 --- a/crates/astrid-mcp-bridge/src/mcp.rs +++ b/crates/astrid-mcp-bridge/src/mcp.rs @@ -106,12 +106,14 @@ impl ServerHandler for AstridMcpServer { 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, @@ -143,16 +145,36 @@ impl ServerHandler for AstridMcpServer { // 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 { elicit_approval(&peer, req).await } + 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| McpError::internal_error(format!("{e}"), None))?; + .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 @@ -209,9 +231,21 @@ async fn elicit_approval( requested_schema: schema, }; - match peer + crate::debug::log(format_args!( + "elicit_approval: issuing elicitation, timeout={APPROVAL_TIMEOUT:?}" + )); + let res = peer .create_elicitation_with_timeout(params, Some(APPROVAL_TIMEOUT)) - .await + .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 => {