gitcortex_mcp/mcp/
server.rs1use std::{
2 path::{Path, PathBuf},
3 sync::{
4 atomic::{AtomicUsize, Ordering},
5 Arc,
6 },
7 time::Duration,
8};
9
10use anyhow::{Context, Result};
11use rmcp::ServiceExt;
12use tokio::io::AsyncReadExt;
13
14use crate::embeddings::{node_text, Embedder, SemanticIndex};
15use crate::mcp::tools::{GitCortexServer, SemanticState};
16use gitcortex_core::store::GraphStore;
17use gitcortex_store::branch;
18
19const COMPACT_MODE: u8 = 0;
20const FULL_MODE: u8 = 1;
21const STARTUP_IDLE_TIMEOUT: Duration = Duration::from_secs(15);
22const CLIENT_IDLE_TIMEOUT: Duration = Duration::from_millis(500);
23
24struct SocketGuard {
25 path: PathBuf,
26}
27
28impl Drop for SocketGuard {
29 fn drop(&mut self) {
30 let _ = std::fs::remove_file(&self.path);
31 }
32}
33
34pub fn daemon_socket_path(repo_root: &Path) -> PathBuf {
37 let repo_id = branch::storage_repo_id(repo_root);
38 branch::data_dir(&repo_id).join("mcp.sock")
39}
40
41pub fn daemon_log_path(repo_root: &Path) -> PathBuf {
42 let repo_id = branch::storage_repo_id(repo_root);
43 branch::data_dir(&repo_id).join("daemon.log")
44}
45
46pub async fn serve_daemon(repo_root: PathBuf) -> Result<()> {
49 let handler = GitCortexServer::new_daemon(&repo_root).context("failed to open graph store")?;
52
53 let socket_path = daemon_socket_path(&repo_root);
54 if let Some(parent) = socket_path.parent() {
55 std::fs::create_dir_all(parent)?;
56 }
57 if socket_path.exists() {
58 std::fs::remove_file(&socket_path)
59 .with_context(|| format!("remove stale socket {}", socket_path.display()))?;
60 }
61 let listener = tokio::net::UnixListener::bind(&socket_path)
62 .with_context(|| format!("bind repository daemon socket {}", socket_path.display()))?;
63 let _socket_guard = SocketGuard {
64 path: socket_path.clone(),
65 };
66 #[cfg(unix)]
67 {
68 use std::os::unix::fs::PermissionsExt;
69 std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))?;
70 }
71
72 spawn_background_services(&repo_root, &handler);
75
76 let clients = Arc::new(AtomicUsize::new(0));
77 let changed = Arc::new(tokio::sync::Notify::new());
78 let mut ever_connected = false;
79 tracing::info!(
80 "GitCortex repository daemon listening at {}",
81 socket_path.display()
82 );
83
84 loop {
85 let idle_timeout = if ever_connected {
86 CLIENT_IDLE_TIMEOUT
87 } else {
88 STARTUP_IDLE_TIMEOUT
89 };
90 if clients.load(Ordering::Acquire) == 0 {
91 tokio::select! {
92 accepted = listener.accept() => {
93 let (stream, _) = accepted.context("accept MCP client")?;
94 ever_connected = true;
95 spawn_client(handler.clone(), stream, clients.clone(), changed.clone());
96 }
97 _ = changed.notified() => {}
98 _ = tokio::time::sleep(idle_timeout) => {
99 if clients.load(Ordering::Acquire) == 0 {
100 break;
101 }
102 }
103 }
104 } else {
105 tokio::select! {
106 accepted = listener.accept() => {
107 let (stream, _) = accepted.context("accept MCP client")?;
108 ever_connected = true;
109 spawn_client(handler.clone(), stream, clients.clone(), changed.clone());
110 }
111 _ = changed.notified() => {}
112 }
113 }
114 }
115
116 tracing::info!("GitCortex repository daemon stopped after its last client disconnected");
117 Ok(())
118}
119
120fn spawn_client(
121 handler: GitCortexServer,
122 mut stream: tokio::net::UnixStream,
123 clients: Arc<AtomicUsize>,
124 changed: Arc<tokio::sync::Notify>,
125) {
126 clients.fetch_add(1, Ordering::AcqRel);
127 tokio::spawn(async move {
128 let result = async {
129 let mode = stream.read_u8().await.context("read MCP client mode")?;
130 let compact = match mode {
131 COMPACT_MODE => true,
132 FULL_MODE => false,
133 other => anyhow::bail!("unsupported MCP client mode byte {other}"),
134 };
135 let service = handler
136 .clone_with_mode(compact)
137 .serve(stream)
138 .await
139 .context("start MCP client")?;
140 service.waiting().await.context("MCP client stopped")?;
141 Ok::<_, anyhow::Error>(())
142 }
143 .await;
144 if let Err(error) = result {
145 tracing::warn!("MCP client connection ended with an error: {error:#}");
146 }
147 clients.fetch_sub(1, Ordering::AcqRel);
148 changed.notify_one();
149 });
150}
151
152fn spawn_background_services(repo_root: &Path, handler: &GitCortexServer) {
153 let (watch_store, watch_branch, graph_revision) = handler.store_context();
156 crate::mcp::watcher::spawn_file_watcher(
157 repo_root.to_owned(),
158 watch_store,
159 watch_branch.clone(),
160 graph_revision.clone(),
161 );
162
163 let (sem_arc, store_arc, default_branch) = handler.semantic_context();
164 if std::env::var_os("GCX_DISABLE_SEMANTIC").is_some() {
165 if let Ok(mut state) = sem_arc.lock() {
166 *state = SemanticState::Disabled;
167 }
168 tracing::info!("semantic search disabled by GCX_DISABLE_SEMANTIC");
169 return;
170 }
171 let repo_id = branch::storage_repo_id(repo_root);
172 tokio::task::spawn(async move {
173 let mut indexed_branch = String::new();
174 let mut indexed_revision = u64::MAX;
175 loop {
176 let active_branch = watch_branch
177 .lock()
178 .map(|branch| branch.clone())
179 .unwrap_or_else(|_| default_branch.clone());
180 let revision = graph_revision.load(std::sync::atomic::Ordering::Acquire);
181 if active_branch != indexed_branch || revision != indexed_revision {
182 let branch_changed = active_branch != indexed_branch;
183 if branch_changed {
184 if let Ok(mut state) = sem_arc.lock() {
185 *state = SemanticState::Pending;
186 }
187 }
188 let task_sem = sem_arc.clone();
189 let task_store = store_arc.clone();
190 let task_branch = active_branch.clone();
191 let task_repo_id = repo_id.clone();
192 let result = tokio::task::spawn_blocking(move || {
193 if branch_changed {
194 run_background_indexer(task_sem, task_store, &task_branch, &task_repo_id)
195 } else {
196 refresh_background_indexer(task_sem, task_store, &task_branch)
197 }
198 })
199 .await;
200 match result {
201 Ok(Ok(())) => {
202 tracing::info!("semantic indexer finished for branch '{active_branch}'")
203 }
204 Ok(Err(error)) => tracing::warn!("semantic indexer failed: {error}"),
205 Err(error) => tracing::warn!("semantic indexer panicked: {error}"),
206 }
207 indexed_branch = active_branch;
208 indexed_revision = revision;
209 }
210 tokio::time::sleep(Duration::from_millis(500)).await;
211 }
212 });
213}
214
215fn run_background_indexer(
216 sem_arc: std::sync::Arc<std::sync::Mutex<SemanticState>>,
217 store_arc: std::sync::Arc<std::sync::Mutex<gitcortex_store::kuzu::KuzuGraphStore>>,
218 branch: &str,
219 repo_id: &str,
220) -> anyhow::Result<()> {
221 let embedder = match Embedder::new(&branch::models_dir()) {
223 Ok(e) => e,
224 Err(e) => {
225 tracing::warn!("semantic search disabled: {e}");
226 if let Ok(mut s) = sem_arc.lock() {
227 *s = SemanticState::Disabled;
228 }
229 return Ok(());
230 }
231 };
232
233 let index_path =
235 branch::data_dir(repo_id).join(format!("embeddings_{}.bin", branch::sanitize(branch)));
236 let mut index = SemanticIndex::load_or_create(&index_path);
237
238 let nodes = {
240 let store = store_arc
241 .lock()
242 .map_err(|_| anyhow::anyhow!("store mutex poisoned"))?;
243 store.list_all_nodes(branch).unwrap_or_default()
244 };
245
246 update_semantic_index(&embedder, &mut index, &nodes, branch);
247
248 if let Ok(mut s) = sem_arc.lock() {
250 *s = SemanticState::Ready {
251 branch: branch.to_owned(),
252 embedder: Box::new(embedder),
253 index: Box::new(index),
254 };
255 }
256
257 Ok(())
258}
259
260fn refresh_background_indexer(
261 sem_arc: std::sync::Arc<std::sync::Mutex<SemanticState>>,
262 store_arc: std::sync::Arc<std::sync::Mutex<gitcortex_store::kuzu::KuzuGraphStore>>,
263 branch: &str,
264) -> anyhow::Result<()> {
265 let nodes = {
266 let store = store_arc
267 .lock()
268 .map_err(|_| anyhow::anyhow!("store mutex poisoned"))?;
269 store.list_all_nodes(branch).unwrap_or_default()
270 };
271 let mut state = sem_arc
272 .lock()
273 .map_err(|_| anyhow::anyhow!("semantic mutex poisoned"))?;
274 if let SemanticState::Ready {
275 branch: indexed_branch,
276 embedder,
277 index,
278 } = &mut *state
279 {
280 if indexed_branch == branch {
281 update_semantic_index(embedder, index, &nodes, branch);
282 }
283 }
284 Ok(())
285}
286
287fn update_semantic_index(
288 embedder: &Embedder,
289 index: &mut SemanticIndex,
290 nodes: &[gitcortex_core::graph::Node],
291 branch: &str,
292) {
293 let live_ids: std::collections::HashSet<String> =
294 nodes.iter().map(|node| node.id.as_str()).collect();
295 let pruned = index.retain_ids(&live_ids);
296 if pruned > 0 {
297 tracing::info!("semantic index: pruned {pruned} stale vectors");
298 }
299
300 let missing: Vec<_> = nodes
301 .iter()
302 .filter(|node| !index.has(&node.id.as_str()))
303 .collect();
304 if !missing.is_empty() {
305 tracing::info!(
306 "semantic indexer: embedding {} new nodes on branch '{branch}'",
307 missing.len()
308 );
309 const BATCH: usize = 32;
310 for chunk in missing.chunks(BATCH) {
311 let texts: Vec<String> = chunk.iter().map(|node| node_text(node)).collect();
312 let ids: Vec<String> = chunk.iter().map(|node| node.id.as_str()).collect();
313 match embedder.embed_batch(texts) {
314 Ok(vectors) => {
315 for (id, vector) in ids.into_iter().zip(vectors) {
316 index.insert(id, vector);
317 }
318 }
319 Err(error) => tracing::warn!("embedding batch failed: {error}"),
320 }
321 }
322 index.save();
323 tracing::info!("semantic index: {} vectors", index.len());
324 } else if pruned > 0 {
325 index.save();
326 } else {
327 tracing::info!("semantic index up-to-date: {} vectors", index.len());
328 }
329}