Skip to main content

seekr_code/server/
mcp.rs

1//! MCP Server protocol implementation.
2//!
3//! Implements Model Context Protocol (MCP) over stdio transport.
4//! Registers three tools:
5//! - `seekr_search`: Search code
6//! - `seekr_index`: Trigger index build
7//! - `seekr_status`: View index status
8//!
9//! The MCP protocol uses JSON-RPC 2.0 over stdin/stdout.
10
11use std::io::{BufRead, Write};
12use std::path::Path;
13use std::time::Instant;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::config::SeekrConfig;
19use crate::embedder::batch::{BatchEmbedder, DummyEmbedder};
20use crate::embedder::traits::Embedder;
21use crate::index::store::SeekrIndex;
22use crate::parser::CodeChunk;
23use crate::parser::chunker::chunk_file_from_path;
24use crate::parser::summary::generate_summary;
25use crate::scanner::filter::should_index_file;
26use crate::scanner::walker::walk_directory;
27use crate::search::ast_pattern::search_ast_pattern;
28use crate::search::fusion::{
29    fuse_ast_only, fuse_semantic_only, fuse_text_only, rrf_fuse, rrf_fuse_three,
30};
31use crate::search::semantic::{SemanticSearchOptions, search_semantic};
32use crate::search::text::{TextSearchOptions, search_text_regex};
33use crate::search::{SearchMode, SearchResult};
34
35// ============================================================
36// JSON-RPC 2.0 types
37// ============================================================
38
39/// JSON-RPC request.
40#[derive(Debug, Deserialize)]
41struct JsonRpcRequest {
42    jsonrpc: String,
43    id: Option<Value>,
44    method: String,
45    #[serde(default)]
46    params: Option<Value>,
47}
48
49/// JSON-RPC response.
50#[derive(Debug, Serialize)]
51struct JsonRpcResponse {
52    jsonrpc: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    id: Option<Value>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    result: Option<Value>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    error: Option<JsonRpcError>,
59}
60
61/// JSON-RPC error.
62#[derive(Debug, Serialize)]
63struct JsonRpcError {
64    code: i32,
65    message: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    data: Option<Value>,
68}
69
70impl JsonRpcResponse {
71    fn success(id: Option<Value>, result: Value) -> Self {
72        Self {
73            jsonrpc: "2.0".to_string(),
74            id,
75            result: Some(result),
76            error: None,
77        }
78    }
79
80    fn error(id: Option<Value>, code: i32, message: String) -> Self {
81        Self {
82            jsonrpc: "2.0".to_string(),
83            id,
84            result: None,
85            error: Some(JsonRpcError {
86                code,
87                message,
88                data: None,
89            }),
90        }
91    }
92}
93
94// ============================================================
95// MCP Protocol constants
96// ============================================================
97
98const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
99const SEEKR_MCP_NAME: &str = "seekr-code";
100const SEEKR_MCP_VERSION: &str = env!("CARGO_PKG_VERSION");
101
102// JSON-RPC error codes
103const ERROR_PARSE: i32 = -32700;
104const ERROR_INVALID_REQUEST: i32 = -32600;
105const ERROR_METHOD_NOT_FOUND: i32 = -32601;
106const ERROR_INTERNAL: i32 = -32603;
107
108// ============================================================
109// MCP Server
110// ============================================================
111
112/// Run the MCP Server over stdio.
113///
114/// Reads JSON-RPC requests from stdin (one per line) and writes
115/// responses to stdout. This blocks until stdin is closed.
116pub fn run_mcp_stdio(config: &SeekrConfig) -> Result<(), crate::error::ServerError> {
117    let stdin = std::io::stdin();
118    let stdout = std::io::stdout();
119    let mut stdout = stdout.lock();
120
121    tracing::info!("MCP Server starting on stdio");
122
123    for line in stdin.lock().lines() {
124        let line = match line {
125            Ok(l) => l,
126            Err(e) => {
127                tracing::error!("Failed to read stdin: {}", e);
128                break;
129            }
130        };
131
132        let line = line.trim();
133        if line.is_empty() {
134            continue;
135        }
136
137        let request: JsonRpcRequest = match serde_json::from_str(line) {
138            Ok(req) => req,
139            Err(e) => {
140                let resp = JsonRpcResponse::error(None, ERROR_PARSE, format!("Parse error: {}", e));
141                write_response(&mut stdout, &resp);
142                continue;
143            }
144        };
145
146        if request.jsonrpc != "2.0" {
147            let resp = JsonRpcResponse::error(
148                request.id,
149                ERROR_INVALID_REQUEST,
150                "Invalid JSON-RPC version, expected 2.0".to_string(),
151            );
152            write_response(&mut stdout, &resp);
153            continue;
154        }
155
156        let response = handle_request(&request, config);
157        write_response(&mut stdout, &response);
158    }
159
160    tracing::info!("MCP Server shutting down");
161    Ok(())
162}
163
164/// Write a JSON-RPC response to stdout (one line).
165fn write_response(writer: &mut impl Write, response: &JsonRpcResponse) {
166    if let Ok(json) = serde_json::to_string(response) {
167        let _ = writeln!(writer, "{}", json);
168        let _ = writer.flush();
169    }
170}
171
172/// Route an incoming MCP request to the appropriate handler.
173fn handle_request(request: &JsonRpcRequest, config: &SeekrConfig) -> JsonRpcResponse {
174    match request.method.as_str() {
175        // MCP lifecycle
176        "initialize" => handle_initialize(request),
177        "initialized" => {
178            // Notification — no response needed, but we return a result anyway
179            // since some clients expect it
180            JsonRpcResponse::success(request.id.clone(), Value::Null)
181        }
182        "ping" => JsonRpcResponse::success(request.id.clone(), serde_json::json!({})),
183
184        // MCP discovery
185        "tools/list" => handle_tools_list(request),
186
187        // MCP tool invocation
188        "tools/call" => handle_tools_call(request, config),
189
190        // Unknown method
191        _ => JsonRpcResponse::error(
192            request.id.clone(),
193            ERROR_METHOD_NOT_FOUND,
194            format!("Method not found: {}", request.method),
195        ),
196    }
197}
198
199// ============================================================
200// MCP Lifecycle handlers
201// ============================================================
202
203fn handle_initialize(request: &JsonRpcRequest) -> JsonRpcResponse {
204    JsonRpcResponse::success(
205        request.id.clone(),
206        serde_json::json!({
207            "protocolVersion": MCP_PROTOCOL_VERSION,
208            "capabilities": {
209                "tools": {}
210            },
211            "serverInfo": {
212                "name": SEEKR_MCP_NAME,
213                "version": SEEKR_MCP_VERSION,
214            }
215        }),
216    )
217}
218
219// ============================================================
220// MCP Tools discovery
221// ============================================================
222
223fn handle_tools_list(request: &JsonRpcRequest) -> JsonRpcResponse {
224    let tools = serde_json::json!({
225        "tools": [
226            {
227                "name": "seekr_search",
228                "description": "Search code in a project using text regex, semantic vector, AST pattern, or hybrid mode. Returns ranked code chunks matching the query.",
229                "inputSchema": {
230                    "type": "object",
231                    "properties": {
232                        "query": {
233                            "type": "string",
234                            "description": "Search query. For text mode: regex pattern. For semantic mode: natural language description. For AST mode: function signature pattern (e.g., 'fn(string) -> number'). For hybrid mode: any query."
235                        },
236                        "mode": {
237                            "type": "string",
238                            "description": "Search mode: 'text', 'semantic', 'ast', or 'hybrid' (default).",
239                            "enum": ["text", "semantic", "ast", "hybrid"],
240                            "default": "hybrid"
241                        },
242                        "top_k": {
243                            "type": "integer",
244                            "description": "Maximum number of results to return (default: 20).",
245                            "default": 20
246                        },
247                        "project_path": {
248                            "type": "string",
249                            "description": "Absolute or relative path to the project directory to search in.",
250                            "default": "."
251                        }
252                    },
253                    "required": ["query"]
254                }
255            },
256            {
257                "name": "seekr_index",
258                "description": "Build or rebuild the code search index for a project. Scans source files, parses them into semantic chunks, generates embeddings, and builds a searchable index.",
259                "inputSchema": {
260                    "type": "object",
261                    "properties": {
262                        "path": {
263                            "type": "string",
264                            "description": "Path to the project directory to index.",
265                            "default": "."
266                        },
267                        "force": {
268                            "type": "boolean",
269                            "description": "Force full re-index, ignoring incremental state.",
270                            "default": false
271                        }
272                    }
273                }
274            },
275            {
276                "name": "seekr_status",
277                "description": "Get the index status for a project. Returns information about whether the project is indexed, how many chunks exist, and the index version.",
278                "inputSchema": {
279                    "type": "object",
280                    "properties": {
281                        "path": {
282                            "type": "string",
283                            "description": "Path to the project directory to check.",
284                            "default": "."
285                        }
286                    }
287                }
288            }
289        ]
290    });
291
292    JsonRpcResponse::success(request.id.clone(), tools)
293}
294
295// ============================================================
296// MCP Tools invocation
297// ============================================================
298
299fn handle_tools_call(request: &JsonRpcRequest, config: &SeekrConfig) -> JsonRpcResponse {
300    let params = match &request.params {
301        Some(p) => p,
302        None => {
303            return JsonRpcResponse::error(
304                request.id.clone(),
305                ERROR_INVALID_REQUEST,
306                "Missing params".to_string(),
307            );
308        }
309    };
310
311    let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
312    let arguments = params
313        .get("arguments")
314        .cloned()
315        .unwrap_or(Value::Object(Default::default()));
316
317    match tool_name {
318        "seekr_search" => handle_tool_search(request.id.clone(), &arguments, config),
319        "seekr_index" => handle_tool_index(request.id.clone(), &arguments, config),
320        "seekr_status" => handle_tool_status(request.id.clone(), &arguments, config),
321        _ => JsonRpcResponse::error(
322            request.id.clone(),
323            ERROR_METHOD_NOT_FOUND,
324            format!("Unknown tool: {}", tool_name),
325        ),
326    }
327}
328
329/// Handle `seekr_search` tool call.
330fn handle_tool_search(
331    id: Option<Value>,
332    arguments: &Value,
333    config: &SeekrConfig,
334) -> JsonRpcResponse {
335    let query = arguments
336        .get("query")
337        .and_then(|v| v.as_str())
338        .unwrap_or("");
339    let mode_str = arguments
340        .get("mode")
341        .and_then(|v| v.as_str())
342        .unwrap_or("hybrid");
343    let top_k = arguments
344        .get("top_k")
345        .and_then(|v| v.as_u64())
346        .unwrap_or(20) as usize;
347    let project_path_str = arguments
348        .get("project_path")
349        .and_then(|v| v.as_str())
350        .unwrap_or(".");
351
352    if query.is_empty() {
353        return JsonRpcResponse::error(id, ERROR_INVALID_REQUEST, "Missing query".to_string());
354    }
355
356    let search_mode: SearchMode = match mode_str.parse() {
357        Ok(m) => m,
358        Err(e) => return JsonRpcResponse::error(id, ERROR_INVALID_REQUEST, e),
359    };
360
361    let project_path = Path::new(project_path_str)
362        .canonicalize()
363        .unwrap_or_else(|_| Path::new(project_path_str).to_path_buf());
364
365    let index_dir = config.project_index_dir(&project_path);
366    let index = match SeekrIndex::load(&index_dir) {
367        Ok(idx) => idx,
368        Err(e) => {
369            return JsonRpcResponse::error(
370                id,
371                ERROR_INTERNAL,
372                format!("Failed to load index: {}. Run `seekr-code index` first.", e),
373            );
374        }
375    };
376
377    let start = Instant::now();
378
379    let fused_results = match execute_search(&search_mode, query, &index, config, top_k) {
380        Ok(results) => results,
381        Err(e) => return JsonRpcResponse::error(id, ERROR_INTERNAL, e),
382    };
383
384    let elapsed = start.elapsed();
385
386    let results: Vec<SearchResult> = fused_results
387        .iter()
388        .filter_map(|fused| {
389            index.get_chunk(fused.chunk_id).map(|chunk| SearchResult {
390                chunk: chunk.clone(),
391                score: fused.fused_score,
392                source: search_mode.clone(),
393                matched_lines: fused.matched_lines.clone(),
394            })
395        })
396        .collect();
397
398    // Format results as MCP content
399    let content = format_results_for_mcp(&results, elapsed.as_millis() as u64);
400
401    JsonRpcResponse::success(
402        id,
403        serde_json::json!({
404            "content": [{
405                "type": "text",
406                "text": content,
407            }]
408        }),
409    )
410}
411
412/// Handle `seekr_index` tool call.
413fn handle_tool_index(
414    id: Option<Value>,
415    arguments: &Value,
416    config: &SeekrConfig,
417) -> JsonRpcResponse {
418    let path_str = arguments
419        .get("path")
420        .and_then(|v| v.as_str())
421        .unwrap_or(".");
422
423    let project_path = Path::new(path_str)
424        .canonicalize()
425        .unwrap_or_else(|_| Path::new(path_str).to_path_buf());
426
427    let start = Instant::now();
428
429    // Scan
430    let scan_result = match walk_directory(&project_path, config) {
431        Ok(r) => r,
432        Err(e) => {
433            return JsonRpcResponse::error(id, ERROR_INTERNAL, format!("Scan failed: {}", e));
434        }
435    };
436
437    let entries: Vec<_> = scan_result
438        .entries
439        .iter()
440        .filter(|e| should_index_file(&e.path, e.size, config.max_file_size))
441        .collect();
442
443    // Parse
444    let mut all_chunks: Vec<CodeChunk> = Vec::new();
445    let mut parsed_files = 0;
446
447    for entry in &entries {
448        if let Ok(Some(parse_result)) = chunk_file_from_path(&entry.path) {
449            all_chunks.extend(parse_result.chunks);
450            parsed_files += 1;
451        }
452    }
453
454    if all_chunks.is_empty() {
455        return JsonRpcResponse::success(
456            id,
457            serde_json::json!({
458                "content": [{
459                    "type": "text",
460                    "text": "No code chunks found in the project. Nothing to index.",
461                }]
462            }),
463        );
464    }
465
466    // Embed
467    let summaries: Vec<String> = all_chunks.iter().map(generate_summary).collect();
468
469    let embeddings = match create_embedder(config) {
470        Ok(embedder) => {
471            let batch = BatchEmbedder::new(embedder, config.embedding.batch_size);
472            match batch.embed_all(&summaries) {
473                Ok(e) => e,
474                Err(e) => {
475                    return JsonRpcResponse::error(
476                        id,
477                        ERROR_INTERNAL,
478                        format!("Embedding failed: {}", e),
479                    );
480                }
481            }
482        }
483        Err(e) => {
484            return JsonRpcResponse::error(
485                id,
486                ERROR_INTERNAL,
487                format!("Embedder creation failed: {}", e),
488            );
489        }
490    };
491
492    let embedding_dim = embeddings
493        .first()
494        .map(|e: &Vec<f32>| e.len())
495        .unwrap_or(384);
496
497    // Build and save
498    let index = SeekrIndex::build_from(&all_chunks, &embeddings, embedding_dim);
499    let index_dir = config.project_index_dir(&project_path);
500
501    if let Err(e) = index.save(&index_dir) {
502        return JsonRpcResponse::error(id, ERROR_INTERNAL, format!("Index save failed: {}", e));
503    }
504
505    let elapsed = start.elapsed();
506
507    let message = format!(
508        "Index built successfully!\n\
509         • Project: {}\n\
510         • Files parsed: {}\n\
511         • Code chunks: {}\n\
512         • Embedding dim: {}\n\
513         • Duration: {:.1}s",
514        project_path.display(),
515        parsed_files,
516        all_chunks.len(),
517        embedding_dim,
518        elapsed.as_secs_f64(),
519    );
520
521    JsonRpcResponse::success(
522        id,
523        serde_json::json!({
524            "content": [{
525                "type": "text",
526                "text": message,
527            }]
528        }),
529    )
530}
531
532/// Handle `seekr_status` tool call.
533fn handle_tool_status(
534    id: Option<Value>,
535    arguments: &Value,
536    config: &SeekrConfig,
537) -> JsonRpcResponse {
538    let path_str = arguments
539        .get("path")
540        .and_then(|v| v.as_str())
541        .unwrap_or(".");
542
543    let project_path = Path::new(path_str)
544        .canonicalize()
545        .unwrap_or_else(|_| Path::new(path_str).to_path_buf());
546
547    let index_dir = config.project_index_dir(&project_path);
548    // Check for v2 bincode index first, fall back to v1 JSON index
549    let index_exists =
550        index_dir.join("index.bin").exists() || index_dir.join("index.json").exists();
551
552    let message = if !index_exists {
553        format!(
554            "No index found for {}.\n\
555             Run `seekr-code index {}` to build one.",
556            project_path.display(),
557            project_path.display(),
558        )
559    } else {
560        match SeekrIndex::load(&index_dir) {
561            Ok(index) => format!(
562                "Index status for {}:\n\
563                 • Indexed: yes\n\
564                 • Chunks: {}\n\
565                 • Embedding dim: {}\n\
566                 • Version: {}\n\
567                 • Index dir: {}",
568                project_path.display(),
569                index.chunk_count,
570                index.embedding_dim,
571                index.version,
572                index_dir.display(),
573            ),
574            Err(e) => format!(
575                "Index found but could not load: {}\n\
576                 Try rebuilding with `seekr-code index {}`.",
577                e,
578                project_path.display(),
579            ),
580        }
581    };
582
583    JsonRpcResponse::success(
584        id,
585        serde_json::json!({
586            "content": [{
587                "type": "text",
588                "text": message,
589            }]
590        }),
591    )
592}
593
594// ============================================================
595// Shared helpers
596// ============================================================
597
598use crate::search::fusion::FusedResult;
599
600/// Execute search across different modes — shared by MCP and HTTP.
601fn execute_search(
602    mode: &SearchMode,
603    query: &str,
604    index: &SeekrIndex,
605    config: &SeekrConfig,
606    top_k: usize,
607) -> Result<Vec<FusedResult>, String> {
608    match mode {
609        SearchMode::Text => {
610            let options = TextSearchOptions {
611                case_sensitive: false,
612                context_lines: config.search.context_lines,
613                top_k,
614            };
615            let results = search_text_regex(index, query, &options).map_err(|e| e.to_string())?;
616            Ok(fuse_text_only(&results, top_k))
617        }
618        SearchMode::Semantic => {
619            let embedder = create_embedder(config)?;
620            let options = SemanticSearchOptions {
621                top_k,
622                score_threshold: config.search.score_threshold,
623            };
624            let results = search_semantic(index, query, embedder.as_ref(), &options)
625                .map_err(|e| e.to_string())?;
626            Ok(fuse_semantic_only(&results, top_k))
627        }
628        SearchMode::Hybrid => {
629            let text_options = TextSearchOptions {
630                case_sensitive: false,
631                context_lines: config.search.context_lines,
632                top_k,
633            };
634            let text_results =
635                search_text_regex(index, query, &text_options).map_err(|e| e.to_string())?;
636
637            let embedder = create_embedder(config)?;
638            let semantic_options = SemanticSearchOptions {
639                top_k,
640                score_threshold: config.search.score_threshold,
641            };
642            let semantic_results =
643                search_semantic(index, query, embedder.as_ref(), &semantic_options)
644                    .map_err(|e| e.to_string())?;
645
646            // Try AST pattern search — silently degrade if query isn't a valid AST pattern
647            let ast_results = search_ast_pattern(index, query, top_k).unwrap_or_default();
648
649            if ast_results.is_empty() {
650                // 2-way fusion when no AST matches
651                Ok(rrf_fuse(
652                    &text_results,
653                    &semantic_results,
654                    config.search.rrf_k,
655                    top_k,
656                ))
657            } else {
658                // 3-way fusion with AST
659                Ok(rrf_fuse_three(
660                    &text_results,
661                    &semantic_results,
662                    &ast_results,
663                    config.search.rrf_k,
664                    top_k,
665                ))
666            }
667        }
668        SearchMode::Ast => {
669            let results = search_ast_pattern(index, query, top_k).map_err(|e| e.to_string())?;
670            Ok(fuse_ast_only(&results, top_k))
671        }
672    }
673}
674
675/// Create an embedder. Falls back to DummyEmbedder if ONNX is unavailable.
676fn create_embedder(config: &SeekrConfig) -> Result<Box<dyn Embedder>, String> {
677    match crate::embedder::onnx::OnnxEmbedder::new(&config.model_dir) {
678        Ok(embedder) => Ok(Box::new(embedder)),
679        Err(_) => {
680            tracing::warn!("ONNX embedder unavailable, using dummy embedder");
681            Ok(Box::new(DummyEmbedder::new(384)))
682        }
683    }
684}
685
686/// Format search results into a readable text block for MCP tool output.
687fn format_results_for_mcp(results: &[SearchResult], duration_ms: u64) -> String {
688    if results.is_empty() {
689        return "No results found.".to_string();
690    }
691
692    let mut output = format!("Found {} results in {}ms:\n\n", results.len(), duration_ms);
693
694    for (i, result) in results.iter().enumerate() {
695        let name = result.chunk.name.as_deref().unwrap_or("<unnamed>");
696        let file_path = result.chunk.file_path.display();
697        let line_start = result.chunk.line_range.start + 1;
698        let line_end = result.chunk.line_range.end;
699
700        output.push_str(&format!(
701            "---\n[{}] {} ({}) in {} L{}-L{} (score: {:.4})\n",
702            i + 1,
703            name,
704            result.chunk.kind,
705            file_path,
706            line_start,
707            line_end,
708            result.score,
709        ));
710
711        // Show signature or first few lines
712        if let Some(ref sig) = result.chunk.signature {
713            output.push_str(&format!("  Signature: {}\n", sig));
714        }
715
716        // Show first 5 lines of body
717        let body_preview: String = result
718            .chunk
719            .body
720            .lines()
721            .take(5)
722            .collect::<Vec<&str>>()
723            .join("\n");
724        output.push_str(&format!("```\n{}\n```\n\n", body_preview));
725    }
726
727    output
728}