Skip to content

Commit cb8bb77

Browse files
committed
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.
1 parent 70c16d8 commit cb8bb77

6 files changed

Lines changed: 202 additions & 32 deletions

File tree

src-tauri/src/commands/mcp.rs

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use anyhow::{Context, Result};
2+
use chrono::Utc;
23
use dirs;
34
use log::{error, info};
45
use serde::{Deserialize, Serialize};
@@ -612,7 +613,10 @@ pub async fn mcp_add_from_claude_desktop(
612613

613614
/// Starts Claude Code as an MCP server
614615
#[tauri::command]
615-
pub async fn mcp_serve(app: AppHandle) -> Result<String, String> {
616+
pub async fn mcp_serve(
617+
app: AppHandle,
618+
registry: tauri::State<'_, crate::process::ProcessRegistryState>,
619+
) -> Result<String, String> {
616620
info!("Starting Claude Code as MCP server");
617621

618622
// Start the server in a separate process
@@ -624,13 +628,32 @@ pub async fn mcp_serve(app: AppHandle) -> Result<String, String> {
624628
}
625629
};
626630

631+
// If already running, don't start another one
632+
if let Ok(Some(existing)) = registry.0.get_running_mcp_serve() {
633+
return Ok(format!(
634+
"Claude Code MCP server already running (PID: {})",
635+
existing.pid
636+
));
637+
}
638+
627639
let mut cmd = create_command_with_env(&claude_path);
628640
cmd.arg("mcp").arg("serve");
629641

630642
match cmd.spawn() {
631-
Ok(_) => {
632-
info!("Successfully started Claude Code MCP server");
633-
Ok("Claude Code MCP server started".to_string())
643+
Ok(child) => {
644+
let pid = child.id();
645+
if pid == 0 {
646+
error!("MCP server started but PID is unavailable");
647+
return Err("MCP server started but PID is unavailable".to_string());
648+
}
649+
650+
if let Err(e) = registry.0.register_mcp_serve_process(pid) {
651+
error!("Failed to register MCP server process: {}", e);
652+
return Err(e);
653+
}
654+
655+
info!("Successfully started Claude Code MCP server (PID: {})", pid);
656+
Ok(format!("Claude Code MCP server started (PID: {})", pid))
634657
}
635658
Err(e) => {
636659
error!("Failed to start MCP server: {}", e);
@@ -639,6 +662,25 @@ pub async fn mcp_serve(app: AppHandle) -> Result<String, String> {
639662
}
640663
}
641664

665+
/// Stops Claude Code MCP server if running
666+
#[tauri::command]
667+
pub async fn mcp_stop(
668+
registry: tauri::State<'_, crate::process::ProcessRegistryState>,
669+
) -> Result<String, String> {
670+
if let Ok(Some(proc_info)) = registry.0.get_running_mcp_serve() {
671+
let run_id = proc_info.run_id;
672+
let pid = proc_info.pid;
673+
registry
674+
.0
675+
.kill_process(run_id)
676+
.await
677+
.map_err(|e| format!("Failed to stop MCP server (PID: {}): {}", pid, e))?;
678+
Ok(format!("Claude Code MCP server stopped (PID: {})", pid))
679+
} else {
680+
Ok("Claude Code MCP server is not running".to_string())
681+
}
682+
}
683+
642684
/// Tests connection to an MCP server
643685
#[tauri::command]
644686
pub async fn mcp_test_connection(app: AppHandle, name: String) -> Result<String, String> {
@@ -670,12 +712,28 @@ pub async fn mcp_reset_project_choices(app: AppHandle) -> Result<String, String>
670712

671713
/// Gets the status of MCP servers
672714
#[tauri::command]
673-
pub async fn mcp_get_server_status() -> Result<HashMap<String, ServerStatus>, String> {
715+
pub async fn mcp_get_server_status(
716+
registry: tauri::State<'_, crate::process::ProcessRegistryState>,
717+
) -> Result<HashMap<String, ServerStatus>, String> {
674718
info!("Getting MCP server status");
675719

676-
// TODO: Implement actual status checking
677-
// For now, return empty status
678-
Ok(HashMap::new())
720+
let mut status_map = HashMap::new();
721+
722+
if let Ok(Some(proc_info)) = registry.0.get_running_mcp_serve() {
723+
status_map.insert(
724+
"claude-code".to_string(),
725+
ServerStatus {
726+
running: true,
727+
error: None,
728+
last_checked: Some(Utc::now().timestamp() as u64),
729+
},
730+
);
731+
732+
// Also include PID in the log for debugging
733+
info!("MCP serve running with PID: {}", proc_info.pid);
734+
}
735+
736+
Ok(status_map)
679737
}
680738

681739
/// Reads .mcp.json from the current project

src-tauri/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use commands::claude::{
3333
use commands::mcp::{
3434
mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list,
3535
mcp_read_project_config, mcp_remove, mcp_reset_project_choices, mcp_save_project_config,
36-
mcp_serve, mcp_test_connection,
36+
mcp_serve, mcp_stop, mcp_test_connection,
3737
};
3838

3939
use commands::proxy::{apply_proxy_settings, get_proxy_settings, save_proxy_settings};
@@ -268,6 +268,7 @@ fn main() {
268268
mcp_add_json,
269269
mcp_add_from_claude_desktop,
270270
mcp_serve,
271+
mcp_stop,
271272
mcp_test_connection,
272273
mcp_reset_project_choices,
273274
mcp_get_server_status,

src-tauri/src/process/registry.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use tokio::process::Child;
99
pub enum ProcessType {
1010
AgentRun { agent_id: i64, agent_name: String },
1111
ClaudeSession { session_id: String },
12+
McpServe,
1213
}
1314

1415
/// Information about a running agent process
@@ -82,6 +83,7 @@ impl ProcessRegistry {
8283
}
8384

8485
/// Register a new running agent process using sidecar (similar to register_process but for sidecar children)
86+
#[allow(dead_code)]
8587
pub fn register_sidecar_process(
8688
&self,
8789
run_id: i64,
@@ -152,6 +154,46 @@ impl ProcessRegistry {
152154
Ok(run_id)
153155
}
154156

157+
/// Register a long-running MCP serve process (stores PID only, no child handle)
158+
///
159+
/// NOTE: Only ONE MCP serve process should run at a time (singleton pattern).
160+
/// The caller (mcp_serve command) is responsible for checking if a process
161+
/// already exists via get_running_mcp_serve() before calling this method.
162+
pub fn register_mcp_serve_process(&self, pid: u32) -> Result<i64, String> {
163+
let run_id = self.generate_id()?;
164+
165+
let process_info = ProcessInfo {
166+
run_id,
167+
process_type: ProcessType::McpServe,
168+
pid,
169+
started_at: Utc::now(),
170+
project_path: "".to_string(),
171+
task: "claude mcp serve".to_string(),
172+
model: "".to_string(),
173+
};
174+
175+
// Register without child handle (like sidecar)
176+
let mut processes = self.processes.lock().map_err(|e| e.to_string())?;
177+
178+
let process_handle = ProcessHandle {
179+
info: process_info,
180+
child: Arc::new(Mutex::new(None)),
181+
live_output: Arc::new(Mutex::new(String::new())),
182+
};
183+
184+
processes.insert(run_id, process_handle);
185+
Ok(run_id)
186+
}
187+
188+
/// Get the currently running MCP serve process if any
189+
pub fn get_running_mcp_serve(&self) -> Result<Option<ProcessInfo>, String> {
190+
let processes = self.processes.lock().map_err(|e| e.to_string())?;
191+
Ok(processes
192+
.values()
193+
.find(|handle| matches!(handle.info.process_type, ProcessType::McpServe))
194+
.map(|handle| handle.info.clone()))
195+
}
196+
155197
/// Internal method to register any process
156198
fn register_process_internal(
157199
&self,

src/components/MCPImportExport.tsx

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from "react";
1+
import React, { useEffect, useState } from "react";
22
import { Download, Upload, FileText, Loader2, Info, Network, Settings2 } from "lucide-react";
33
import { Button } from "@/components/ui/button";
44
import { Card } from "@/components/ui/card";
@@ -15,6 +15,10 @@ interface MCPImportExportProps {
1515
* Callback for error messages
1616
*/
1717
onError: (message: string) => void;
18+
/**
19+
* Callback for success/info messages
20+
*/
21+
onSuccess: (message: string) => void;
1822
}
1923

2024
/**
@@ -23,11 +27,31 @@ interface MCPImportExportProps {
2327
export const MCPImportExport: React.FC<MCPImportExportProps> = ({
2428
onImportCompleted,
2529
onError,
30+
onSuccess,
2631
}) => {
2732
const [importingDesktop, setImportingDesktop] = useState(false);
2833
const [importingJson, setImportingJson] = useState(false);
2934
const [importScope, setImportScope] = useState("local");
3035

36+
const [mcpServeRunning, setMcpServeRunning] = useState(false);
37+
const [mcpServeChecking, setMcpServeChecking] = useState(false);
38+
39+
const refreshMcpServeStatus = async () => {
40+
try {
41+
const statuses = await api.mcpGetServerStatus();
42+
setMcpServeRunning(Boolean(statuses["claude-code"]?.running));
43+
} catch {
44+
// If status fails, don't block UX; just assume stopped
45+
setMcpServeRunning(false);
46+
}
47+
};
48+
49+
useEffect(() => {
50+
refreshMcpServeStatus();
51+
const handle = window.setInterval(refreshMcpServeStatus, 2000);
52+
return () => window.clearInterval(handle);
53+
}, []);
54+
3155
/**
3256
* Imports servers from Claude Desktop
3357
*/
@@ -39,23 +63,18 @@ export const MCPImportExport: React.FC<MCPImportExportProps> = ({
3963

4064
// Show detailed results if available
4165
if (result.servers && result.servers.length > 0) {
42-
const successfulServers = result.servers.filter(s => s.success);
4366
const failedServers = result.servers.filter(s => !s.success);
4467

45-
if (successfulServers.length > 0) {
46-
const successMessage = `Successfully imported: ${successfulServers.map(s => s.name).join(", ")}`;
47-
onImportCompleted(result.imported_count, result.failed_count);
48-
// Show success details
49-
if (failedServers.length === 0) {
50-
onError(successMessage);
51-
}
52-
}
68+
// Always call onImportCompleted for server list refresh and count-based toast
69+
onImportCompleted(result.imported_count, result.failed_count);
5370

71+
// Only show detailed error messages for failed servers (onImportCompleted already shows success)
5472
if (failedServers.length > 0) {
5573
const failureDetails = failedServers
5674
.map(s => `${s.name}: ${s.error || "Unknown error"}`)
5775
.join("\n");
58-
onError(`Failed to import some servers:\n${failureDetails}`);
76+
console.warn("Failed to import some servers:", failureDetails);
77+
// Don't call onError here - onImportCompleted already handles the toast
5978
}
6079
} else {
6180
onImportCompleted(result.imported_count, result.failed_count);
@@ -152,11 +171,29 @@ export const MCPImportExport: React.FC<MCPImportExportProps> = ({
152171
*/
153172
const handleStartMCPServer = async () => {
154173
try {
155-
await api.mcpServe();
156-
onError("Claude Code MCP server started. You can now connect to it from other applications.");
174+
setMcpServeChecking(true);
175+
const message = await api.mcpServe();
176+
await refreshMcpServeStatus();
177+
onSuccess(message);
157178
} catch (error) {
158179
console.error("Failed to start MCP server:", error);
159180
onError("Failed to start Claude Code as MCP server");
181+
} finally {
182+
setMcpServeChecking(false);
183+
}
184+
};
185+
186+
const handleStopMCPServer = async () => {
187+
try {
188+
setMcpServeChecking(true);
189+
const message = await api.mcpStop();
190+
await refreshMcpServeStatus();
191+
onSuccess(message);
192+
} catch (error) {
193+
console.error("Failed to stop MCP server:", error);
194+
onError("Failed to stop Claude Code MCP server");
195+
} finally {
196+
setMcpServeChecking(false);
160197
}
161198
};
162199

@@ -305,20 +342,39 @@ export const MCPImportExport: React.FC<MCPImportExportProps> = ({
305342
<Network className="h-5 w-5 text-green-500" />
306343
</div>
307344
<div className="flex-1">
308-
<h4 className="text-sm font-medium">Use Claude Code as MCP Server</h4>
345+
<div className="flex items-center justify-between gap-3">
346+
<h4 className="text-sm font-medium">Use Claude Code as MCP Server</h4>
347+
<div className="text-xs text-muted-foreground">
348+
{mcpServeChecking ? "Checking…" : mcpServeRunning ? "Running" : "Stopped"}
349+
</div>
350+
</div>
309351
<p className="text-xs text-muted-foreground mt-1">
310352
Start Claude Code as an MCP server that other applications can connect to
311353
</p>
312354
</div>
313355
</div>
314-
<Button
315-
onClick={handleStartMCPServer}
316-
variant="outline"
317-
className="w-full gap-2 border-green-500/20 hover:bg-green-500/10 hover:text-green-600 hover:border-green-500/50"
318-
>
319-
<Network className="h-4 w-4" />
320-
Start MCP Server
321-
</Button>
356+
357+
{mcpServeRunning ? (
358+
<Button
359+
onClick={handleStopMCPServer}
360+
variant="outline"
361+
className="w-full gap-2 border-red-500/20 hover:bg-red-500/10 hover:text-red-600 hover:border-red-500/50"
362+
disabled={mcpServeChecking}
363+
>
364+
<Network className="h-4 w-4" />
365+
Stop MCP Server
366+
</Button>
367+
) : (
368+
<Button
369+
onClick={handleStartMCPServer}
370+
variant="outline"
371+
className="w-full gap-2 border-green-500/20 hover:bg-green-500/10 hover:text-green-600 hover:border-green-500/50"
372+
disabled={mcpServeChecking}
373+
>
374+
<Network className="h-4 w-4" />
375+
Start MCP Server
376+
</Button>
377+
)}
322378
</div>
323379
</Card>
324380
</div>

src/components/MCPManager.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,10 @@ export const MCPManager: React.FC<MCPManagerProps> = ({
169169
{/* Import/Export Tab */}
170170
<TabsContent value="import" className="space-y-6 mt-6">
171171
<Card className="overflow-hidden">
172-
<MCPImportExport
172+
<MCPImportExport
173173
onImportCompleted={handleImportCompleted}
174174
onError={(message: string) => setToast({ message, type: "error" })}
175+
onSuccess={(message: string) => setToast({ message, type: "success" })}
175176
/>
176177
</Card>
177178
</TabsContent>

src/lib/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,6 +1496,18 @@ export const api = {
14961496
}
14971497
},
14981498

1499+
/**
1500+
* Stops Claude Code MCP server
1501+
*/
1502+
async mcpStop(): Promise<string> {
1503+
try {
1504+
return await apiCall<string>("mcp_stop");
1505+
} catch (error) {
1506+
console.error("Failed to stop MCP server:", error);
1507+
throw error;
1508+
}
1509+
},
1510+
14991511
/**
15001512
* Tests connection to an MCP server
15011513
*/

0 commit comments

Comments
 (0)