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::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#[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#[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#[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
92const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
97const SEEKR_MCP_NAME: &str = "seekr-code";
98const SEEKR_MCP_VERSION: &str = env!("CARGO_PKG_VERSION");
99
100const ERROR_PARSE: i32 = -32700;
102const ERROR_INVALID_REQUEST: i32 = -32600;
103const ERROR_METHOD_NOT_FOUND: i32 = -32601;
104const ERROR_INTERNAL: i32 = -32603;
105
106pub 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
166fn 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
174fn handle_request(request: &JsonRpcRequest, config: &SeekrConfig) -> JsonRpcResponse {
176 match request.method.as_str() {
177 "initialize" => handle_initialize(request),
179 "initialized" => {
180 JsonRpcResponse::success(request.id.clone(), Value::Null)
183 }
184 "ping" => JsonRpcResponse::success(
185 request.id.clone(),
186 serde_json::json!({}),
187 ),
188
189 "tools/list" => handle_tools_list(request),
191
192 "tools/call" => handle_tools_call(request, config),
194
195 _ => JsonRpcResponse::error(
197 request.id.clone(),
198 ERROR_METHOD_NOT_FOUND,
199 format!("Method not found: {}", request.method),
200 ),
201 }
202}
203
204fn 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
224fn 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
300fn 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
331fn 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 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
414fn 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 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 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 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 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
531fn 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
591use crate::search::fusion::FusedResult;
596
597fn 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
659fn 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
670fn 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 if let Some(ref sig) = result.chunk.signature {
697 output.push_str(&format!(" Signature: {}\n", sig));
698 }
699
700 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}