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