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