use clap::Parser;
use m1nd_mcp::cli::Cli;
use m1nd_mcp::instance_registry::spawn_heartbeat;
use m1nd_mcp::server::{McpConfig, McpServer};
use std::path::PathBuf;
#[cfg(unix)]
fn ensure_bwrap_compat_wrapper() {
if let Ok(home) = std::env::var("HOME") {
let bwrap_path = std::path::PathBuf::from(home).join(".local/bin/bwrap");
if !bwrap_path.exists() {
let wrapper = r#"#!/bin/bash
args=()
skip_next=0
for arg in "$@"; do
if [ "$skip_next" -eq 1 ]; then
skip_next=0
continue
fi
if [ "$arg" = "--argv0" ]; then
skip_next=1
continue
fi
args+=("$arg")
done
exec /usr/bin/bwrap "${args[@]}"
"#;
if let Some(parent) = bwrap_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&bwrap_path, wrapper).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = std::fs::metadata(&bwrap_path).map(|m| m.permissions()) {
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&bwrap_path, perms);
}
}
}
}
}
}
#[cfg(feature = "serve")]
fn resolve_attach_auto(cli: &Cli) -> Result<String, String> {
let runtime_root: PathBuf = if let Some(dir) = &cli.runtime_dir {
PathBuf::from(dir)
} else if let Some(graph) = &cli.graph {
PathBuf::from(graph)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
} else {
std::env::current_dir().map_err(|e| format!("cannot read current dir: {e}"))?
};
let registry_dir = cli.registry_dir.as_ref().map(PathBuf::from);
eprintln!(
"[m1nd-mcp][attach] auto-discovery: runtime_root={}",
runtime_root.display()
);
let url = m1nd_mcp::instance_registry::discover_serve_owner_base_url(
&runtime_root,
registry_dir.as_deref(),
)?;
eprintln!("[m1nd-mcp][attach] auto-discovery resolved owner: {url}");
Ok(url)
}
fn resolve_graph_source(path: PathBuf) -> PathBuf {
if path.as_os_str() == "temp" {
std::env::temp_dir().join(format!("m1nd-graph-{}.snapshot", std::process::id()))
} else {
path
}
}
fn anchor_persist_target(path: PathBuf, runtime_dir: Option<&std::path::Path>) -> PathBuf {
match runtime_dir {
Some(root) if path.is_relative() => root.join(path),
_ => path,
}
}
fn load_config_from_cli(cli: &Cli) -> McpConfig {
let force_read_only = cli.read_only
|| std::env::var("M1ND_READ_ONLY")
.map(|v| v != "0" && v != "false" && !v.is_empty())
.unwrap_or(false);
if let Some(ref path) = cli.config {
if let Ok(contents) = std::fs::read_to_string(path) {
if let Ok(mut config) = serde_json::from_str::<McpConfig>(&contents) {
eprintln!("[m1nd-mcp] Config loaded from {}", path);
config.read_only = config.read_only || force_read_only;
return config;
}
}
}
let runtime_dir = cli
.runtime_dir
.as_ref()
.map(PathBuf::from)
.or_else(|| std::env::var("M1ND_RUNTIME_DIR").ok().map(PathBuf::from));
let graph_source = cli
.graph
.as_ref()
.map(PathBuf::from)
.or_else(|| std::env::var("M1ND_GRAPH_SOURCE").ok().map(PathBuf::from))
.or_else(|| std::env::var("GRAPH_SNAPSHOT_PATH").ok().map(PathBuf::from))
.map(resolve_graph_source)
.unwrap_or_else(|| PathBuf::from("./graph_snapshot.json"));
let graph_source = anchor_persist_target(graph_source, runtime_dir.as_deref());
let plasticity_state = cli
.plasticity
.as_ref()
.map(PathBuf::from)
.or_else(|| {
std::env::var("M1ND_PLASTICITY_STATE")
.ok()
.map(PathBuf::from)
})
.or_else(|| {
std::env::var("PLASTICITY_STATE_PATH")
.ok()
.map(PathBuf::from)
})
.unwrap_or_else(|| PathBuf::from("./plasticity_state.json"));
let plasticity_state = anchor_persist_target(plasticity_state, runtime_dir.as_deref());
let registry_dir = cli
.registry_dir
.as_ref()
.map(PathBuf::from)
.or_else(|| std::env::var("M1ND_REGISTRY_DIR").ok().map(PathBuf::from));
let xlr_enabled = std::env::var("M1ND_XLR_ENABLED")
.map(|v| v != "0" && v != "false")
.unwrap_or(true);
let domain = match cli.domain.as_str() {
"code" | "music" | "memory" | "generic" => Some(cli.domain.clone()),
_ => None,
};
McpConfig {
graph_source,
plasticity_state,
runtime_dir,
registry_dir,
xlr_enabled,
domain,
read_only: force_read_only,
..McpConfig::default()
}
}
fn run_inbox_sweep(config: McpConfig, no_distribute: bool) {
use std::collections::BTreeMap;
use std::path::PathBuf;
let server = match McpServer::new(config) {
Ok(s) => s,
Err(e) => {
eprintln!("[m1nd-mcp][inbox_sweep] failed to boot session: {e}");
std::process::exit(1);
}
};
let state = server.into_session_state();
let runtime_root = state.runtime_root.clone();
let worktree_base = state
.project_root_display()
.as_deref()
.map(m1nd_mcp::mailbox::project_basename)
.unwrap_or_default();
let mut roots: Vec<String> = Vec::new();
if let Some(bound) = state.project_root_display() {
roots.push(bound);
}
let registry = m1nd_mcp::project_brains::ProjectBrainRegistry::new(
runtime_root.join(m1nd_mcp::project_brains::PROJECT_BRAINS_DIR),
None,
);
for (_key, facts, _dir) in registry.disk_roster() {
roots.push(facts.project_root);
}
let (known, mut boxes) = m1nd_mcp::mailbox::boxes_from_roots(&roots, &worktree_base);
boxes.push(m1nd_mcp::mailbox::KnownBox {
label: "medulla".into(),
path: m1nd_mcp::mailbox::medulla_box_path(&runtime_root),
reachable: true,
});
let spool = m1nd_mcp::mailbox::spool_path_for_runtime(&runtime_root);
let foreign: std::collections::BTreeSet<String> =
["context7", "browseros", "playwright", "semgrep"]
.iter()
.map(|s| s.to_string())
.collect();
let distribution = if no_distribute {
None
} else {
match m1nd_mcp::mailbox::distribute(&spool, &runtime_root, &worktree_base, &known) {
Ok(r) => Some(r),
Err(e) => {
eprintln!("[m1nd-mcp][inbox_sweep] distribution failed: {e}");
std::process::exit(1);
}
}
};
let (_known2, mut boxes2) = m1nd_mcp::mailbox::boxes_from_roots(&roots, &worktree_base);
boxes2.push(m1nd_mcp::mailbox::KnownBox {
label: "medulla".into(),
path: m1nd_mcp::mailbox::medulla_box_path(&runtime_root),
reachable: true,
});
let _ = boxes;
let sweep = match m1nd_mcp::mailbox::inbox_sweep(&spool, &boxes2, &foreign) {
Ok(s) => s,
Err(e) => {
eprintln!("[m1nd-mcp][inbox_sweep] sweep failed: {e}");
std::process::exit(1);
}
};
let out = serde_json::json!({
"schema": "m1nd-inbox-sweep-v0",
"spool_path": spool.to_string_lossy(),
"distribution": distribution.map(|d| serde_json::json!({
"spool_total": d.spool_total,
"appended": d.appended,
"to_project": d.to_project,
"to_medulla": d.to_medulla,
"pending": d.pending,
})),
"total": sweep.total,
"open": sweep.open,
"misdelivery": sweep.misdelivery,
"unreachable": sweep.unreachable,
"letters": sweep.letters,
});
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
let _: BTreeMap<String, PathBuf> = known; }
fn run_medulla_migrate(
config: McpConfig,
mode: m1nd_mcp::cli::MedullaMigrateMode,
migrate_project_root: Option<String>,
) {
use m1nd_mcp::cli::MedullaMigrateMode;
use m1nd_mcp::medulla_migration::MedullaMigration;
use m1nd_mcp::project_brains::{ProjectBrainRegistry, PROJECT_BRAINS_DIR};
let server = match McpServer::new(config) {
Ok(s) => s,
Err(e) => {
eprintln!("[m1nd-mcp][medulla_migrate] failed to boot session: {e}");
std::process::exit(1);
}
};
let state = server.into_session_state();
let runtime_root = state.runtime_root.clone();
let medulla_dir = runtime_root.join("agent-memory");
let ingest_roots_path = runtime_root.join("ingest_roots.json");
let (project_origin, destination_source) = match (&migrate_project_root, mode) {
(Some(root), _) => (root.clone(), "explicit"),
(None, MedullaMigrateMode::Apply | MedullaMigrateMode::Rollback) => {
eprintln!(
"[m1nd-mcp][medulla_migrate] {} requires --migrate-project-root <repo>: the \
destination brain must be named explicitly, never derived from the ambient \
session binding (field bug 2026-07-05)",
match mode {
MedullaMigrateMode::Apply => "apply",
MedullaMigrateMode::Rollback => "rollback",
MedullaMigrateMode::Plan => unreachable!(),
}
);
std::process::exit(2);
}
(None, MedullaMigrateMode::Plan) => {
eprintln!(
"[m1nd-mcp][medulla_migrate] WARNING: no --migrate-project-root given; plan \
falls back to the ambient session binding, which is UNSAFE as a destination \
(field bug 2026-07-05). Pass --migrate-project-root <repo> to target a brain \
explicitly."
);
(
state.project_root_display().unwrap_or_default(),
"ambient-binding (unsafe — pass --migrate-project-root)",
)
}
};
let registry = ProjectBrainRegistry::new(runtime_root.join(PROJECT_BRAINS_DIR), None);
let project_dir = registry
.store_dir_for(&ProjectBrainRegistry::canonical_key(&project_origin))
.join("agent-memory");
let mut mig = MedullaMigration::new(
&medulla_dir,
&project_dir,
&ingest_roots_path,
project_origin.clone(),
);
if let Ok(p) = std::env::var("M1ND_MEDULLA_GUARD_PORT") {
if let Ok(port) = p.parse::<u16>() {
mig = mig.with_owner_guard_port(port);
}
}
let (mode_str, payload) = match mode {
MedullaMigrateMode::Plan => match mig.plan() {
Ok(plan) => ("plan", serde_json::to_value(&plan).unwrap_or_default()),
Err(e) => {
eprintln!("[m1nd-mcp][medulla_migrate] plan failed: {e}");
std::process::exit(1);
}
},
MedullaMigrateMode::Apply => match mig.apply() {
Ok(receipt) => {
if let Err(e) = registry.ensure_registered(&project_origin) {
eprintln!(
"[m1nd-mcp][medulla_migrate] apply moved the memories but FAILED to \
register the destination brain ({e}); the store may be unmountable — \
resolve this before relying on the migrated memories"
);
std::process::exit(1);
}
("apply", serde_json::to_value(&receipt).unwrap_or_default())
}
Err(e) => {
eprintln!("[m1nd-mcp][medulla_migrate] apply failed: {e}");
std::process::exit(1);
}
},
MedullaMigrateMode::Rollback => {
let backup = match most_recent_backup(&medulla_dir) {
Some(b) => b,
None => {
eprintln!(
"[m1nd-mcp][medulla_migrate] rollback: no backup found under {}",
medulla_dir.display()
);
std::process::exit(1);
}
};
match mig.rollback(&backup.to_string_lossy(), &[]) {
Ok(removed) => (
"rollback",
serde_json::json!({
"restored_from": backup.to_string_lossy(),
"removed_from_project": removed,
}),
),
Err(e) => {
eprintln!("[m1nd-mcp][medulla_migrate] rollback failed: {e}");
std::process::exit(1);
}
}
}
};
let out = serde_json::json!({
"schema": "m1nd-medulla-migrate-v0",
"mode": mode_str,
"medulla_dir": medulla_dir.to_string_lossy(),
"project_dir": project_dir.to_string_lossy(),
"project_origin": project_origin,
"destination_source": destination_source,
"plan": payload,
});
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
}
fn most_recent_backup(medulla_dir: &std::path::Path) -> Option<PathBuf> {
const PREFIX: &str = ".m5a-backup-";
std::fs::read_dir(medulla_dir)
.ok()?
.flatten()
.filter_map(|e| {
let path = e.path();
let name = path.file_name()?.to_str()?.to_string();
let suffix = name.strip_prefix(PREFIX)?;
let stamp: u128 = suffix.parse().ok()?;
path.is_dir().then_some((stamp, path))
})
.max_by_key(|(stamp, _)| *stamp)
.map(|(_, path)| path)
}
async fn run_stdio_server(config: McpConfig, event_log: Option<String>, no_gui: bool, _port: u16) {
if event_log.is_some() {
eprintln!(
"[m1nd-mcp] NOTE: --event-log in stdio-only mode writes events for external consumers."
);
eprintln!(
"[m1nd-mcp] For cross-process SSE, use --serve --stdio --event-log <path>."
);
}
#[cfg(feature = "serve")]
let _gui_handle: Option<tokio::task::JoinHandle<()>> = if !no_gui {
eprintln!(
"[m1nd-mcp] Auto GUI disabled in stdio mode while multi-instance runtime leases are active."
);
eprintln!(
"[m1nd-mcp] Use `m1nd-mcp --serve --stdio` when you want one shared HTTP + stdio instance."
);
None
} else {
None
};
#[cfg(not(feature = "serve"))]
let _ = (no_gui, _port);
let mut server = match McpServer::new(config) {
Ok(s) => s,
Err(e) => {
eprintln!("[m1nd-mcp] Failed to create server: {}", e);
std::process::exit(1);
}
};
if let Err(e) = server.start() {
eprintln!("[m1nd-mcp] Failed to start server: {}", e);
std::process::exit(1);
}
let _heartbeat = spawn_heartbeat(server.instance_handle());
let serve_handle = tokio::task::spawn_blocking(move || {
let result = server.serve();
let _ = server.shutdown();
result
});
tokio::select! {
_ = tokio::signal::ctrl_c() => {
eprintln!("[m1nd-mcp] SIGINT received.");
}
result = serve_handle => {
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => eprintln!("[m1nd-mcp] Server error: {}", e),
Err(e) => eprintln!("[m1nd-mcp] Task error: {}", e),
}
}
}
}
fn strict_version_verdict(
strict: bool,
running_version: &str,
running_sha: &str,
expected_version: Option<&str>,
expected_sha: Option<&str>,
) -> Option<String> {
if !strict {
return None;
}
let version_mismatch = expected_version
.map(|expected| expected.trim() != running_version)
.unwrap_or(false);
let sha_mismatch = expected_sha
.map(|expected| expected.trim() != running_sha)
.unwrap_or(false);
if version_mismatch || sha_mismatch {
Some(format!(
"[m1nd-mcp] STRICT VERSION REFUSAL: running {running_version} ({running_sha}) but expected version={} sha={} (M1ND_STRICT_VERSION=1). Refusing to start against a mismatched binary.",
expected_version.unwrap_or("<unset>"),
expected_sha.unwrap_or("<unset>"),
))
} else {
None
}
}
fn enforce_strict_version() {
let strict = std::env::var("M1ND_STRICT_VERSION")
.map(|v| v != "0" && v != "false" && !v.trim().is_empty())
.unwrap_or(false);
let expected_version = std::env::var("M1ND_EXPECTED_VERSION")
.ok()
.filter(|v| !v.trim().is_empty());
let expected_sha = std::env::var("M1ND_EXPECTED_SHA")
.ok()
.filter(|v| !v.trim().is_empty());
if let Some(error) = strict_version_verdict(
strict,
m1nd_mcp::session::BINARY_VERSION,
m1nd_mcp::session::BINARY_GIT_SHA,
expected_version.as_deref(),
expected_sha.as_deref(),
) {
eprintln!("{error}");
std::process::exit(2);
}
}
#[tokio::main]
async fn main() {
#[cfg(unix)]
ensure_bwrap_compat_wrapper();
enforce_strict_version();
let cli = Cli::parse();
if let Some(attach_arg) = cli.attach.clone() {
#[cfg(feature = "serve")]
{
let base_url = match std::env::var("M1ND_ATTACH_URL") {
Ok(url) if !url.trim().is_empty() => {
eprintln!(
"[m1nd-mcp][attach] using M1ND_ATTACH_URL override: {}",
url.trim()
);
url.trim().to_string()
}
_ if attach_arg.trim().eq_ignore_ascii_case("auto") => {
match resolve_attach_auto(&cli) {
Ok(url) => url,
Err(msg) => {
eprintln!("[m1nd-mcp][attach] auto-discovery failed: {msg}");
std::process::exit(1);
}
}
}
_ => attach_arg,
};
m1nd_mcp::attach_client::run_attach_client(base_url).await;
return;
}
#[cfg(not(feature = "serve"))]
{
let _ = attach_arg;
eprintln!("[m1nd-mcp] --attach requires the 'serve' feature (HTTP client).");
eprintln!(" Rebuild with: cargo build --release --features serve");
std::process::exit(1);
}
}
let config = load_config_from_cli(&cli);
if cli.inbox_sweep {
run_inbox_sweep(config, cli.no_distribute);
return;
}
if let Some(mode) = cli.medulla_migrate {
run_medulla_migrate(config, mode, cli.migrate_project_root);
return;
}
let event_log = cli.event_log;
let watch_events = cli.watch_events;
if cli.serve {
#[cfg(feature = "serve")]
{
m1nd_mcp::http_server::run(
config,
cli.port,
cli.bind,
cli.allow_remote,
cli.dev,
cli.open,
cli.stdio,
event_log,
watch_events,
)
.await;
}
#[cfg(not(feature = "serve"))]
{
let _ = (event_log, watch_events); eprintln!("[m1nd-mcp] --serve requires the 'serve' feature.");
eprintln!(" Rebuild with: cargo build --release --features serve");
std::process::exit(1);
}
} else {
run_stdio_server(config, event_log, cli.no_gui, cli.port).await;
}
}
#[cfg(test)]
mod tests {
use super::{anchor_persist_target, resolve_graph_source, strict_version_verdict};
use std::path::PathBuf;
#[test]
fn relative_persist_target_anchors_on_runtime_dir() {
let runtime = PathBuf::from("/Users/kle1nz/.m1nd/runtimes/claude");
let graph = anchor_persist_target(
PathBuf::from("./graph_snapshot.json"),
Some(runtime.as_path()),
);
assert_eq!(
graph,
runtime.join("./graph_snapshot.json"),
"relative graph snapshot must land under the runtime dir"
);
assert!(
graph.starts_with(&runtime),
"anchored graph path must be under the runtime dir, got {graph:?}"
);
let plas = anchor_persist_target(
PathBuf::from("plasticity_state.json"),
Some(runtime.as_path()),
);
assert_eq!(plas, runtime.join("plasticity_state.json"));
}
#[test]
fn explicit_absolute_persist_target_is_never_rewritten() {
let runtime = PathBuf::from("/Users/kle1nz/.m1nd/runtimes/claude");
let explicit = PathBuf::from("/data/snapshots/graph_snapshot.json");
assert_eq!(
anchor_persist_target(explicit.clone(), Some(runtime.as_path())),
explicit,
"explicit absolute path must not be re-anchored"
);
}
#[test]
fn no_runtime_dir_leaves_relative_target_unchanged() {
let rel = PathBuf::from("./graph_snapshot.json");
assert_eq!(anchor_persist_target(rel.clone(), None), rel);
}
#[test]
fn temp_sentinel_never_resolves_to_a_cwd_relative_temp_path() {
let resolved = resolve_graph_source(PathBuf::from("temp"));
assert_ne!(
resolved,
PathBuf::from("temp"),
"temp sentinel still resolves to CWD `temp`"
);
assert!(
resolved.is_absolute(),
"resolved temp graph path must be absolute, got {resolved:?}"
);
assert!(
resolved.starts_with(std::env::temp_dir()),
"temp graph snapshot must live under the OS temp dir, got {resolved:?}"
);
assert!(
resolved
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("m1nd-graph-")),
"temp graph snapshot filename should be process-scoped, got {resolved:?}"
);
}
#[test]
fn non_sentinel_graph_source_passes_through_unchanged() {
let explicit = PathBuf::from("/some/where/graph_snapshot.json");
assert_eq!(resolve_graph_source(explicit.clone()), explicit);
let looks_like = PathBuf::from("temp.json");
assert_eq!(resolve_graph_source(looks_like.clone()), looks_like);
let nested = PathBuf::from("./temp/graph.json");
assert_eq!(resolve_graph_source(nested.clone()), nested);
}
#[test]
fn strict_off_never_refuses_even_on_mismatch() {
assert!(strict_version_verdict(false, "1.1.0", "abc", Some("0.0.1"), None).is_none());
}
#[test]
fn strict_on_refuses_version_mismatch() {
let verdict = strict_version_verdict(true, "1.1.0", "abc123", Some("0.0.0-beta.8"), None);
let msg = verdict.expect("strict + mismatch must refuse");
assert!(msg.contains("STRICT VERSION REFUSAL"));
assert!(msg.contains("1.1.0"));
assert!(msg.contains("0.0.0-beta.8"));
}
#[test]
fn strict_on_refuses_sha_mismatch() {
let verdict = strict_version_verdict(true, "1.1.0", "abc123", None, Some("deadbee"));
assert!(verdict.expect("sha mismatch refuses").contains("deadbee"));
}
#[test]
fn strict_on_allows_exact_match() {
assert!(
strict_version_verdict(true, "1.1.0", "abc123", Some("1.1.0"), Some("abc123"))
.is_none()
);
}
#[test]
fn strict_on_with_no_expectation_allows() {
assert!(strict_version_verdict(true, "1.1.0", "abc123", None, None).is_none());
}
}