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
331    let ctx = ProjectContext::load_or_init()
332        .await
333        .map_err(|e| format!("Failed to load project context: {}", e))?;
334
335    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
336        TaskManager::with_mcp_notifier(
337            &ctx.pool,
338            ctx.root.to_string_lossy().to_string(),
339            notifier.clone(),
340        )
341    } else {
342        TaskManager::new(&ctx.pool)
343    };
344    let task = task_mgr
345        .add_task(name, spec, parent_id)
346        .await
347        .map_err(|e| format!("Failed to add task: {}", e))?;
348
349    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
350}
351
352async fn handle_task_add_dependency(args: Value) -> Result<Value, String> {
353    let blocked_task_id = args
354        .get("blocked_task_id")
355        .and_then(|v| v.as_i64())
356        .ok_or("Missing required parameter: blocked_task_id")?;
357
358    let blocking_task_id = args
359        .get("blocking_task_id")
360        .and_then(|v| v.as_i64())
361        .ok_or("Missing required parameter: blocking_task_id")?;
362
363    let ctx = ProjectContext::load_or_init()
364        .await
365        .map_err(|e| format!("Failed to load project context: {}", e))?;
366
367    let dependency =
368        crate::dependencies::add_dependency(&ctx.pool, blocking_task_id, blocked_task_id)
369            .await
370            .map_err(|e| format!("Failed to add dependency: {}", e))?;
371
372    serde_json::to_value(&dependency).map_err(|e| format!("Serialization error: {}", e))
373}
374
375async fn handle_task_start(args: Value) -> Result<Value, String> {
376    let task_id = args
377        .get("task_id")
378        .and_then(|v| v.as_i64())
379        .ok_or("Missing required parameter: task_id")?;
380
381    let with_events = args
382        .get("with_events")
383        .and_then(|v| v.as_bool())
384        .unwrap_or(true);
385
386    let ctx = ProjectContext::load_or_init()
387        .await
388        .map_err(|e| format!("Failed to load project context: {}", e))?;
389
390    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
391        TaskManager::with_mcp_notifier(
392            &ctx.pool,
393            ctx.root.to_string_lossy().to_string(),
394            notifier.clone(),
395        )
396    } else {
397        TaskManager::new(&ctx.pool)
398    };
399    let task = task_mgr
400        .start_task(task_id, with_events)
401        .await
402        .map_err(|e| format!("Failed to start task: {}", e))?;
403
404    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
405}
406
407async fn handle_task_pick_next(args: Value) -> Result<Value, String> {
408    let _max_count = args.get("max_count").and_then(|v| v.as_i64());
409    let _capacity = args.get("capacity").and_then(|v| v.as_i64());
410
411    let ctx = ProjectContext::load_or_init()
412        .await
413        .map_err(|e| format!("Failed to load project context: {}", e))?;
414
415    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
416        TaskManager::with_mcp_notifier(
417            &ctx.pool,
418            ctx.root.to_string_lossy().to_string(),
419            notifier.clone(),
420        )
421    } else {
422        TaskManager::new(&ctx.pool)
423    };
424    let response = task_mgr
425        .pick_next()
426        .await
427        .map_err(|e| format!("Failed to pick next task: {}", e))?;
428
429    serde_json::to_value(&response).map_err(|e| format!("Serialization error: {}", e))
430}
431
432async fn handle_task_spawn_subtask(args: Value) -> Result<Value, String> {
433    let name = args
434        .get("name")
435        .and_then(|v| v.as_str())
436        .ok_or("Missing required parameter: name")?;
437
438    let spec = args.get("spec").and_then(|v| v.as_str());
439
440    let ctx = ProjectContext::load_or_init()
441        .await
442        .map_err(|e| format!("Failed to load project context: {}", e))?;
443
444    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
445        TaskManager::with_mcp_notifier(
446            &ctx.pool,
447            ctx.root.to_string_lossy().to_string(),
448            notifier.clone(),
449        )
450    } else {
451        TaskManager::new(&ctx.pool)
452    };
453    let subtask = task_mgr
454        .spawn_subtask(name, spec)
455        .await
456        .map_err(|e| format!("Failed to spawn subtask: {}", e))?;
457
458    serde_json::to_value(&subtask).map_err(|e| format!("Serialization error: {}", e))
459}
460
461async fn handle_task_done(args: Value) -> Result<Value, String> {
462    let task_id = args.get("task_id").and_then(|v| v.as_i64());
463
464    let ctx = ProjectContext::load_or_init()
465        .await
466        .map_err(|e| format!("Failed to load project context: {}", e))?;
467
468    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
469        TaskManager::with_mcp_notifier(
470            &ctx.pool,
471            ctx.root.to_string_lossy().to_string(),
472            notifier.clone(),
473        )
474    } else {
475        TaskManager::new(&ctx.pool)
476    };
477
478    // If task_id is provided, set it as current first
479    if let Some(id) = task_id {
480        let workspace_mgr = WorkspaceManager::new(&ctx.pool);
481        workspace_mgr
482            .set_current_task(id)
483            .await
484            .map_err(|e| format!("Failed to set current task: {}", e))?;
485    }
486
487    let task = task_mgr
488        .done_task()
489        .await
490        .map_err(|e| format!("Failed to mark task as done: {}", e))?;
491
492    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
493}
494
495async fn handle_task_update(args: Value) -> Result<Value, String> {
496    let task_id = args
497        .get("task_id")
498        .and_then(|v| v.as_i64())
499        .ok_or("Missing required parameter: task_id")?;
500
501    let name = args.get("name").and_then(|v| v.as_str());
502    let spec = args.get("spec").and_then(|v| v.as_str());
503    let status = args.get("status").and_then(|v| v.as_str());
504    let complexity = args
505        .get("complexity")
506        .and_then(|v| v.as_i64())
507        .map(|v| v as i32);
508    let priority = match args.get("priority").and_then(|v| v.as_str()) {
509        Some(p) => Some(
510            crate::priority::PriorityLevel::parse_to_int(p)
511                .map_err(|e| format!("Invalid priority: {}", e))?,
512        ),
513        None => None,
514    };
515    let parent_id = args.get("parent_id").and_then(|v| v.as_i64()).map(Some);
516
517    let ctx = ProjectContext::load_or_init()
518        .await
519        .map_err(|e| format!("Failed to load project context: {}", e))?;
520
521    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
522        TaskManager::with_mcp_notifier(
523            &ctx.pool,
524            ctx.root.to_string_lossy().to_string(),
525            notifier.clone(),
526        )
527    } else {
528        TaskManager::new(&ctx.pool)
529    };
530    let task = task_mgr
531        .update_task(task_id, name, spec, parent_id, status, complexity, priority)
532        .await
533        .map_err(|e| format!("Failed to update task: {}", e))?;
534
535    serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
536}
537
538async fn handle_task_list(args: Value) -> Result<Value, String> {
539    let status = args.get("status").and_then(|v| v.as_str());
540    let parent = args.get("parent").and_then(|v| v.as_str());
541
542    let parent_opt = parent.map(|p| {
543        if p == "null" {
544            None
545        } else {
546            p.parse::<i64>().ok()
547        }
548    });
549
550    let ctx = ProjectContext::load()
551        .await
552        .map_err(|e| format!("Failed to load project context: {}", e))?;
553
554    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
555        TaskManager::with_mcp_notifier(
556            &ctx.pool,
557            ctx.root.to_string_lossy().to_string(),
558            notifier.clone(),
559        )
560    } else {
561        TaskManager::new(&ctx.pool)
562    };
563    let tasks = task_mgr
564        .find_tasks(status, parent_opt)
565        .await
566        .map_err(|e| format!("Failed to list tasks: {}", e))?;
567
568    serde_json::to_value(&tasks).map_err(|e| format!("Serialization error: {}", e))
569}
570
571async fn handle_task_get(args: Value) -> Result<Value, String> {
572    let task_id = args
573        .get("task_id")
574        .and_then(|v| v.as_i64())
575        .ok_or("Missing required parameter: task_id")?;
576
577    let with_events = args
578        .get("with_events")
579        .and_then(|v| v.as_bool())
580        .unwrap_or(false);
581
582    let ctx = ProjectContext::load()
583        .await
584        .map_err(|e| format!("Failed to load project context: {}", e))?;
585
586    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
587        TaskManager::with_mcp_notifier(
588            &ctx.pool,
589            ctx.root.to_string_lossy().to_string(),
590            notifier.clone(),
591        )
592    } else {
593        TaskManager::new(&ctx.pool)
594    };
595
596    if with_events {
597        let task = task_mgr
598            .get_task_with_events(task_id)
599            .await
600            .map_err(|e| format!("Failed to get task: {}", e))?;
601        serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
602    } else {
603        let task = task_mgr
604            .get_task(task_id)
605            .await
606            .map_err(|e| format!("Failed to get task: {}", e))?;
607        serde_json::to_value(&task).map_err(|e| format!("Serialization error: {}", e))
608    }
609}
610
611async fn handle_task_context(args: Value) -> Result<Value, String> {
612    // Get task_id from args, or fall back to current task
613    let task_id = if let Some(id) = args.get("task_id").and_then(|v| v.as_i64()) {
614        id
615    } else {
616        // Fall back to current_task_id if no task_id provided
617        let ctx = ProjectContext::load()
618            .await
619            .map_err(|e| format!("Failed to load project context: {}", e))?;
620
621        let current_task_id: Option<String> =
622            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
623                .fetch_optional(&ctx.pool)
624                .await
625                .map_err(|e| format!("Database error: {}", e))?;
626
627        current_task_id
628            .and_then(|s| s.parse::<i64>().ok())
629            .ok_or_else(|| {
630                "No current task is set and task_id was not provided. \
631                 Use task_start to set a task first, or provide task_id parameter."
632                    .to_string()
633            })?
634    };
635
636    let ctx = ProjectContext::load()
637        .await
638        .map_err(|e| format!("Failed to load project context: {}", e))?;
639
640    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
641        TaskManager::with_mcp_notifier(
642            &ctx.pool,
643            ctx.root.to_string_lossy().to_string(),
644            notifier.clone(),
645        )
646    } else {
647        TaskManager::new(&ctx.pool)
648    };
649    let context = task_mgr
650        .get_task_context(task_id)
651        .await
652        .map_err(|e| format!("Failed to get task context: {}", e))?;
653
654    serde_json::to_value(&context).map_err(|e| format!("Serialization error: {}", e))
655}
656
657async fn handle_task_delete(args: Value) -> Result<Value, String> {
658    let task_id = args
659        .get("task_id")
660        .and_then(|v| v.as_i64())
661        .ok_or("Missing required parameter: task_id")?;
662
663    let ctx = ProjectContext::load()
664        .await
665        .map_err(|e| format!("Failed to load project context: {}", e))?;
666
667    let task_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
668        TaskManager::with_mcp_notifier(
669            &ctx.pool,
670            ctx.root.to_string_lossy().to_string(),
671            notifier.clone(),
672        )
673    } else {
674        TaskManager::new(&ctx.pool)
675    };
676    task_mgr
677        .delete_task(task_id)
678        .await
679        .map_err(|e| format!("Failed to delete task: {}", e))?;
680
681    Ok(json!({"success": true, "deleted_task_id": task_id}))
682}
683
684async fn handle_event_add(args: Value) -> Result<Value, String> {
685    let task_id = args.get("task_id").and_then(|v| v.as_i64());
686
687    let event_type = args
688        .get("event_type")
689        .and_then(|v| v.as_str())
690        .ok_or("Missing required parameter: event_type")?;
691
692    let data = args
693        .get("data")
694        .and_then(|v| v.as_str())
695        .ok_or("Missing required parameter: data")?;
696
697    let ctx = ProjectContext::load_or_init()
698        .await
699        .map_err(|e| format!("Failed to load project context: {}", e))?;
700
701    // Determine the target task ID
702    let target_task_id = if let Some(id) = task_id {
703        id
704    } else {
705        // Fall back to current_task_id
706        let current_task_id: Option<String> =
707            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
708                .fetch_optional(&ctx.pool)
709                .await
710                .map_err(|e| format!("Database error: {}", e))?;
711
712        current_task_id
713            .and_then(|s| s.parse::<i64>().ok())
714            .ok_or_else(|| {
715                "No current task is set and task_id was not provided. \
716                 Use task_start to set a task first."
717                    .to_string()
718            })?
719    };
720
721    // Create EventManager with MCP notification support (if available)
722    let event_mgr = if let Some(notifier) = MCP_NOTIFIER.get() {
723        EventManager::with_mcp_notifier(
724            &ctx.pool,
725            ctx.root.to_string_lossy().to_string(),
726            notifier.clone(),
727        )
728    } else {
729        EventManager::new(&ctx.pool)
730    };
731    let event = event_mgr
732        .add_event(target_task_id, event_type, data)
733        .await
734        .map_err(|e| format!("Failed to add event: {}", e))?;
735
736    serde_json::to_value(&event).map_err(|e| format!("Serialization error: {}", e))
737}
738
739async fn handle_event_list(args: Value) -> Result<Value, String> {
740    let task_id = args.get("task_id").and_then(|v| v.as_i64());
741
742    let limit = args.get("limit").and_then(|v| v.as_i64());
743    let log_type = args
744        .get("type")
745        .and_then(|v| v.as_str())
746        .map(|s| s.to_string());
747    let since = args
748        .get("since")
749        .and_then(|v| v.as_str())
750        .map(|s| s.to_string());
751
752    let ctx = ProjectContext::load()
753        .await
754        .map_err(|e| format!("Failed to load project context: {}", e))?;
755
756    let event_mgr = EventManager::new(&ctx.pool);
757    let events = event_mgr
758        .list_events(task_id, limit, log_type, since)
759        .await
760        .map_err(|e| format!("Failed to list events: {}", e))?;
761
762    serde_json::to_value(&events).map_err(|e| format!("Serialization error: {}", e))
763}
764
765async fn handle_unified_search(args: Value) -> Result<Value, String> {
766    use crate::search::SearchManager;
767
768    let query = args
769        .get("query")
770        .and_then(|v| v.as_str())
771        .ok_or("Missing required parameter: query")?;
772
773    let include_tasks = args
774        .get("include_tasks")
775        .and_then(|v| v.as_bool())
776        .unwrap_or(true);
777
778    let include_events = args
779        .get("include_events")
780        .and_then(|v| v.as_bool())
781        .unwrap_or(true);
782
783    let limit = args.get("limit").and_then(|v| v.as_i64());
784
785    let ctx = ProjectContext::load()
786        .await
787        .map_err(|e| format!("Failed to load project context: {}", e))?;
788
789    let search_mgr = SearchManager::new(&ctx.pool);
790    let results = search_mgr
791        .unified_search(query, include_tasks, include_events, limit)
792        .await
793        .map_err(|e| format!("Failed to perform unified search: {}", e))?;
794
795    serde_json::to_value(&results).map_err(|e| format!("Serialization error: {}", e))
796}
797
798async fn handle_current_task_get(_args: Value) -> Result<Value, String> {
799    let ctx = ProjectContext::load()
800        .await
801        .map_err(|e| format!("Failed to load project context: {}", e))?;
802
803    let workspace_mgr = WorkspaceManager::new(&ctx.pool);
804    let response = workspace_mgr
805        .get_current_task()
806        .await
807        .map_err(|e| format!("Failed to get current task: {}", e))?;
808
809    serde_json::to_value(&response).map_err(|e| format!("Serialization error: {}", e))
810}
811
812async fn handle_report_generate(args: Value) -> Result<Value, String> {
813    let since = args.get("since").and_then(|v| v.as_str()).map(String::from);
814    let status = args
815        .get("status")
816        .and_then(|v| v.as_str())
817        .map(String::from);
818    let filter_name = args
819        .get("filter_name")
820        .and_then(|v| v.as_str())
821        .map(String::from);
822    let filter_spec = args
823        .get("filter_spec")
824        .and_then(|v| v.as_str())
825        .map(String::from);
826    let summary_only = args
827        .get("summary_only")
828        .and_then(|v| v.as_bool())
829        .unwrap_or(true);
830
831    let ctx = ProjectContext::load()
832        .await
833        .map_err(|e| format!("Failed to load project context: {}", e))?;
834
835    let report_mgr = ReportManager::new(&ctx.pool);
836    let report = report_mgr
837        .generate_report(since, status, filter_name, filter_spec, summary_only)
838        .await
839        .map_err(|e| format!("Failed to generate report: {}", e))?;
840
841    serde_json::to_value(&report).map_err(|e| format!("Serialization error: {}", e))
842}
843
844async fn handle_plan(args: Value) -> Result<Value, String> {
845    // Deserialize the plan request
846    let request: PlanRequest =
847        serde_json::from_value(args).map_err(|e| format!("Invalid plan request: {}", e))?;
848
849    let ctx = ProjectContext::load_or_init()
850        .await
851        .map_err(|e| format!("Failed to load project context: {}", e))?;
852
853    let plan_executor = PlanExecutor::new(&ctx.pool);
854    let result = plan_executor
855        .execute(&request)
856        .await
857        .map_err(|e| format!("Failed to execute plan: {}", e))?;
858
859    serde_json::to_value(&result).map_err(|e| format!("Serialization error: {}", e))
860}
861
862// ============================================================================
863// MCP Connection Registry Integration
864// ============================================================================
865
866/// Register this MCP server instance (now handled via WebSocket Protocol v1.0)
867/// This function only validates project path - actual registration happens via WebSocket
868fn register_mcp_connection(project_path: &std::path::Path) -> anyhow::Result<()> {
869    // Normalize the path to handle symlinks (e.g., ~/prj -> /mnt/d/prj)
870    let normalized_path = project_path
871        .canonicalize()
872        .unwrap_or_else(|_| project_path.to_path_buf());
873
874    // Validate project path - reject temporary directories
875    // This prevents test environments from polluting the Dashboard
876    // IMPORTANT: Canonicalize temp_dir to match normalized_path format (fixes Windows UNC paths)
877    let temp_dir = std::env::temp_dir()
878        .canonicalize()
879        .unwrap_or_else(|_| std::env::temp_dir());
880    if normalized_path.starts_with(&temp_dir) {
881        tracing::debug!(
882            "Skipping MCP connection for temporary path: {}",
883            normalized_path.display()
884        );
885        return Ok(()); // Silently skip, don't error - non-fatal for MCP server
886    }
887
888    // Registration now happens automatically via WebSocket connection in ws_client.rs
889    // No need to write to Registry file
890    tracing::debug!("MCP server initialized for {}", normalized_path.display());
891
892    Ok(())
893}
894
895/// Unregister this MCP server instance (now handled via WebSocket close)
896/// This function is a no-op - actual cleanup happens when WebSocket connection closes
897fn unregister_mcp_connection(project_path: &std::path::Path) -> anyhow::Result<()> {
898    // Normalize the path for logging
899    let normalized_path = project_path
900        .canonicalize()
901        .unwrap_or_else(|_| project_path.to_path_buf());
902
903    // Unregistration now happens automatically when WebSocket connection closes
904    // No need to write to Registry file
905    tracing::debug!("MCP server shutting down for {}", normalized_path.display());
906
907    Ok(())
908}
909
910/// Check if Dashboard is running by testing the health endpoint
911async fn is_dashboard_running() -> bool {
912    // Use a timeout to prevent blocking - Dashboard check should be fast
913    match tokio::time::timeout(
914        std::time::Duration::from_millis(100), // Very short timeout
915        tokio::net::TcpStream::connect("127.0.0.1:11391"),
916    )
917    .await
918    {
919        Ok(Ok(_)) => true,
920        Ok(Err(_)) => false,
921        Err(_) => {
922            // Timeout occurred - assume dashboard is not running
923            false
924        },
925    }
926}
927
928/// Start Dashboard in background using `ie dashboard start` command
929async fn start_dashboard_background() -> io::Result<()> {
930    use tokio::process::Command;
931
932    // Get the current executable path
933    let current_exe = std::env::current_exe()?;
934
935    // Spawn Dashboard process in foreground mode
936    // IMPORTANT: Must keep Child handle alive to prevent blocking on Windows
937    let mut child = Command::new(current_exe)
938        .arg("dashboard")
939        .arg("start")
940        .arg("--foreground")
941        .stdin(std::process::Stdio::null())
942        .stdout(std::process::Stdio::null())
943        .stderr(std::process::Stdio::null())
944        .kill_on_drop(false) // Don't kill Dashboard when this function returns
945        .spawn()?;
946
947    // Wait for Dashboard to start (check health endpoint)
948    for _ in 0..10 {
949        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
950        if is_dashboard_running().await {
951            // Spawn a background task to hold the Child handle
952            // This prevents the process from being reaped and blocking the parent
953            tokio::spawn(async move {
954                let _ = child.wait().await;
955            });
956            return Ok(());
957        }
958    }
959
960    Err(io::Error::other(
961        "Dashboard failed to start within 5 seconds",
962    ))
963}
964
965#[cfg(test)]
966#[path = "server_tests.rs"]
967mod tests;