Skip to main content

qualia_cli/
mcp.rs

1use clap::{Subcommand, ValueEnum};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use std::fs;
5use std::io::{BufRead, BufReader, Write};
6use std::net::{Shutdown, TcpStream};
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::time::Duration;
10use sysinfo::{Pid, Signal, System};
11use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};
12use tokio::net::{TcpListener, TcpStream as TokioTcpStream};
13
14pub const DEFAULT_MCP_BIND: &str = "127.0.0.1:4244";
15const PID_FILE_NAME: &str = "mcp-service.json";
16const LOG_FILE_NAME: &str = "mcp-service.log";
17const ERR_LOG_FILE_NAME: &str = "mcp-service.err.log";
18
19#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
20#[serde(rename_all = "lowercase")]
21pub enum McpTransport {
22    Stdio,
23    Tcp,
24}
25
26#[derive(Subcommand, Debug)]
27pub enum McpAction {
28    /// Run the MCP server in the foreground
29    Serve {
30        /// Transport used by this MCP server instance
31        #[arg(long, value_enum, default_value_t = McpTransport::Stdio)]
32        transport: McpTransport,
33        /// Bind address for TCP transport
34        #[arg(long, default_value = DEFAULT_MCP_BIND)]
35        bind: String,
36        /// Hidden child mode used by `mcp start`
37        #[arg(long, hide = true)]
38        service_child: bool,
39    },
40    /// Start a detached MCP service with PID management
41    Start {
42        /// Bind address for the background TCP transport
43        #[arg(long, default_value = DEFAULT_MCP_BIND)]
44        bind: String,
45        /// Replace an existing recorded service if its PID file is stale
46        #[arg(long)]
47        force: bool,
48    },
49    /// Stop the detached MCP service
50    Stop,
51    /// Report whether the detached MCP service is running
52    Status,
53    /// Inspect the MCP surface, transport, and health checks
54    Doctor,
55    /// Proxy MCP stdio to the Webizen Desktop GUI application (TCP 4245)
56    DesktopProxy,
57}
58
59#[derive(Debug, Serialize, Deserialize)]
60struct McpServiceRecord {
61    pid: u32,
62    transport: McpTransport,
63    bind: String,
64    started_at: String,
65    log_path: String,
66    qpu_enabled: bool,
67}
68
69pub async fn handle(action: &McpAction, qpu_enabled: bool) {
70    match action {
71        McpAction::Serve {
72            transport,
73            bind,
74            service_child,
75        } => {
76            if *service_child {
77                let _ = write_service_record(McpServiceRecord {
78                    pid: std::process::id(),
79                    transport: *transport,
80                    bind: bind.clone(),
81                    started_at: chrono::Utc::now().to_rfc3339(),
82                    log_path: log_file_path().display().to_string(),
83                    qpu_enabled,
84                });
85            }
86            match transport {
87                McpTransport::Stdio => {
88                    qualia_core_db::mcp_server::start_mcp_listener_with_flags(qpu_enabled, true)
89                        .await;
90                }
91                McpTransport::Tcp => {
92                    if let Err(err) = serve_tcp(bind, qpu_enabled).await {
93                        eprintln!("MCP TCP server failed: {err}");
94                    }
95                }
96            }
97        }
98        McpAction::Start { bind, force } => {
99            if let Err(err) = start_background(bind, qpu_enabled, *force) {
100                eprintln!("Failed to start MCP service: {err}");
101            }
102        }
103        McpAction::Stop => {
104            if let Err(err) = stop_background() {
105                eprintln!("Failed to stop MCP service: {err}");
106            }
107        }
108        McpAction::Status => {
109            if let Err(err) = print_status() {
110                eprintln!("Failed to inspect MCP service: {err}");
111            }
112        }
113        McpAction::Doctor => {
114            if let Err(err) = print_doctor() {
115                eprintln!("MCP doctor failed: {err}");
116            }
117        }
118        McpAction::DesktopProxy => {
119            if let Err(err) = run_desktop_proxy() {
120                eprintln!("MCP desktop proxy failed: {err}");
121            }
122        }
123    }
124}
125
126/// Proxy MCP stdio ↔ the Webizen Desktop GUI's TCP MCP server (127.0.0.1:4245). An external MCP
127/// client that speaks stdio (e.g. an editor agent) can drive the desktop app's tool surface through
128/// this bridge: each stdin JSON-RPC line is forwarded to the desktop server and its reply written to
129/// stdout.
130fn run_desktop_proxy() -> Result<(), String> {
131    let stream = TcpStream::connect("127.0.0.1:4245")
132        .map_err(|e| format!("connect desktop MCP (127.0.0.1:4245): {e}"))?;
133    let mut tcp_writer = stream.try_clone().map_err(|e| e.to_string())?;
134    let mut tcp_reader = BufReader::new(stream);
135    let stdin = std::io::stdin();
136    let stdout = std::io::stdout();
137    let mut reply = String::new();
138    for req in stdin.lock().lines() {
139        let req = req.map_err(|e| e.to_string())?;
140        if req.trim().is_empty() {
141            continue;
142        }
143        tcp_writer
144            .write_all(req.as_bytes())
145            .map_err(|e| e.to_string())?;
146        tcp_writer.write_all(b"\n").map_err(|e| e.to_string())?;
147        tcp_writer.flush().ok();
148        reply.clear();
149        if tcp_reader
150            .read_line(&mut reply)
151            .map_err(|e| e.to_string())?
152            == 0
153        {
154            break;
155        }
156        let mut out = stdout.lock();
157        out.write_all(reply.as_bytes()).map_err(|e| e.to_string())?;
158        out.flush().ok();
159    }
160    Ok(())
161}
162
163async fn serve_tcp(bind: &str, qpu_enabled: bool) -> Result<(), String> {
164    let listener = TcpListener::bind(bind)
165        .await
166        .map_err(|e| format!("bind {bind}: {e}"))?;
167    eprintln!("[MCP Server] Listening on tcp://{bind}");
168
169    loop {
170        let (socket, _) = listener
171            .accept()
172            .await
173            .map_err(|e| format!("accept failed: {e}"))?;
174        tokio::spawn(handle_tcp_client(socket, qpu_enabled));
175    }
176}
177
178async fn handle_tcp_client(stream: TokioTcpStream, qpu_enabled: bool) {
179    let (reader_half, mut writer_half) = stream.into_split();
180    let mut reader = TokioBufReader::new(reader_half);
181    let mut line = String::new();
182
183    loop {
184        line.clear();
185        match reader.read_line(&mut line).await {
186            Ok(0) => break,
187            Ok(_) => {
188                let request = line.trim_end_matches(['\r', '\n']);
189                if request.is_empty() {
190                    continue;
191                }
192                if let Some(reply) =
193                    qualia_core_db::mcp_server::handle_jsonrpc_message(request, qpu_enabled, true)
194                {
195                    let _ = writer_half.write_all(reply.as_bytes()).await;
196                    let _ = writer_half.write_all(b"\n").await;
197                }
198            }
199            Err(_) => break,
200        }
201    }
202}
203
204pub fn start_background(bind: &str, qpu_enabled: bool, force: bool) -> Result<(), String> {
205    ensure_runtime_dir()?;
206
207    if let Some(record) = read_service_record()? {
208        if pid_is_running(record.pid) {
209            return Err(format!(
210                "service already running (pid {}, bind {}). Use `qualia-cli mcp stop` first.",
211                record.pid, record.bind
212            ));
213        }
214        if !force {
215            eprintln!(
216                "Removing stale MCP service record for pid {} at {}.",
217                record.pid, record.bind
218            );
219        }
220        let _ = clear_service_record();
221    }
222
223    let launched_pid = spawn_detached_service(bind, qpu_enabled)?;
224
225    for _ in 0..20 {
226        std::thread::sleep(Duration::from_millis(200));
227        if let Some(record) = read_service_record()? {
228            if record.bind == bind && ping_service(bind).is_ok() {
229                println!(
230                    "MCP service started on tcp://{} (pid {}).",
231                    record.bind, record.pid
232                );
233                return Ok(());
234            }
235        }
236    }
237
238    let pid_hint = read_service_record()?
239        .map(|record| record.pid.to_string())
240        .or_else(|| launched_pid.map(|pid| pid.to_string()))
241        .unwrap_or_else(|| "unknown".to_string());
242    match ping_service(bind) {
243        Ok(_) => {
244            println!("MCP service started on tcp://{bind} (pid {pid_hint}).");
245            Ok(())
246        }
247        Err(err) => Err(format!(
248            "spawned pid {pid_hint}, but health probe failed: {err}. Check {}",
249            err_log_file_path().display()
250        )),
251    }
252}
253
254fn spawn_detached_service(bind: &str, qpu_enabled: bool) -> Result<Option<u32>, String> {
255    #[cfg(windows)]
256    {
257        spawn_detached_service_windows(bind, qpu_enabled)
258    }
259
260    #[cfg(not(windows))]
261    {
262        spawn_detached_service_portable(bind, qpu_enabled)
263    }
264}
265
266#[cfg(windows)]
267fn spawn_detached_service_windows(bind: &str, qpu_enabled: bool) -> Result<Option<u32>, String> {
268    let current_exe = std::env::current_exe().map_err(|e| e.to_string())?;
269    let log = log_file_path();
270    let err = err_log_file_path();
271    let batch = runtime_dir().join("mcp-start.cmd");
272    let line = if qpu_enabled {
273        format!(
274            "@echo off\r\n\"{}\" --enable-qpu mcp serve --transport tcp --bind {bind} --service-child 1>> \"{}\" 2>> \"{}\"\r\n",
275            current_exe.display(),
276            log.display(),
277            err.display()
278        )
279    } else {
280        format!(
281            "@echo off\r\n\"{}\" mcp serve --transport tcp --bind {bind} --service-child 1>> \"{}\" 2>> \"{}\"\r\n",
282            current_exe.display(),
283            log.display(),
284            err.display()
285        )
286    };
287    fs::write(&batch, line).map_err(|e| format!("write {}: {e}", batch.display()))?;
288
289    let mut command = Command::new("cmd");
290    command
291        .arg("/C")
292        .arg("start")
293        .arg("/B")
294        .arg("")
295        .arg(&batch)
296        .stdin(Stdio::null());
297
298    use std::os::windows::process::CommandExt;
299    const CREATE_NO_WINDOW: u32 = 0x08000000;
300    const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
301    command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
302
303    let child = command.spawn().map_err(|e| format!("spawn failed: {e}"))?;
304    Ok(Some(child.id()))
305}
306
307#[cfg(not(windows))]
308fn spawn_detached_service_portable(bind: &str, qpu_enabled: bool) -> Result<Option<u32>, String> {
309    let current_exe = std::env::current_exe().map_err(|e| e.to_string())?;
310    let mut command = Command::new(current_exe);
311    if qpu_enabled {
312        command.arg("--enable-qpu");
313    }
314    command
315        .arg("mcp")
316        .arg("serve")
317        .arg("--transport")
318        .arg("tcp")
319        .arg("--bind")
320        .arg(bind)
321        .arg("--service-child")
322        .stdin(Stdio::null());
323
324    let child = command.spawn().map_err(|e| format!("spawn failed: {e}"))?;
325    Ok(Some(child.id()))
326}
327
328pub fn stop_background() -> Result<(), String> {
329    let Some(record) = read_service_record()? else {
330        println!("MCP service is not running.");
331        return Ok(());
332    };
333
334    if !pid_is_running(record.pid) {
335        clear_service_record()?;
336        println!("Removed stale MCP service record for pid {}.", record.pid);
337        return Ok(());
338    }
339
340    let mut system = System::new_all();
341    system.refresh_all();
342    let pid = Pid::from_u32(record.pid);
343    let Some(process) = system.process(pid) else {
344        clear_service_record()?;
345        println!("Removed stale MCP service record for pid {}.", record.pid);
346        return Ok(());
347    };
348
349    let terminated = process.kill_with(Signal::Term).unwrap_or(false) || process.kill();
350    if !terminated {
351        return Err(format!("unable to terminate pid {}", record.pid));
352    }
353
354    for _ in 0..10 {
355        if !pid_is_running(record.pid) {
356            clear_service_record()?;
357            println!("Stopped MCP service pid {}.", record.pid);
358            return Ok(());
359        }
360        std::thread::sleep(Duration::from_millis(200));
361    }
362
363    clear_service_record()?;
364    println!(
365        "Sent termination to pid {} and cleared the MCP service record.",
366        record.pid
367    );
368    Ok(())
369}
370
371pub fn print_status() -> Result<(), String> {
372    let Some(record) = read_service_record()? else {
373        println!("stopped");
374        return Ok(());
375    };
376
377    let running = pid_is_running(record.pid);
378    let health = ping_service(&record.bind).ok();
379    let health_label = if health.is_some() {
380        "healthy"
381    } else {
382        "unreachable"
383    };
384
385    if running {
386        println!(
387            "running pid={} transport={:?} bind={} health={}",
388            record.pid, record.transport, record.bind, health_label
389        );
390    } else {
391        println!(
392            "stale pid={} transport={:?} bind={} health={}",
393            record.pid, record.transport, record.bind, health_label
394        );
395    }
396    Ok(())
397}
398
399pub fn print_doctor() -> Result<(), String> {
400    println!("MCP doctor");
401    println!("  foreground stdio : qualia-cli mcp serve");
402    println!(
403        "  background tcp   : qualia-cli mcp start --bind {}",
404        DEFAULT_MCP_BIND
405    );
406
407    match read_service_record()? {
408        Some(record) => {
409            println!(
410                "  service record   : pid={} transport={:?} bind={}",
411                record.pid, record.transport, record.bind
412            );
413            println!("  pid alive        : {}", pid_is_running(record.pid));
414            println!("  log file         : {}", record.log_path);
415
416            match ping_service(&record.bind) {
417                Ok(reply) => {
418                    println!("  health           : ok");
419                    if let Ok(tool_reply) = send_request(
420                        &record.bind,
421                        &json!({"jsonrpc":"2.0","id":"tools","method":"tools/list"}),
422                    ) {
423                        let count = tool_reply["result"]["tools"]
424                            .as_array()
425                            .map(|tools| tools.len())
426                            .unwrap_or(0);
427                        println!("  tools/list       : {} tool(s)", count);
428                    }
429                    println!(
430                        "  ping response    : {}",
431                        reply["jsonrpc"].as_str().unwrap_or("unknown")
432                    );
433                }
434                Err(err) => {
435                    println!("  health           : failed ({err})");
436                }
437            }
438        }
439        None => {
440            println!("  service record   : none");
441            println!("  health           : service not running");
442        }
443    }
444
445    Ok(())
446}
447
448fn ping_service(bind: &str) -> Result<Value, String> {
449    send_request(bind, &json!({"jsonrpc":"2.0","id":"ping","method":"ping"}))
450}
451
452fn send_request(bind: &str, payload: &Value) -> Result<Value, String> {
453    let mut stream = TcpStream::connect(bind).map_err(|e| format!("connect {bind}: {e}"))?;
454    stream
455        .set_read_timeout(Some(Duration::from_secs(2)))
456        .map_err(|e| e.to_string())?;
457    stream
458        .set_write_timeout(Some(Duration::from_secs(2)))
459        .map_err(|e| e.to_string())?;
460
461    let request = serde_json::to_string(payload).map_err(|e| e.to_string())?;
462    stream
463        .write_all(request.as_bytes())
464        .map_err(|e| format!("write request: {e}"))?;
465    stream
466        .write_all(b"\n")
467        .map_err(|e| format!("write newline: {e}"))?;
468    stream.flush().map_err(|e| e.to_string())?;
469    let _ = stream.shutdown(Shutdown::Write);
470
471    let mut reader = BufReader::new(stream);
472    let mut response = String::new();
473    reader
474        .read_line(&mut response)
475        .map_err(|e| format!("read response: {e}"))?;
476    if response.trim().is_empty() {
477        return Err("empty response".to_string());
478    }
479    serde_json::from_str(response.trim()).map_err(|e| format!("decode response: {e}"))
480}
481
482fn ensure_runtime_dir() -> Result<(), String> {
483    fs::create_dir_all(runtime_dir()).map_err(|e| e.to_string())
484}
485
486fn runtime_dir() -> PathBuf {
487    state_dir().join("run")
488}
489
490fn state_dir() -> PathBuf {
491    if let Ok(dir) = std::env::var("QUALIA_DATA_DIR") {
492        return PathBuf::from(dir);
493    }
494    if let Ok(dir) = std::env::var("QUALIA_STORAGE_PATH") {
495        return PathBuf::from(dir);
496    }
497    if let Ok(dir) = std::env::current_dir() {
498        return dir.join(".qualia");
499    }
500    if let Ok(home) = std::env::var("HOME") {
501        return PathBuf::from(home).join(".qualia");
502    }
503    if let Ok(home) = std::env::var("USERPROFILE") {
504        return PathBuf::from(home).join(".qualia");
505    }
506    PathBuf::from(".qualia")
507}
508
509fn pid_file_path() -> PathBuf {
510    runtime_dir().join(PID_FILE_NAME)
511}
512
513fn log_file_path() -> PathBuf {
514    runtime_dir().join(LOG_FILE_NAME)
515}
516
517fn err_log_file_path() -> PathBuf {
518    runtime_dir().join(ERR_LOG_FILE_NAME)
519}
520
521fn read_service_record() -> Result<Option<McpServiceRecord>, String> {
522    let path = pid_file_path();
523    if !path.exists() {
524        return Ok(None);
525    }
526    let raw = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
527    let record =
528        serde_json::from_str(&raw).map_err(|e| format!("decode {}: {e}", path.display()))?;
529    Ok(Some(record))
530}
531
532fn write_service_record(record: McpServiceRecord) -> Result<(), String> {
533    ensure_runtime_dir()?;
534    let path = pid_file_path();
535    let raw = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
536    fs::write(&path, raw).map_err(|e| format!("write {}: {e}", path.display()))
537}
538
539fn clear_service_record() -> Result<(), String> {
540    let path = pid_file_path();
541    if path.exists() {
542        fs::remove_file(&path).map_err(|e| format!("remove {}: {e}", path.display()))?;
543    }
544    Ok(())
545}
546
547fn pid_is_running(pid: u32) -> bool {
548    let mut system = System::new_all();
549    system.refresh_all();
550    system.process(Pid::from_u32(pid)).is_some()
551}