diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 2db974f63..8cc101d0f 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use chrono::Utc; use dirs; use log::{error, info}; use serde::{Deserialize, Serialize}; @@ -612,7 +613,10 @@ pub async fn mcp_add_from_claude_desktop( /// Starts Claude Code as an MCP server #[tauri::command] -pub async fn mcp_serve(app: AppHandle) -> Result { +pub async fn mcp_serve( + app: AppHandle, + registry: tauri::State<'_, crate::process::ProcessRegistryState>, +) -> Result { info!("Starting Claude Code as MCP server"); // Start the server in a separate process @@ -628,9 +632,41 @@ pub async fn mcp_serve(app: AppHandle) -> Result { cmd.arg("mcp").arg("serve"); match cmd.spawn() { - Ok(_) => { - info!("Successfully started Claude Code MCP server"); - Ok("Claude Code MCP server started".to_string()) + Ok(child) => { + let pid = child.id(); + if pid == 0 { + error!("MCP server started but PID is unavailable"); + return Err("MCP server started but PID is unavailable".to_string()); + } + + // Intentionally drop the child handle - MCP server runs as detached process. + // We track it by PID only, allowing it to outlive the parent process. + std::mem::forget(child); + + // Register atomically - will fail if another instance already exists + match registry.0.register_mcp_serve_process(pid) { + Ok(_run_id) => { + info!("Successfully started Claude Code MCP server (PID: {})", pid); + Ok(format!("Claude Code MCP server started (PID: {})", pid)) + } + Err(e) => { + // Registration failed (likely already running) - kill the process we just started + error!("Failed to register MCP server process: {}", e); + #[cfg(unix)] + { + use std::process::Command; + let _ = Command::new("kill").arg(pid.to_string()).spawn(); + } + #[cfg(windows)] + { + use std::process::Command; + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .spawn(); + } + Err(e) + } + } } Err(e) => { error!("Failed to start MCP server: {}", e); @@ -639,6 +675,25 @@ pub async fn mcp_serve(app: AppHandle) -> Result { } } +/// Stops Claude Code MCP server if running +#[tauri::command] +pub async fn mcp_stop( + registry: tauri::State<'_, crate::process::ProcessRegistryState>, +) -> Result { + if let Ok(Some(proc_info)) = registry.0.get_running_mcp_serve() { + let run_id = proc_info.run_id; + let pid = proc_info.pid; + registry + .0 + .kill_process(run_id) + .await + .map_err(|e| format!("Failed to stop MCP server (PID: {}): {}", pid, e))?; + Ok(format!("Claude Code MCP server stopped (PID: {})", pid)) + } else { + Ok("Claude Code MCP server is not running".to_string()) + } +} + /// Tests connection to an MCP server #[tauri::command] pub async fn mcp_test_connection(app: AppHandle, name: String) -> Result { @@ -670,12 +725,28 @@ pub async fn mcp_reset_project_choices(app: AppHandle) -> Result /// Gets the status of MCP servers #[tauri::command] -pub async fn mcp_get_server_status() -> Result, String> { +pub async fn mcp_get_server_status( + registry: tauri::State<'_, crate::process::ProcessRegistryState>, +) -> Result, String> { info!("Getting MCP server status"); - // TODO: Implement actual status checking - // For now, return empty status - Ok(HashMap::new()) + let mut status_map = HashMap::new(); + + if let Ok(Some(proc_info)) = registry.0.get_running_mcp_serve() { + status_map.insert( + "claude-code".to_string(), + ServerStatus { + running: true, + error: None, + last_checked: Some(Utc::now().timestamp() as u64), + }, + ); + + // Also include PID in the log for debugging + info!("MCP serve running with PID: {}", proc_info.pid); + } + + Ok(status_map) } /// Reads .mcp.json from the current project diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc93adbcf..0bbbe930a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -33,7 +33,7 @@ use commands::claude::{ use commands::mcp::{ mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list, mcp_read_project_config, mcp_remove, mcp_reset_project_choices, mcp_save_project_config, - mcp_serve, mcp_test_connection, + mcp_serve, mcp_stop, mcp_test_connection, }; use commands::proxy::{apply_proxy_settings, get_proxy_settings, save_proxy_settings}; @@ -268,6 +268,7 @@ fn main() { mcp_add_json, mcp_add_from_claude_desktop, mcp_serve, + mcp_stop, mcp_test_connection, mcp_reset_project_choices, mcp_get_server_status, diff --git a/src-tauri/src/process/registry.rs b/src-tauri/src/process/registry.rs index f4f33b5a2..3971777df 100644 --- a/src-tauri/src/process/registry.rs +++ b/src-tauri/src/process/registry.rs @@ -9,6 +9,7 @@ use tokio::process::Child; pub enum ProcessType { AgentRun { agent_id: i64, agent_name: String }, ClaudeSession { session_id: String }, + McpServe, } /// Information about a running agent process @@ -82,6 +83,7 @@ impl ProcessRegistry { } /// Register a new running agent process using sidecar (similar to register_process but for sidecar children) + #[allow(dead_code)] pub fn register_sidecar_process( &self, run_id: i64, @@ -152,6 +154,58 @@ impl ProcessRegistry { Ok(run_id) } + /// Register a long-running MCP serve process (stores PID only, no child handle) + /// + /// NOTE: Only ONE MCP serve process should run at a time (singleton pattern). + /// This method enforces the singleton by checking atomically within the lock. + pub fn register_mcp_serve_process(&self, pid: u32) -> Result { + // Acquire lock first to make check-and-register atomic (prevents race conditions) + let mut processes = self.processes.lock().map_err(|e| e.to_string())?; + + // Check if MCP serve process already exists (atomic with registration) + let existing_mcp = processes.values().find(|p| { + matches!(p.info.process_type, ProcessType::McpServe) + }); + + if let Some(existing) = existing_mcp { + return Err(format!( + "MCP server already running (PID: {})", + existing.info.pid + )); + } + + // Now safe to register new MCP serve process + let run_id = self.generate_id()?; + + let process_info = ProcessInfo { + run_id, + process_type: ProcessType::McpServe, + pid, + started_at: Utc::now(), + project_path: "".to_string(), + task: "claude mcp serve".to_string(), + model: "".to_string(), + }; + + let process_handle = ProcessHandle { + info: process_info, + child: Arc::new(Mutex::new(None)), + live_output: Arc::new(Mutex::new(String::new())), + }; + + processes.insert(run_id, process_handle); + Ok(run_id) + } + + /// Get the currently running MCP serve process if any + pub fn get_running_mcp_serve(&self) -> Result, String> { + let processes = self.processes.lock().map_err(|e| e.to_string())?; + Ok(processes + .values() + .find(|handle| matches!(handle.info.process_type, ProcessType::McpServe)) + .map(|handle| handle.info.clone())) + } + /// Internal method to register any process fn register_process_internal( &self, diff --git a/src/components/MCPImportExport.tsx b/src/components/MCPImportExport.tsx index b63ee1c9d..50fad66d1 100644 --- a/src/components/MCPImportExport.tsx +++ b/src/components/MCPImportExport.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Download, Upload, FileText, Loader2, Info, Network, Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -15,6 +15,10 @@ interface MCPImportExportProps { * Callback for error messages */ onError: (message: string) => void; + /** + * Callback for success/info messages + */ + onSuccess: (message: string) => void; } /** @@ -23,11 +27,31 @@ interface MCPImportExportProps { export const MCPImportExport: React.FC = ({ onImportCompleted, onError, + onSuccess, }) => { const [importingDesktop, setImportingDesktop] = useState(false); const [importingJson, setImportingJson] = useState(false); const [importScope, setImportScope] = useState("local"); + const [mcpServeRunning, setMcpServeRunning] = useState(false); + const [mcpServeChecking, setMcpServeChecking] = useState(false); + + const refreshMcpServeStatus = async () => { + try { + const statuses = await api.mcpGetServerStatus(); + setMcpServeRunning(Boolean(statuses["claude-code"]?.running)); + } catch { + // If status fails, don't block UX; just assume stopped + setMcpServeRunning(false); + } + }; + + useEffect(() => { + refreshMcpServeStatus(); + const handle = window.setInterval(refreshMcpServeStatus, 2000); + return () => window.clearInterval(handle); + }, []); + /** * Imports servers from Claude Desktop */ @@ -39,23 +63,18 @@ export const MCPImportExport: React.FC = ({ // Show detailed results if available if (result.servers && result.servers.length > 0) { - const successfulServers = result.servers.filter(s => s.success); const failedServers = result.servers.filter(s => !s.success); - if (successfulServers.length > 0) { - const successMessage = `Successfully imported: ${successfulServers.map(s => s.name).join(", ")}`; - onImportCompleted(result.imported_count, result.failed_count); - // Show success details - if (failedServers.length === 0) { - onError(successMessage); - } - } + // Always call onImportCompleted for server list refresh and count-based toast + onImportCompleted(result.imported_count, result.failed_count); + // Only show detailed error messages for failed servers (onImportCompleted already shows success) if (failedServers.length > 0) { const failureDetails = failedServers .map(s => `${s.name}: ${s.error || "Unknown error"}`) .join("\n"); - onError(`Failed to import some servers:\n${failureDetails}`); + console.warn("Failed to import some servers:", failureDetails); + // Don't call onError here - onImportCompleted already handles the toast } } else { onImportCompleted(result.imported_count, result.failed_count); @@ -152,11 +171,29 @@ export const MCPImportExport: React.FC = ({ */ const handleStartMCPServer = async () => { try { - await api.mcpServe(); - onError("Claude Code MCP server started. You can now connect to it from other applications."); + setMcpServeChecking(true); + const message = await api.mcpServe(); + await refreshMcpServeStatus(); + onSuccess(message); } catch (error) { console.error("Failed to start MCP server:", error); onError("Failed to start Claude Code as MCP server"); + } finally { + setMcpServeChecking(false); + } + }; + + const handleStopMCPServer = async () => { + try { + setMcpServeChecking(true); + const message = await api.mcpStop(); + await refreshMcpServeStatus(); + onSuccess(message); + } catch (error) { + console.error("Failed to stop MCP server:", error); + onError("Failed to stop Claude Code MCP server"); + } finally { + setMcpServeChecking(false); } }; @@ -305,20 +342,39 @@ export const MCPImportExport: React.FC = ({
-

Use Claude Code as MCP Server

+
+

Use Claude Code as MCP Server

+
+ {mcpServeChecking ? "Checking…" : mcpServeRunning ? "Running" : "Stopped"} +
+

Start Claude Code as an MCP server that other applications can connect to

- + + {mcpServeRunning ? ( + + ) : ( + + )} diff --git a/src/components/MCPManager.tsx b/src/components/MCPManager.tsx index 7bef9e110..b1f243c22 100644 --- a/src/components/MCPManager.tsx +++ b/src/components/MCPManager.tsx @@ -169,9 +169,10 @@ export const MCPManager: React.FC = ({ {/* Import/Export Tab */} - setToast({ message, type: "error" })} + onSuccess={(message: string) => setToast({ message, type: "success" })} /> diff --git a/src/lib/api.ts b/src/lib/api.ts index eb76da821..c78122db5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1496,6 +1496,18 @@ export const api = { } }, + /** + * Stops Claude Code MCP server + */ + async mcpStop(): Promise { + try { + return await apiCall("mcp_stop"); + } catch (error) { + console.error("Failed to stop MCP server:", error); + throw error; + } + }, + /** * Tests connection to an MCP server */