mod helpers;
mod tools;
mod types;
use std::collections::BTreeMap;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use arc_swap::ArcSwap;
use lru::LruCache;
use rmcp::ServerHandler;
use rmcp::handler::server::tool::ToolRouter;
use rmcp::model::{ServerCapabilities, ServerInfo};
use rmcp::tool_handler;
use tokio::sync::RwLock;
use crate::extract::{FileMapL1, Import};
use crate::lang::LangId;
use crate::store::Store;
pub(crate) const OUTLINE_CACHE_CAP: usize = 512;
pub(crate) struct OutlineEntry {
pub map: Arc<FileMapL1>,
pub source: Arc<Vec<u8>>,
}
pub(crate) type OutlineCache = Mutex<LruCache<(gix::ObjectId, LangId), Arc<OutlineEntry>>>;
#[derive(Clone)]
pub struct BasemindServer {
pub(crate) state: Arc<ServerState>,
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
pub(crate) struct ServerState {
pub(crate) store: RwLock<Store>,
pub(crate) root: PathBuf,
pub(crate) cache: ArcSwap<MapCache>,
pub(crate) repo: Option<Arc<crate::git::Repo>>,
pub(crate) git_cache: Arc<crate::git_cache::GitCache>,
pub(crate) outline_cache: Arc<OutlineCache>,
}
pub(crate) struct MapCache {
pub(crate) by_path: BTreeMap<crate::path::RelPath, FileMapL1>,
pub(crate) imports_index: Vec<(PathBuf, Vec<Import>)>,
}
impl MapCache {
fn build(store: &Store) -> Self {
let mut by_path = BTreeMap::new();
for (path, entry) in &store.index.files {
match store.read_l1_by_hex(&entry.hash_hex) {
Ok(Some(l1)) => {
by_path.insert(path.clone(), l1);
}
Ok(None) | Err(_) => continue,
}
}
let imports_index: Vec<(PathBuf, Vec<Import>)> = by_path
.iter()
.map(|(p, l1)| (p.to_path_buf(), l1.imports.clone()))
.collect();
Self {
by_path,
imports_index,
}
}
}
impl BasemindServer {
pub fn new(
store: Store,
root: PathBuf,
repo: Option<Arc<crate::git::Repo>>,
git_cache: Arc<crate::git_cache::GitCache>,
) -> Self {
let cache = Arc::new(MapCache::build(&store));
tracing::info!(
files = cache.by_path.len(),
git = repo.is_some(),
"preloaded code map into RAM for MCP server"
);
let outline_cache: Arc<OutlineCache> = Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(OUTLINE_CACHE_CAP).expect("OUTLINE_CACHE_CAP > 0"),
)));
let state = Arc::new(ServerState {
store: RwLock::new(store),
root,
cache: ArcSwap::from(cache),
repo,
git_cache,
outline_cache,
});
spawn_view_watcher(Arc::clone(&state));
Self {
state,
tool_router: Self::tool_router(),
}
}
}
fn spawn_view_watcher(state: Arc<ServerState>) {
let (basemind_dir, view) = {
let store = match state.store.try_read() {
Ok(g) => g,
Err(_) => return,
};
(store.basemind_dir.clone(), store.view.clone())
};
let view_dir = basemind_dir.join(crate::store::VIEWS_DIR).join(&view);
let target = view_dir.join(crate::store::INDEX_FILE);
std::thread::Builder::new()
.name("basemind-mcp-view-watcher".to_string())
.spawn(move || {
use notify_debouncer_full::new_debouncer;
use std::time::Duration;
let (tx, rx) = std::sync::mpsc::channel();
let mut debouncer = match new_debouncer(Duration::from_millis(150), None, tx) {
Ok(d) => d,
Err(e) => {
tracing::warn!(error = %e, "view watcher: failed to start debouncer");
return;
}
};
if let Err(e) = debouncer.watch(&view_dir, notify::RecursiveMode::NonRecursive) {
tracing::warn!(error = %e, dir = %view_dir.display(), "view watcher: failed to watch");
return;
}
tracing::info!(target = %target.display(), "view watcher armed");
while let Ok(result) = rx.recv() {
let events = match result {
Ok(e) => e,
Err(_) => continue,
};
let touches_index = events
.iter()
.any(|de| de.event.paths.iter().any(|p| p == &target));
if !touches_index {
continue;
}
let new_store = match crate::store::Store::open_read_only(
state.root.as_path(),
&state
.store
.try_read()
.map(|g| g.view.clone())
.unwrap_or_default(),
) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "view watcher: store reopen failed");
continue;
}
};
let new_cache = Arc::new(MapCache::build(&new_store));
tracing::info!(
files = new_cache.by_path.len(),
"view watcher: rebuilt MapCache from refreshed index"
);
state.cache.store(new_cache);
}
tracing::info!("view watcher: channel closed; exiting");
})
.ok();
}
#[tool_handler]
impl ServerHandler for BasemindServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
"basemind exposes a tree-sitter-backed code map plus git context. \
Code-map tools: `outline`, `search_symbols`, `list_files`, `dependents`, `status`. \
Git tools (inside a repo): `working_tree_status`, `recent_changes`, `commits_touching`, \
`find_commits_by_path`, `hot_files`, `diff_outline`, `diff_file`, `blame_file`, \
`blame_symbol`, `symbol_history`, `repo_info`. All paths are repository-relative \
with forward-slash separators.",
)
}
}