use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use omgbase_search::{EmbeddingProvider, EmbeddingSettings, create_external_provider};
use omgbase_surface::{Profile, Surface};
use omgbase_sync::workspace::{WORKSPACE_ENV, find_root};
use crate::cli::argv::{Opt, parse_args};
use crate::cli::context::Cli;
use crate::cli::help::{render_help_for, wants_help};
use crate::cli::output::{CliError, EXIT_OK, Io, Result};
use crate::cli::render::Style;
use crate::cli::seams::{
MinterSource, SPEC_CLOCK_ENV, SPEC_MINTER_ENV, WRITER_LOCK_TIMEOUT_ENV, open_store,
};
use crate::drain::{DrainFn, DrainReport, Drainer, SharedProvider};
use crate::{mcp as transport, watch};
use serde_json::Value as Json;
fn announce_seams(cli: &Cli) {
if cli.seams.sequential_minter {
cli.io.err(&cli.style.dim(&format!(
"[mcp] spec seam: sequential id minter ({SPEC_MINTER_ENV}=sequential) — conformance run, not for production"
)));
}
if let Some(ts) = &cli.seams.clock {
cli.io.err(&cli.style.dim(&format!(
"[mcp] spec seam: clock pinned to {ts} ({SPEC_CLOCK_ENV}) — conformance run, not for production"
)));
}
if let Some(ms) = cli.seams.writer_lock_timeout_ms {
cli.io.err(&cli.style.dim(&format!(
"[mcp] test seam: writer lock waits {ms} ms ({WRITER_LOCK_TIMEOUT_ENV}) — not for production"
)));
}
}
fn embedding_settings(store: &omgbase_store::Store, repo_id: &str) -> Option<EmbeddingSettings> {
let settings = match omgbase_sync::resolve_settings(store, Some(repo_id)) {
Ok(s) => s,
Err(e) => {
eprintln!("[mcp] settings unreadable: {e}");
return None;
}
};
let emb = settings.get("embedding")?.as_object()?;
let s = |k: &str| emb.get(k).and_then(|v| v.as_str()).map(str::to_owned);
let n = |k: &str| emb.get(k).and_then(serde_json::Value::as_u64);
let cfg = EmbeddingSettings {
provider: s("provider"),
model: s("model"),
dim: n("dim").map(|d| usize::try_from(d).unwrap_or(0)),
max_input_tokens: n("maxInputTokens")
.or_else(|| n("max_input_tokens"))
.map(|d| u32::try_from(d).unwrap_or(u32::MAX)),
};
cfg.provider
.as_deref()
.is_some_and(|p| !p.trim().is_empty())
.then_some(cfg)
}
fn embedding_provider(cfg: &EmbeddingSettings) -> Option<SharedProvider> {
match create_external_provider(cfg) {
Ok(Some(p)) => {
eprintln!("[mcp] semantic query enabled via {}", p.model());
Some(SharedProvider::new(p))
}
Ok(None) => None,
Err(e) => {
eprintln!("[mcp] embedder nonfunctional — semantic search + auto-embed disabled: {e}");
None
}
}
}
fn start_drainer(
db: &Path,
repo_id: &str,
minters: &MinterSource,
provider: SharedProvider,
) -> Drainer {
let (db, repo_id, minters) = (db.to_path_buf(), repo_id.to_owned(), minters.clone());
Drainer::spawn(crate::drain::DEBOUNCE, move || {
let store = open_store(&db, &minters)?;
Ok(Box::new(move || {
let stats = store
.drain(&repo_id, &provider)
.map_err(|e| e.to_string())?;
Ok(DrainReport {
embedded: stats.blocks.embedded + stats.docs.embedded,
pooled: stats.docs.pooled,
})
}) as DrainFn)
})
}
static SIGNALLED: AtomicBool = AtomicBool::new(false);
extern "C" fn on_signal(_sig: libc::c_int) {
SIGNALLED.store(true, Ordering::SeqCst);
}
fn install_signal_handlers() {
let handler = on_signal as extern "C" fn(libc::c_int) as *const () as libc::sighandler_t;
unsafe {
libc::signal(libc::SIGINT, handler);
libc::signal(libc::SIGTERM, handler);
}
}
#[derive(Default)]
struct Helpers {
watcher: Option<watch::Watcher>,
drainer: Option<Drainer>,
}
impl Helpers {
fn shutdown(self) {
if let Some(w) = self.watcher {
w.stop();
}
if let Some(d) = self.drainer {
d.flush();
d.close();
}
}
}
type Shared = Arc<Mutex<Option<Helpers>>>;
fn take_helpers(shared: &Shared) -> Option<Helpers> {
shared
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
fn spawn_signal_thread(shared: Shared) {
install_signal_handlers();
std::thread::Builder::new()
.name("omgbase-signals".to_owned())
.spawn(move || {
loop {
std::thread::sleep(Duration::from_millis(50));
if SIGNALLED.load(Ordering::SeqCst) {
if let Some(h) = take_helpers(&shared) {
eprintln!("[mcp] signal: shutting down");
h.shutdown();
std::process::exit(0);
}
return;
}
}
})
.expect("spawn the signal thread");
}
fn profile_flag(value: &str) -> Result<Profile> {
value.parse::<Profile>().map_err(|_| {
CliError::usage(format!(
"--tools must be one of full|minimal (got {})",
Json::String(value.to_owned())
))
})
}
fn tools_profile(
io: Io,
style: &Style,
flag: Option<&str>,
store: &omgbase_store::Store,
repo_id: &str,
) -> Result<Profile> {
if let Some(v) = flag {
return profile_flag(v);
}
let settings = omgbase_sync::resolve_settings(store, Some(repo_id))?;
match settings.get("mcp").and_then(|m| m.get("tools")) {
None => Ok(Profile::Full),
Some(v) => match v.as_str().and_then(|s| s.parse::<Profile>().ok()) {
Some(p) => Ok(p),
None => {
io.err(&style.warn(&format!(
"[mcp] ignoring setting mcp.tools = {v} (expected full|minimal); serving the full catalog"
)));
Ok(Profile::Full)
}
},
}
}
fn locate(cli: &mut Cli, explicit: Option<&str>) -> Result<crate::cli::context::WsInfo> {
if let Some(dir) = explicit {
return cli.open_at(PathBuf::from(dir));
}
if let Some(env) = std::env::var_os(WORKSPACE_ENV).filter(|v| !v.is_empty()) {
return cli.open_at(PathBuf::from(env));
}
if find_root(&cli.cwd).is_none() {
let message = cli.no_workspace_error().message().to_owned();
let p = &cli.prog;
return Err(CliError::engine_hint(
"repo_not_found",
message,
format!(
"`{p} mcp` serves one workspace: point it there with -C, e.g. {{\"command\": \"{p}\", \"args\": [\"mcp\", \"-C\", \"/path/to/notes\"]}} in the MCP host config — or create one first with `{p} init <dir>` and `{p} -C <dir> source add .`"
),
));
}
cli.workspace()
}
pub fn mcp(cli: &mut Cli, args: &[String]) -> Result<i32> {
if wants_help(args) {
return render_help_for(cli, "mcp");
}
let a = parse_args(
args,
&[
Opt::flag("no-watch"),
Opt::value("workspace"),
Opt::value("tools"),
],
)?;
if let Some(v) = a.value("tools") {
profile_flag(v)?;
}
announce_seams(cli);
let ws = locate(cli, a.value("workspace"))?;
let repo = cli.repo()?;
let (io, style) = (cli.io, cli.style);
let profile = tools_profile(io, &style, a.value("tools"), cli.store()?, &repo.repo_id)?;
let embedding = embedding_settings(cli.store()?, &repo.repo_id);
let provider = embedding.as_ref().and_then(embedding_provider);
eprintln!("[mcp] workspace {}", ws.root.display());
let omgbase_dir = ws.omgbase_dir.clone();
let db = ws.db_path.clone();
let minters = cli.minters.clone();
let clock = cli.seams.clock.clone();
cli.close();
let drainer = provider
.as_ref()
.map(|p| start_drainer(&db, &repo.repo_id, &minters, p.clone()));
let drain_handle = drainer.as_ref().map(Drainer::handle);
let mut watcher = None;
let status = if a.flag("no-watch") {
"watcher off (--no-watch)".to_owned()
} else {
match watch::start(watch::WatchOptions {
omgbase_dir: omgbase_dir.clone(),
db_path: db.clone(),
repo: repo.clone(),
minters: minters.clone(),
clock: clock.clone(),
adapter_override: watch::adapter_override_from_env(),
ready_patience: watch::READY_PATIENCE,
drain: drain_handle.clone(),
on_checkpoint: None,
})
.map_err(|e| CliError::engine("error", e))?
{
watch::Outcome::Live(w) => {
eprintln!("[watch] adapter: {}", w.argv().join(" "));
watcher = Some(w);
"watcher live".to_owned()
}
watch::Outcome::LeaseHeld => "watcher elsewhere".to_owned(),
watch::Outcome::NoSource => "sourceless (no watch)".to_owned(),
watch::Outcome::AdapterUnavailable(why) => {
eprintln!(
"[mcp] watcher unavailable: {why}\n install @omgbase/fs-adapter (the `omgbase-fs-adapter` bin on PATH) or set {}=<command line>; serving without a watcher — one-shot commands and `omg watch` keep the repo fresh",
watch::ADAPTER_ENV
);
"no watch (adapter unavailable)".to_owned()
}
}
};
eprintln!("[mcp] serving {} on stdio · {status}", repo.slug);
if drainer.is_some() {
eprintln!("[mcp] auto-embed on mutation enabled");
}
if profile != Profile::Full {
eprintln!("[mcp] tools profile: {profile}");
}
let shared: Shared = Arc::new(Mutex::new(Some(Helpers { watcher, drainer })));
spawn_signal_thread(Arc::clone(&shared));
let store = open_store(&db, &minters).map_err(|e| CliError::engine("error", e))?;
let query_provider: Option<Box<dyn EmbeddingProvider>> =
provider.map(|p| Box::new(p) as Box<dyn EmbeddingProvider>);
let mut surface = Surface::new(store, &repo.repo_id, query_provider)
.with_build_info(crate::cli::build_info())
.with_profile(profile);
if let Some(ts) = clock {
surface = surface.with_clock(move || ts.clone());
}
if let Some(h) = drain_handle {
surface = surface.with_mutation_hook(move || h.schedule());
}
let serve_opts = transport::ServeOptions {
omgbase_dir: Some(omgbase_dir),
lock: cli.seams.lock_options(),
};
let served = transport::serve(&mut surface, crate::cli::VERSION, &serve_opts);
if let Some(h) = take_helpers(&shared) {
h.shutdown();
}
drop(surface);
served.map_err(|e| CliError::engine("error", e.to_string()))?;
Ok(EXIT_OK)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_flag_beats_the_setting_and_a_bad_setting_serves_full() {
let style = Style::detect(true, false);
let t = |flag: Option<&str>, store: &omgbase_store::Store, repo: &str| {
tools_profile(Io, &style, flag, store, repo)
};
let mut store = omgbase_store::Store::open_in_memory().unwrap();
let repo = store.create_repo("r").unwrap();
assert_eq!(t(None, &store, &repo).unwrap(), Profile::Full);
assert_eq!(t(Some("minimal"), &store, &repo).unwrap(), Profile::Minimal);
let err = t(Some("lite"), &store, &repo).unwrap_err();
assert!(matches!(err, CliError::Usage { .. }));
assert_eq!(
err.message(),
"--tools must be one of full|minimal (got \"lite\")"
);
let mut settings = serde_json::Map::new();
settings.insert("mcp".to_owned(), serde_json::json!({ "tools": "minimal" }));
omgbase_sync::write_repo_settings(&store, &repo, &settings).unwrap();
assert_eq!(t(None, &store, &repo).unwrap(), Profile::Minimal);
assert_eq!(t(Some("full"), &store, &repo).unwrap(), Profile::Full);
settings.insert("mcp".to_owned(), serde_json::json!({ "tools": "lite" }));
omgbase_sync::write_repo_settings(&store, &repo, &settings).unwrap();
assert_eq!(t(None, &store, &repo).unwrap(), Profile::Full);
settings.insert("mcp".to_owned(), serde_json::json!({ "tools": 3 }));
omgbase_sync::write_repo_settings(&store, &repo, &settings).unwrap();
assert_eq!(t(None, &store, &repo).unwrap(), Profile::Full);
}
}