From cb8bb7797f03c9a71efd47a4a598acf54e9480da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CFardeen?= Date: Sun, 11 Jan 2026 00:48:41 +0530 Subject: [PATCH 1/2] feat: add MCP server start/stop controls Add ability to start and stop Claude Code as an MCP server from the GUI. Backend changes: - Add mcp_serve() command to spawn 'claude mcp serve' process - Add mcp_stop() command to terminate running MCP serve process - Add ProcessType::McpServe to process registry with singleton pattern - Add register_mcp_serve_process() and get_running_mcp_serve() methods - Update mcp_get_server_status() to check registry for running process Frontend changes: - Add Start/Stop MCP Server buttons to MCPImportExport component - Add status polling (2s interval) to show running state - Wire onSuccess callback in MCPManager for status refresh - Fix duplicate toast notification bug The MCP server allows external tools to communicate with Claude Code via the Model Context Protocol over stdio transport. --- src-tauri/src/commands/mcp.rs | 74 ++++++++++++++++++--- src-tauri/src/main.rs | 3 +- src-tauri/src/process/registry.rs | 42 ++++++++++++ src/components/MCPImportExport.tsx | 100 ++++++++++++++++++++++------- src/components/MCPManager.tsx | 3 +- src/lib/api.ts | 12 ++++ 6 files changed, 202 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 2db974f63..dc5d5df4f 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 @@ -624,13 +628,32 @@ pub async fn mcp_serve(app: AppHandle) -> Result { } }; + // If already running, don't start another one + if let Ok(Some(existing)) = registry.0.get_running_mcp_serve() { + return Ok(format!( + "Claude Code MCP server already running (PID: {})", + existing.pid + )); + } + let mut cmd = create_command_with_env(&claude_path); 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()); + } + + if let Err(e) = registry.0.register_mcp_serve_process(pid) { + error!("Failed to register MCP server process: {}", e); + return Err(e); + } + + info!("Successfully started Claude Code MCP server (PID: {})", pid); + Ok(format!("Claude Code MCP server started (PID: {})", pid)) } Err(e) => { error!("Failed to start MCP server: {}", e); @@ -639,6 +662,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 +712,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..6f4cda412 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,46 @@ 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). + /// The caller (mcp_serve command) is responsible for checking if a process + /// already exists via get_running_mcp_serve() before calling this method. + pub fn register_mcp_serve_process(&self, pid: u32) -> Result { + 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(), + }; + + // Register without child handle (like sidecar) + let mut processes = self.processes.lock().map_err(|e| e.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 */ From a735370a9b6b36d136c0f9a190e2a1f7024b2d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CFardeen?= Date: Sun, 11 Jan 2026 01:03:15 +0530 Subject: [PATCH 2/2] fix: prevent race condition in MCP server start Make singleton enforcement atomic by checking for existing MCP server inside the register_mcp_serve_process() lock. This prevents two concurrent start commands from both passing the check and spawning duplicate processes. Additional improvements: - Use std::mem::forget() to document intentional process detachment - Kill spawned process if registration fails (cleanup on race loss) - Move singleton check comment from caller to enforcement point --- src-tauri/src/commands/mcp.rs | 41 ++++++++++++++++++++----------- src-tauri/src/process/registry.rs | 22 +++++++++++++---- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index dc5d5df4f..8cc101d0f 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -628,14 +628,6 @@ pub async fn mcp_serve( } }; - // If already running, don't start another one - if let Ok(Some(existing)) = registry.0.get_running_mcp_serve() { - return Ok(format!( - "Claude Code MCP server already running (PID: {})", - existing.pid - )); - } - let mut cmd = create_command_with_env(&claude_path); cmd.arg("mcp").arg("serve"); @@ -647,13 +639,34 @@ pub async fn mcp_serve( return Err("MCP server started but PID is unavailable".to_string()); } - if let Err(e) = registry.0.register_mcp_serve_process(pid) { - error!("Failed to register MCP server process: {}", e); - return Err(e); - } + // 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); - info!("Successfully started Claude Code MCP server (PID: {})", pid); - Ok(format!("Claude Code MCP server started (PID: {})", pid)) + // 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); diff --git a/src-tauri/src/process/registry.rs b/src-tauri/src/process/registry.rs index 6f4cda412..3971777df 100644 --- a/src-tauri/src/process/registry.rs +++ b/src-tauri/src/process/registry.rs @@ -157,9 +157,24 @@ impl ProcessRegistry { /// 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). - /// The caller (mcp_serve command) is responsible for checking if a process - /// already exists via get_running_mcp_serve() before calling this method. + /// 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 { @@ -172,9 +187,6 @@ impl ProcessRegistry { model: "".to_string(), }; - // Register without child handle (like sidecar) - let mut processes = self.processes.lock().map_err(|e| e.to_string())?; - let process_handle = ProcessHandle { info: process_info, child: Arc::new(Mutex::new(None)),