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