Skip to main content

gitcortex_mcp/mcp/
server.rs

1use 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
34/// Socket used by the machine-local repository daemon. The stable repository
35/// ID keeps the path short enough for Unix-domain socket limits.
36pub 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
46/// Own the embedded graph once and multiplex any number of local MCP clients.
47/// Client proxies send one mode byte before the newline-delimited MCP stream.
48pub async fn serve_daemon(repo_root: PathBuf) -> Result<()> {
49    // Open Kuzu before advertising the socket. A successful client connect then
50    // means the graph owner is fully ready, not merely that startup has begun.
51    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    // The base handler owns Kuzu and all branch/semantic state. Per-client
73    // clones differ only in whether they expose compact or full tool schemas.
74    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    // The watcher and semantic indexer run once per repository daemon, not once
154    // per editor connection.
155    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    // 1. Initialise the embedding model (downloads on first run).
222    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    // 2. Load or create per-branch vector index.
234    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    // 3. Embed nodes that don't yet have a vector.
239    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    // 4. Flip to Ready.
249    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}