mod elicit;
mod grant;
mod ingress;
#[cfg(unix)]
mod parent_death;
mod server;
mod session_guard;
mod watch;
use std::process::ExitCode;
use std::sync::Arc;
use anyhow::{Context, Result};
use rmcp::ServiceExt;
use tokio::sync::Mutex;
use tracing::info;
use uuid::Uuid;
use server::AstridMcpServer;
fn require_authenticated_unless_anonymous(
caller: &astrid_core::PrincipalId,
authenticated: bool,
) -> Result<()> {
if authenticated || *caller == astrid_core::PrincipalId::anonymous() {
return Ok(());
}
anyhow::bail!(
"could not authenticate as principal '{caller}' for `astrid mcp serve`: \
no keypair found (keys/{caller}.key), so the daemon would bind this \
connection to the no-capability `anonymous` identity. Every tool call \
would then fail the ingress-trust and capability checks and appear to \
hang. Refusing to serve the MCP bridge as `anonymous`.\n\n\
Fix: run `astrid agent create {caller}` to mint its keypair (or \
back-fill an existing keyless principal's), then retry. To serve \
unauthenticated on purpose, pass `--principal anonymous` before `mcp serve`."
);
}
pub(crate) async fn serve(principal: Option<&str>) -> Result<ExitCode> {
let caller = match principal {
Some(p) => astrid_core::PrincipalId::new(p)
.with_context(|| format!("invalid principal for `astrid mcp serve`: {p}"))?,
None => crate::principal::current(),
};
crate::commands::daemon::ensure_daemon_quiet("mcp-serve")
.await
.context("failed to ensure Astrid daemon for `astrid mcp serve`")?;
let session = astrid_core::SessionId::from_uuid(Uuid::new_v4());
let client = crate::socket_client::connect_for_workspace(session, caller.clone(), None)
.await
.context("Failed to connect to the Astrid daemon socket")?;
require_authenticated_unless_anonymous(&caller, client.is_authenticated())?;
info!(
principal = %caller,
"astrid mcp serve: uplink established, starting MCP stdio transport"
);
tokio::spawn(session_guard::run(caller.clone()));
let server = AstridMcpServer::new(Arc::new(Mutex::new(client)), caller.clone());
let running = server
.serve(rmcp::transport::stdio())
.await
.context("Failed to start MCP stdio transport")?;
let peer = running.peer().clone();
tokio::spawn(watch::run(peer, caller.to_string()));
#[cfg(unix)]
let parent_death_fut = parent_death::wait_for_parent_death();
#[cfg(not(unix))]
let parent_death_fut = std::future::pending::<()>();
tokio::select! {
biased;
() = parent_death_fut => {
info!("astrid mcp serve: launching session ended (reparented); closing MCP bridge");
Ok(ExitCode::SUCCESS)
}
quit = running.waiting() => {
let quit_reason = quit.context("MCP stdio transport terminated abnormally")?;
info!(?quit_reason, "astrid mcp serve: MCP transport closed");
Ok(ExitCode::SUCCESS)
}
}
}
#[cfg(test)]
mod fail_loud_tests {
use super::require_authenticated_unless_anonymous;
use astrid_core::PrincipalId;
#[test]
fn authenticated_principal_is_allowed() {
let p = PrincipalId::new("claude-code").unwrap();
assert!(require_authenticated_unless_anonymous(&p, true).is_ok());
}
#[test]
fn unauthenticated_non_anonymous_is_refused_with_actionable_message() {
let p = PrincipalId::new("claude-code").unwrap();
let err = require_authenticated_unless_anonymous(&p, false)
.expect_err("an unauthenticated non-anonymous principal must be refused");
let msg = err.to_string();
assert!(
msg.contains("anonymous"),
"explains the anonymous fallback: {msg}"
);
assert!(msg.contains("claude-code"), "names the principal: {msg}");
assert!(msg.contains("agent create"), "gives the fix: {msg}");
}
#[test]
fn explicit_anonymous_is_allowed_even_unauthenticated() {
assert!(require_authenticated_unless_anonymous(&PrincipalId::anonymous(), false).is_ok());
}
}