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