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