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