1use 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#[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#[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#[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
94const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
99const SEEKR_MCP_NAME: &str = "seekr-code";
100const SEEKR_MCP_VERSION: &str = env!("CARGO_PKG_VERSION");
101
102const ERROR_PARSE: i32 = -32700;
104const ERROR_INVALID_REQUEST: i32 = -32600;
105const ERROR_METHOD_NOT_FOUND: i32 = -32601;
106const ERROR_INTERNAL: i32 = -32603;
107
108pub 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
164fn 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
172fn handle_request(request: &JsonRpcRequest, config: &SeekrConfig) -> JsonRpcResponse {
174 match request.method.as_str() {
175 "initialize" => handle_initialize(request),
177 "initialized" => {
178 JsonRpcResponse::success(request.id.clone(), Value::Null)
181 }
182 "ping" => JsonRpcResponse::success(request.id.clone(), serde_json::json!({})),
183
184 "tools/list" => handle_tools_list(request),
186
187 "tools/call" => handle_tools_call(request, config),
189
190 _ => JsonRpcResponse::error(
192 request.id.clone(),
193 ERROR_METHOD_NOT_FOUND,
194 format!("Method not found: {}", request.method),
195 ),
196 }
197}
198
199fn 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
219fn 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
295fn 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
329fn 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 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
412fn 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 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 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 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 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
532fn 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 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
594use crate::search::fusion::FusedResult;
599
600fn 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 let ast_results = search_ast_pattern(index, query, top_k).unwrap_or_default();
648
649 if ast_results.is_empty() {
650 Ok(rrf_fuse(
652 &text_results,
653 &semantic_results,
654 config.search.rrf_k,
655 top_k,
656 ))
657 } else {
658 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
675fn 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
686fn 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 if let Some(ref sig) = result.chunk.signature {
713 output.push_str(&format!(" Signature: {}\n", sig));
714 }
715
716 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}