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