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 Serve {
30 #[arg(long, value_enum, default_value_t = McpTransport::Stdio)]
32 transport: McpTransport,
33 #[arg(long, default_value = DEFAULT_MCP_BIND)]
35 bind: String,
36 #[arg(long, hide = true)]
38 service_child: bool,
39 },
40 Start {
42 #[arg(long, default_value = DEFAULT_MCP_BIND)]
44 bind: String,
45 #[arg(long)]
47 force: bool,
48 },
49 Stop,
51 Status,
53 Doctor,
55 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
126fn 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}