intent_engine/mcp/
server.rs

1//! Intent-Engine MCP Server (Rust Implementation)
2//!
3//! This is a native Rust implementation of the MCP (Model Context Protocol) server
4//! that provides a JSON-RPC 2.0 interface for AI assistants to interact with
5//! intent-engine's task management capabilities.
6//!
7//! Unlike the Python wrapper (mcp-server.py), this implementation directly uses
8//! the Rust library functions, avoiding subprocess overhead and improving performance.
9
10use crate::error::IntentError;
11use crate::events::EventManager;
12use crate::project::ProjectContext;
13use crate::report::ReportManager;
14use crate::tasks::TaskManager;
15use crate::workspace::WorkspaceManager;
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18use std::io;
19
20#[derive(Debug, Deserialize)]
21struct JsonRpcRequest {
22    jsonrpc: String,
23    id: Option<Value>,
24    method: String,
25    params: Option<Value>,
26}
27
28#[derive(Debug, Serialize)]
29struct JsonRpcResponse {
30    jsonrpc: String,
31    id: Option<Value>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    result: Option<Value>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    error: Option<JsonRpcError>,
36}
37
38#[derive(Debug, Serialize)]
39struct JsonRpcError {
40    code: i32,
41    message: String,
42}
43
44#[derive(Debug, Deserialize)]
45struct ToolCallParams {
46    name: String,
47    arguments: Value,
48}
49
50/// MCP Tool Schema
51const MCP_TOOLS: &str = include_str!("../../mcp-server.json");
52
53/// Run the MCP server
54/// This is the main entry point for MCP server mode
55pub async fn run() -> io::Result<()> {
56    // Load project context - only load existing projects, don't initialize new ones
57    // This prevents blocking when MCP server is started outside an intent-engine project
58    let ctx = match ProjectContext::load().await {
59        Ok(ctx) => ctx,
60        Err(IntentError::NotAProject) => {
61            // Error message removed to prevent Windows stderr buffer blocking
62            // The error is returned through the proper error channel below
63            return Err(io::Error::other(
64                "MCP server must be run within an intent-engine project directory. Run 'ie workspace init' to create a project, or cd to an existing project.".to_string(),
65            ));
66        },
67        Err(e) => {
68            return Err(io::Error::other(format!(
69                "Failed to load project context: {}",
70                e
71            )));
72        },
73    };
74
75    // Auto-start Dashboard if not running (fully async, non-blocking)
76    // Skip in test environments to avoid port conflicts and slowdowns
77    // NOTE: All eprintln! output removed to prevent Windows stderr buffer blocking
78    let skip_dashboard = std::env::var("INTENT_ENGINE_NO_DASHBOARD_AUTOSTART").is_ok();
79
80    if !skip_dashboard && !is_dashboard_running().await {
81        // Spawn Dashboard startup in background task - don't block MCP Server initialization
82        tokio::spawn(async {
83            let _ = start_dashboard_background().await;
84            // Silently fail - MCP server can work without Dashboard
85        });
86    }
87
88    // Register MCP connection in the global registry (non-blocking)
89    let project_root = ctx.root.clone();
90    tokio::task::spawn_blocking(move || {
91        let _ = register_mcp_connection(&project_root);
92        // Silently fail - not critical for MCP server operation
93    });
94
95    // Start heartbeat task
96    let project_path = ctx.root.clone();
97    let heartbeat_handle = tokio::spawn(async move {
98        heartbeat_task(project_path).await;
99    });
100
101    // Run the MCP server
102    let result = run_server().await;
103
104    // Clean up: unregister MCP connection
105    let _ = unregister_mcp_connection(&ctx.root);
106    // Silently fail - cleanup error not critical
107
108    // Cancel heartbeat task
109    heartbeat_handle.abort();
110
111    result
112}
113
114async fn run_server() -> io::Result<()> {
115    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
116
117    let stdin = tokio::io::stdin();
118    let mut stdout = tokio::io::stdout();
119    let reader = BufReader::new(stdin);
120    let mut lines = reader.lines();
121
122    while let Some(line) = lines.next_line().await? {
123        if line.trim().is_empty() {
124            continue;
125        }
126
127        let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
128            Ok(request) => {
129                // Handle notifications (no id = no response needed)
130                if request.id.is_none() {
131                    handle_notification(&request).await;
132                    continue; // Skip sending response for notifications
133                }
134                handle_request(request).await
135            },
136            Err(e) => JsonRpcResponse {
137                jsonrpc: "2.0".to_string(),
138                id: None,
139                result: None,
140                error: Some(JsonRpcError {
141                    code: -32700,
142                    message: format!("Parse error: {}", e),
143                }),
144            },
145        };
146
147        let response_json = serde_json::to_string(&response)?;
148        stdout.write_all(response_json.as_bytes()).await?;
149        stdout.write_all(b"\n").await?;
150        stdout.flush().await?;
151    }
152
153    Ok(())
154}
155
156async fn handle_notification(request: &JsonRpcRequest) {
157    // Handle MCP notifications (no response required)
158    // All eprintln! removed to prevent Windows stderr buffer blocking
159    match request.method.as_str() {
160        "initialized" | "notifications/cancelled" => {
161            // Silently acknowledge notification
162        },
163        _ => {
164            // Unknown notification - silently ignore
165        },
166    }
167}
168
169async fn handle_request(request: JsonRpcRequest) -> JsonRpcResponse {
170    // Validate JSON-RPC version
171    if request.jsonrpc != "2.0" {
172        return JsonRpcResponse {
173            jsonrpc: "2.0".to_string(),
174            id: request.id,
175            result: None,
176            error: Some(JsonRpcError {
177                code: -32600,
178                message: format!("Invalid JSON-RPC version: {}", request.jsonrpc),
179            }),
180        };
181    }
182
183    let result = match request.method.as_str() {
184        "initialize" => handle_initialize(request.params),
185        "ping" => Ok(json!({})), // Ping response for connection keep-alive
186        "tools/list" => handle_tools_list(),
187        "tools/call" => handle_tool_call(request.params).await,
188        _ => Err(format!("Method not found: {}", request.method)),
189    };
190
191    match result {
192        Ok(value) => JsonRpcResponse {
193            jsonrpc: "2.0".to_string(),
194            id: request.id,
195            result: Some(value),
196            error: None,
197        },
198        Err(message) => JsonRpcResponse {
199            jsonrpc: "2.0".to_string(),
200            id: request.id,
201            result: None,
202            error: Some(JsonRpcError {
203                code: -32000,
204                message,
205            }),
206        },
207    }
208}
209
210fn handle_initialize(_params: Option<Value>) -> Result<Value, String> {
211    // MCP initialize handshake
212    // Return server capabilities and info per MCP specification
213    Ok(json!({
214        "protocolVersion": "2024-11-05",
215        "capabilities": {
216            "tools": {
217                "listChanged": false  // Static tool list, no dynamic changes
218            }
219        },
220        "serverInfo": {
221            "name": "intent-engine",
222            "version": env!("CARGO_PKG_VERSION")
223        }
224    }))
225}
226
227fn handle_tools_list() -> Result<Value, String> {
228    let config: Value = serde_json::from_str(MCP_TOOLS)
229        .map_err(|e| format!("Failed to parse MCP tools schema: {}", e))?;
230
231    Ok(json!({
232        "tools": config.get("tools").unwrap_or(&json!([]))
233    }))
234}
235
236async fn handle_tool_call(params: Option<Value>) -> Result<Value, String> {
237    let params: ToolCallParams = serde_json::from_value(params.unwrap_or(json!({})))
238        .map_err(|e| format!("Invalid tool call parameters: {}", e))?;
239
240    let result = match params.name.as_str() {
241        "task_add" => handle_task_add(params.arguments).await,
242        "task_add_dependency" => handle_task_add_dependency(params.arguments).await,
243        "task_start" => handle_task_start(params.arguments).await,
244        "task_pick_next" => handle_task_pick_next(params.arguments).await,
245        "task_spawn_subtask" => handle_task_spawn_subtask(params.arguments).await,
246        "task_switch" => handle_task_switch(params.arguments).await,
247        "task_done" => handle_task_done(params.arguments).await,
248        "task_update" => handle_task_update(params.arguments).await,
249        "task_list" => handle_task_list(params.arguments).await,
250        "task_get" => handle_task_get(params.arguments).await,
251        "task_context" => handle_task_context(params.arguments).await,
252        "task_delete" => handle_task_delete(params.arguments).await,
253        "event_add" => handle_event_add(params.arguments).await,
254        "event_list" => handle_event_list(params.arguments).await,
255        "search" => handle_unified_search(params.arguments).await,
256        "current_task_get" => handle_current_task_get(params.arguments).await,
257        "report_generate" => handle_report_generate(params.arguments).await,
258        _ => Err(format!("Unknown tool: {}", params.name)),
259    }?;
260
261    Ok(json!({
262        "content": [{
263            "type": "text",
264            "text": serde_json::to_string_pretty(&result)
265                .unwrap_or_else(|_| "{}".to_string())
266        }]
267    }))
268}
269
270// Tool Handlers
271
272async fn handle_task_add(args: Value) -> Result<Value, String> {
273    // Improved parameter validation with specific error messages
274    let name = match args.get("name") {
275        None => return Err("Missing required parameter: name".to_string()),
276        Some(value) => {
277            if value.is_null() {
278                return Err("Parameter 'name' cannot be null".to_string());
279            }
280            match value.as_str() {
281                Some(s) if s.trim().is_empty() => {
282                    return Err("Parameter 'name' cannot be empty".to_string());
283                },
284                Some(s) => s,
285                None => return Err(format!("Parameter 'name' must be a string, got: {}", value)),
286            }
287        },
288    };
289
290    let spec = args.get("spec").and_then(|v| v.as_str());
291    let parent_id = args.get("parent_id").and_then(|v| v.as_i64());
292
293    let ctx = ProjectContext::load_or_init()
294        .await
295        .map_err(|e| format!("Failed to load project context: {}", e))?;
296
297    let task_mgr = TaskManager::new(&ctx.pool);
298    let task = task_mgr
299        .add_task(name, spec, parent_id)
300        .await
301        .map_err(|e| format!("Failed to add task: {}", e))?;
302
303    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
304}
305
306async fn handle_task_add_dependency(args: Value) -> Result<Value, String> {
307    let blocked_task_id = args
308        .get("blocked_task_id")
309        .and_then(|v| v.as_i64())
310        .ok_or("Missing required parameter: blocked_task_id")?;
311
312    let blocking_task_id = args
313        .get("blocking_task_id")
314        .and_then(|v| v.as_i64())
315        .ok_or("Missing required parameter: blocking_task_id")?;
316
317    let ctx = ProjectContext::load_or_init()
318        .await
319        .map_err(|e| format!("Failed to load project context: {}", e))?;
320
321    let dependency =
322        crate::dependencies::add_dependency(&ctx.pool, blocking_task_id, blocked_task_id)
323            .await
324            .map_err(|e| format!("Failed to add dependency: {}", e))?;
325
326    serde_json::to_value(&dependency).map_err(|e| format!("Serialization error: {}", e))
327}
328
329async fn handle_task_start(args: Value) -> Result<Value, String> {
330    let task_id = args
331        .get("task_id")
332        .and_then(|v| v.as_i64())
333        .ok_or("Missing required parameter: task_id")?;
334
335    let with_events = args
336        .get("with_events")
337        .and_then(|v| v.as_bool())
338        .unwrap_or(true);
339
340    let ctx = ProjectContext::load_or_init()
341        .await
342        .map_err(|e| format!("Failed to load project context: {}", e))?;
343
344    let task_mgr = TaskManager::new(&ctx.pool);
345    let task = task_mgr
346        .start_task(task_id, with_events)
347        .await
348        .map_err(|e| format!("Failed to start task: {}", e))?;
349
350    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
351}
352
353async fn handle_task_pick_next(args: Value) -> Result<Value, String> {
354    let _max_count = args.get("max_count").and_then(|v| v.as_i64());
355    let _capacity = args.get("capacity").and_then(|v| v.as_i64());
356
357    let ctx = ProjectContext::load_or_init()
358        .await
359        .map_err(|e| format!("Failed to load project context: {}", e))?;
360
361    let task_mgr = TaskManager::new(&ctx.pool);
362    let response = task_mgr
363        .pick_next()
364        .await
365        .map_err(|e| format!("Failed to pick next task: {}", e))?;
366
367    serde_json::to_value(&response).map_err(|e| format!("Serialization error: {}", e))
368}
369
370async fn handle_task_spawn_subtask(args: Value) -> Result<Value, String> {
371    let name = args
372        .get("name")
373        .and_then(|v| v.as_str())
374        .ok_or("Missing required parameter: name")?;
375
376    let spec = args.get("spec").and_then(|v| v.as_str());
377
378    let ctx = ProjectContext::load_or_init()
379        .await
380        .map_err(|e| format!("Failed to load project context: {}", e))?;
381
382    let task_mgr = TaskManager::new(&ctx.pool);
383    let subtask = task_mgr
384        .spawn_subtask(name, spec)
385        .await
386        .map_err(|e| format!("Failed to spawn subtask: {}", e))?;
387
388    serde_json::to_value(&subtask).map_err(|e| format!("Serialization error: {}", e))
389}
390
391async fn handle_task_switch(args: Value) -> Result<Value, String> {
392    let task_id = args
393        .get("task_id")
394        .and_then(|v| v.as_i64())
395        .ok_or("Missing required parameter: task_id")?;
396
397    let ctx = ProjectContext::load_or_init()
398        .await
399        .map_err(|e| format!("Failed to load project context: {}", e))?;
400
401    let task_mgr = TaskManager::new(&ctx.pool);
402    let task = task_mgr
403        .switch_to_task(task_id)
404        .await
405        .map_err(|e| format!("Failed to switch task: {}", e))?;
406
407    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
408}
409
410async fn handle_task_done(args: Value) -> Result<Value, String> {
411    let task_id = args.get("task_id").and_then(|v| v.as_i64());
412
413    let ctx = ProjectContext::load_or_init()
414        .await
415        .map_err(|e| format!("Failed to load project context: {}", e))?;
416
417    let task_mgr = TaskManager::new(&ctx.pool);
418
419    // If task_id is provided, set it as current first
420    if let Some(id) = task_id {
421        let workspace_mgr = WorkspaceManager::new(&ctx.pool);
422        workspace_mgr
423            .set_current_task(id)
424            .await
425            .map_err(|e| format!("Failed to set current task: {}", e))?;
426    }
427
428    let task = task_mgr
429        .done_task()
430        .await
431        .map_err(|e| format!("Failed to mark task as done: {}", e))?;
432
433    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
434}
435
436async fn handle_task_update(args: Value) -> Result<Value, String> {
437    let task_id = args
438        .get("task_id")
439        .and_then(|v| v.as_i64())
440        .ok_or("Missing required parameter: task_id")?;
441
442    let name = args.get("name").and_then(|v| v.as_str());
443    let spec = args.get("spec").and_then(|v| v.as_str());
444    let status = args.get("status").and_then(|v| v.as_str());
445    let complexity = args
446        .get("complexity")
447        .and_then(|v| v.as_i64())
448        .map(|v| v as i32);
449    let priority = match args.get("priority").and_then(|v| v.as_str()) {
450        Some(p) => Some(
451            crate::priority::PriorityLevel::parse_to_int(p)
452                .map_err(|e| format!("Invalid priority: {}", e))?,
453        ),
454        None => None,
455    };
456    let parent_id = args.get("parent_id").and_then(|v| v.as_i64()).map(Some);
457
458    let ctx = ProjectContext::load_or_init()
459        .await
460        .map_err(|e| format!("Failed to load project context: {}", e))?;
461
462    let task_mgr = TaskManager::new(&ctx.pool);
463    let task = task_mgr
464        .update_task(task_id, name, spec, parent_id, status, complexity, priority)
465        .await
466        .map_err(|e| format!("Failed to update task: {}", e))?;
467
468    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
469}
470
471async fn handle_task_list(args: Value) -> Result<Value, String> {
472    let status = args.get("status").and_then(|v| v.as_str());
473    let parent = args.get("parent").and_then(|v| v.as_str());
474
475    let parent_opt = parent.map(|p| {
476        if p == "null" {
477            None
478        } else {
479            p.parse::<i64>().ok()
480        }
481    });
482
483    let ctx = ProjectContext::load()
484        .await
485        .map_err(|e| format!("Failed to load project context: {}", e))?;
486
487    let task_mgr = TaskManager::new(&ctx.pool);
488    let tasks = task_mgr
489        .find_tasks(status, parent_opt)
490        .await
491        .map_err(|e| format!("Failed to list tasks: {}", e))?;
492
493    serde_json::to_value(&tasks).map_err(|e| format!("Serialization error: {}", e))
494}
495
496async fn handle_task_get(args: Value) -> Result<Value, String> {
497    let task_id = args
498        .get("task_id")
499        .and_then(|v| v.as_i64())
500        .ok_or("Missing required parameter: task_id")?;
501
502    let with_events = args
503        .get("with_events")
504        .and_then(|v| v.as_bool())
505        .unwrap_or(false);
506
507    let ctx = ProjectContext::load()
508        .await
509        .map_err(|e| format!("Failed to load project context: {}", e))?;
510
511    let task_mgr = TaskManager::new(&ctx.pool);
512
513    if with_events {
514        let task = task_mgr
515            .get_task_with_events(task_id)
516            .await
517            .map_err(|e| format!("Failed to get task: {}", e))?;
518        serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
519    } else {
520        let task = task_mgr
521            .get_task(task_id)
522            .await
523            .map_err(|e| format!("Failed to get task: {}", e))?;
524        serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
525    }
526}
527
528async fn handle_task_context(args: Value) -> Result<Value, String> {
529    // Get task_id from args, or fall back to current task
530    let task_id = if let Some(id) = args.get("task_id").and_then(|v| v.as_i64()) {
531        id
532    } else {
533        // Fall back to current_task_id if no task_id provided
534        let ctx = ProjectContext::load()
535            .await
536            .map_err(|e| format!("Failed to load project context: {}", e))?;
537
538        let current_task_id: Option<String> =
539            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
540                .fetch_optional(&ctx.pool)
541                .await
542                .map_err(|e| format!("Database error: {}", e))?;
543
544        current_task_id
545            .and_then(|s| s.parse::<i64>().ok())
546            .ok_or_else(|| {
547                "No current task is set and task_id was not provided. \
548                 Use task_start or task_switch to set a task first, or provide task_id parameter."
549                    .to_string()
550            })?
551    };
552
553    let ctx = ProjectContext::load()
554        .await
555        .map_err(|e| format!("Failed to load project context: {}", e))?;
556
557    let task_mgr = TaskManager::new(&ctx.pool);
558    let context = task_mgr
559        .get_task_context(task_id)
560        .await
561        .map_err(|e| format!("Failed to get task context: {}", e))?;
562
563    serde_json::to_value(&context).map_err(|e| format!("Serialization error: {}", e))
564}
565
566async fn handle_task_delete(args: Value) -> Result<Value, String> {
567    let task_id = args
568        .get("task_id")
569        .and_then(|v| v.as_i64())
570        .ok_or("Missing required parameter: task_id")?;
571
572    let ctx = ProjectContext::load()
573        .await
574        .map_err(|e| format!("Failed to load project context: {}", e))?;
575
576    let task_mgr = TaskManager::new(&ctx.pool);
577    task_mgr
578        .delete_task(task_id)
579        .await
580        .map_err(|e| format!("Failed to delete task: {}", e))?;
581
582    Ok(json!({"success": true, "deleted_task_id": task_id}))
583}
584
585async fn handle_event_add(args: Value) -> Result<Value, String> {
586    let task_id = args.get("task_id").and_then(|v| v.as_i64());
587
588    let event_type = args
589        .get("event_type")
590        .and_then(|v| v.as_str())
591        .ok_or("Missing required parameter: event_type")?;
592
593    let data = args
594        .get("data")
595        .and_then(|v| v.as_str())
596        .ok_or("Missing required parameter: data")?;
597
598    let ctx = ProjectContext::load_or_init()
599        .await
600        .map_err(|e| format!("Failed to load project context: {}", e))?;
601
602    // Determine the target task ID
603    let target_task_id = if let Some(id) = task_id {
604        id
605    } else {
606        // Fall back to current_task_id
607        let current_task_id: Option<String> =
608            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
609                .fetch_optional(&ctx.pool)
610                .await
611                .map_err(|e| format!("Database error: {}", e))?;
612
613        current_task_id
614            .and_then(|s| s.parse::<i64>().ok())
615            .ok_or_else(|| {
616                "No current task is set and task_id was not provided. \
617                 Use task_start or task_switch to set a task first."
618                    .to_string()
619            })?
620    };
621
622    let event_mgr = EventManager::new(&ctx.pool);
623    let event = event_mgr
624        .add_event(target_task_id, event_type, data)
625        .await
626        .map_err(|e| format!("Failed to add event: {}", e))?;
627
628    serde_json::to_value(&event).map_err(|e| format!("Serialization error: {}", e))
629}
630
631async fn handle_event_list(args: Value) -> Result<Value, String> {
632    let task_id = args.get("task_id").and_then(|v| v.as_i64());
633
634    let limit = args.get("limit").and_then(|v| v.as_i64());
635    let log_type = args
636        .get("type")
637        .and_then(|v| v.as_str())
638        .map(|s| s.to_string());
639    let since = args
640        .get("since")
641        .and_then(|v| v.as_str())
642        .map(|s| s.to_string());
643
644    let ctx = ProjectContext::load()
645        .await
646        .map_err(|e| format!("Failed to load project context: {}", e))?;
647
648    let event_mgr = EventManager::new(&ctx.pool);
649    let events = event_mgr
650        .list_events(task_id, limit, log_type, since)
651        .await
652        .map_err(|e| format!("Failed to list events: {}", e))?;
653
654    serde_json::to_value(&events).map_err(|e| format!("Serialization error: {}", e))
655}
656
657async fn handle_unified_search(args: Value) -> Result<Value, String> {
658    use crate::search::SearchManager;
659
660    let query = args
661        .get("query")
662        .and_then(|v| v.as_str())
663        .ok_or("Missing required parameter: query")?;
664
665    let include_tasks = args
666        .get("include_tasks")
667        .and_then(|v| v.as_bool())
668        .unwrap_or(true);
669
670    let include_events = args
671        .get("include_events")
672        .and_then(|v| v.as_bool())
673        .unwrap_or(true);
674
675    let limit = args.get("limit").and_then(|v| v.as_i64());
676
677    let ctx = ProjectContext::load()
678        .await
679        .map_err(|e| format!("Failed to load project context: {}", e))?;
680
681    let search_mgr = SearchManager::new(&ctx.pool);
682    let results = search_mgr
683        .unified_search(query, include_tasks, include_events, limit)
684        .await
685        .map_err(|e| format!("Failed to perform unified search: {}", e))?;
686
687    serde_json::to_value(&results).map_err(|e| format!("Serialization error: {}", e))
688}
689
690async fn handle_current_task_get(_args: Value) -> Result<Value, String> {
691    let ctx = ProjectContext::load()
692        .await
693        .map_err(|e| format!("Failed to load project context: {}", e))?;
694
695    let workspace_mgr = WorkspaceManager::new(&ctx.pool);
696    let response = workspace_mgr
697        .get_current_task()
698        .await
699        .map_err(|e| format!("Failed to get current task: {}", e))?;
700
701    serde_json::to_value(&response).map_err(|e| format!("Serialization error: {}", e))
702}
703
704async fn handle_report_generate(args: Value) -> Result<Value, String> {
705    let since = args.get("since").and_then(|v| v.as_str()).map(String::from);
706    let status = args
707        .get("status")
708        .and_then(|v| v.as_str())
709        .map(String::from);
710    let filter_name = args
711        .get("filter_name")
712        .and_then(|v| v.as_str())
713        .map(String::from);
714    let filter_spec = args
715        .get("filter_spec")
716        .and_then(|v| v.as_str())
717        .map(String::from);
718    let summary_only = args
719        .get("summary_only")
720        .and_then(|v| v.as_bool())
721        .unwrap_or(true);
722
723    let ctx = ProjectContext::load()
724        .await
725        .map_err(|e| format!("Failed to load project context: {}", e))?;
726
727    let report_mgr = ReportManager::new(&ctx.pool);
728    let report = report_mgr
729        .generate_report(since, status, filter_name, filter_spec, summary_only)
730        .await
731        .map_err(|e| format!("Failed to generate report: {}", e))?;
732
733    serde_json::to_value(&report).map_err(|e| format!("Serialization error: {}", e))
734}
735
736// ============================================================================
737// MCP Connection Registry Integration
738// ============================================================================
739
740/// Register this MCP server instance with the global project registry
741fn register_mcp_connection(project_path: &std::path::Path) -> anyhow::Result<()> {
742    use crate::dashboard::registry::ProjectRegistry;
743
744    let mut registry = ProjectRegistry::load()?;
745
746    // Detect agent type from environment (Claude Code sets specific env vars)
747    let agent_name = detect_agent_type();
748
749    // Normalize the path to handle symlinks (e.g., ~/prj -> /mnt/d/prj)
750    let normalized_path = project_path
751        .canonicalize()
752        .unwrap_or_else(|_| project_path.to_path_buf());
753
754    // Register MCP connection - this will create a project entry if none exists
755    registry.register_mcp_connection(&normalized_path, agent_name)?;
756
757    // Silently register - eprintln! removed to prevent Windows stderr buffer blocking
758
759    Ok(())
760}
761
762/// Unregister this MCP server instance from the global project registry
763fn unregister_mcp_connection(project_path: &std::path::Path) -> anyhow::Result<()> {
764    use crate::dashboard::registry::ProjectRegistry;
765
766    let mut registry = ProjectRegistry::load()?;
767
768    // Normalize the path to handle symlinks (e.g., ~/prj -> /mnt/d/prj)
769    let normalized_path = project_path
770        .canonicalize()
771        .unwrap_or_else(|_| project_path.to_path_buf());
772
773    registry.unregister_mcp_connection(&normalized_path)?;
774
775    // Silently unregister - eprintln! removed to prevent Windows stderr buffer blocking
776
777    Ok(())
778}
779
780/// Heartbeat task that keeps the MCP connection alive
781async fn heartbeat_task(project_path: std::path::PathBuf) {
782    use crate::dashboard::registry::ProjectRegistry;
783
784    let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
785
786    loop {
787        interval.tick().await;
788
789        // Update heartbeat (non-blocking)
790        let path = project_path.clone();
791        tokio::task::spawn_blocking(move || {
792            // Normalize the path to handle symlinks (e.g., ~/prj -> /mnt/d/prj)
793            let normalized_path = path.canonicalize().unwrap_or_else(|_| path.clone());
794
795            if let Ok(mut registry) = ProjectRegistry::load() {
796                let _ = registry.update_mcp_heartbeat(&normalized_path);
797                // Silently fail - heartbeat error not critical
798            }
799        });
800    }
801}
802
803/// Detect the agent type from environment variables
804fn detect_agent_type() -> Option<String> {
805    // Check for Claude Code specific environment variables
806    if std::env::var("CLAUDE_CODE_VERSION").is_ok() {
807        return Some("claude-code".to_string());
808    }
809
810    // Check for Claude Desktop
811    if std::env::var("CLAUDE_DESKTOP").is_ok() {
812        return Some("claude-desktop".to_string());
813    }
814
815    // Generic MCP client
816    Some("mcp-client".to_string())
817}
818
819/// Check if Dashboard is running by testing the health endpoint
820async fn is_dashboard_running() -> bool {
821    // Use a timeout to prevent blocking - Dashboard check should be fast
822    match tokio::time::timeout(
823        std::time::Duration::from_millis(100), // Very short timeout
824        tokio::net::TcpStream::connect("127.0.0.1:11391"),
825    )
826    .await
827    {
828        Ok(Ok(_)) => true,
829        Ok(Err(_)) => false,
830        Err(_) => {
831            // Timeout occurred - assume dashboard is not running
832            false
833        },
834    }
835}
836
837/// Start Dashboard in background using `ie dashboard start` command
838async fn start_dashboard_background() -> io::Result<()> {
839    use tokio::process::Command;
840
841    // Get the current executable path
842    let current_exe = std::env::current_exe()?;
843
844    // Spawn Dashboard process in foreground mode
845    // IMPORTANT: Must keep Child handle alive to prevent blocking on Windows
846    let mut child = Command::new(current_exe)
847        .arg("dashboard")
848        .arg("start")
849        .arg("--foreground")
850        .stdin(std::process::Stdio::null())
851        .stdout(std::process::Stdio::null())
852        .stderr(std::process::Stdio::null())
853        .kill_on_drop(false) // Don't kill Dashboard when this function returns
854        .spawn()?;
855
856    // Wait for Dashboard to start (check health endpoint)
857    for _ in 0..10 {
858        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
859        if is_dashboard_running().await {
860            // Spawn a background task to hold the Child handle
861            // This prevents the process from being reaped and blocking the parent
862            tokio::spawn(async move {
863                let _ = child.wait().await;
864            });
865            return Ok(());
866        }
867    }
868
869    Err(io::Error::other(
870        "Dashboard failed to start within 5 seconds",
871    ))
872}
873
874#[cfg(test)]
875#[path = "server_tests.rs"]
876mod tests;