1#![deny(clippy::print_stdout)]
16
17use std::collections::BTreeMap;
18use std::fmt::Write as _;
19use std::future::IntoFuture;
20use std::io::Write as _;
21use std::net::SocketAddr;
22#[cfg(unix)]
23use std::os::unix::fs::FileTypeExt;
24use std::path::{Path, PathBuf};
25use std::process::Stdio;
26use std::sync::Arc;
27use std::time::Duration;
28
29use clap::{ArgAction, Parser, Subcommand};
30use rmcp::transport::streamable_http_server::{
31 SessionManager, StreamableHttpServerConfig, StreamableHttpService,
32 session::local::LocalSessionManager,
33};
34use serde::Serialize;
35use tokio::signal::unix::{SignalKind, signal};
36use tokio_util::sync::CancellationToken;
37
38use crate::cli::env_arg::CliEnvEntries;
39use crate::cli::session_setup::{self, SessionSetup, SessionSetupArgs};
40use crate::cli::volume_arg::{CliVolume, parse_volume};
41use crate::error::{OutrigError, Result};
42use outrig::McpClient;
43use outrig::config::{ImageConfig, McpServerSpec, NetworkMode};
44use outrig::container::Container;
45use outrig::image::ImageTag;
46use outrig::mcp_proxy::ProxyServer;
47
48const ATTACH_MONITOR_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
49const HTTP_SESSION_SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum ListenAddr {
53 Tcp(SocketAddr),
54 Unix(PathBuf),
55}
56
57#[derive(Debug, Parser)]
58pub struct McpArgs {
59 #[command(subcommand)]
60 pub cmd: Option<McpCommand>,
61
62 #[arg(long, global = true, value_name = "NAME-OR-LOCAL-REF")]
67 pub image: Option<String>,
68
69 #[arg(long = "session-dir", global = true, value_name = "PATH")]
72 pub session_dir: Option<PathBuf>,
73
74 #[arg(long, value_name = "ADDR", value_parser = parse_listen_addr)]
77 pub listen: Option<ListenAddr>,
78
79 #[arg(long, global = true, value_name = "SESSION_OR_CONTAINER")]
82 pub attach: Option<String>,
83
84 #[arg(long = "env", global = true, value_name = "KEY=VALUE", action = ArgAction::Append)]
87 pub env: Vec<String>,
88
89 #[arg(long = "network", global = true, value_name = "MODE", value_parser = parse_network_mode)]
91 pub network: Option<NetworkMode>,
92
93 #[arg(long = "volume", global = true, value_name = "HOST:CONTAINER[:ro|rw]", action = ArgAction::Append, value_parser = parse_volume)]
96 pub volume: Vec<CliVolume>,
97}
98
99#[derive(Debug, Subcommand)]
100pub enum McpCommand {
101 #[command(name = "self")]
103 SelfDescription,
104 ShowMerged,
106}
107
108impl McpArgs {
109 pub fn is_self_description(&self) -> bool {
110 matches!(self.cmd, Some(McpCommand::SelfDescription))
111 }
112}
113
114pub async fn execute(
116 repo_cfg_path: &Path,
117 global_cfg_path: &Path,
118 session_root_flag: Option<&Path>,
119 args: &McpArgs,
120 verbose: u8,
121) -> Result<i32> {
122 let cli_env =
123 CliEnvEntries::parse(&args.env).map_err(|e| OutrigError::Configuration(e.to_string()))?;
124 if matches!(args.cmd, Some(McpCommand::ShowMerged)) && args.listen.is_some() {
125 return Err(OutrigError::Configuration(
126 "`outrig mcp show-merged` does not serve MCP; remove --listen".to_string(),
127 )
128 .into());
129 }
130
131 let setup = session_setup::setup(SessionSetupArgs {
132 repo_cfg_path,
133 global_cfg_path,
134 session_root_flag,
135 image_flag: args.image.as_deref(),
136 attach_target: args.attach.as_deref(),
137 agent_flag: None,
138 model_override: None,
139 require_agent: false,
140 explicit_session_dir: args.session_dir.as_deref(),
141 network_mode_override: args.network,
142 device_override: None,
143 volumes: &args.volume,
144 verbose,
145 })
146 .await?;
147
148 match &args.cmd {
149 Some(McpCommand::SelfDescription) => unreachable!("handled before repo context"),
150 None => serve(setup, cli_env, args.listen.as_ref()).await,
151 Some(McpCommand::ShowMerged) => show_merged(setup).await,
152 }
153}
154
155async fn serve(
156 setup: SessionSetup,
157 cli_env: CliEnvEntries,
158 listen: Option<&ListenAddr>,
159) -> Result<i32> {
160 let SessionSetup {
161 image_cfg_name,
162 image_cfg,
163 image_tag,
164 container,
165 sid,
166 log_dir,
167 store,
168 attached,
169 network,
170 cfg: _,
171 session: _,
172 session_dir: _,
173 } = setup;
174
175 let mcp = session_setup::merged_mcp(&container, &image_cfg).await?;
177 for name in cli_env.per_server_names() {
178 if !mcp.contains_key(name) {
179 return Err(OutrigError::Configuration(format!(
180 "--env {name}:...: image '{}' has no MCP server '{name}'",
181 image_cfg_name
182 ))
183 .into());
184 }
185 }
186
187 let mut mcp_arcs: Vec<Arc<McpClient>> = Vec::new();
188 let outcome: Result<i32> = serve_inner(
189 &image_cfg_name,
190 &image_tag,
191 &container,
192 &log_dir,
193 sid.as_str(),
194 &mut mcp_arcs,
195 &mcp,
196 &cli_env,
197 attached,
198 listen,
199 )
200 .await;
201
202 let final_exit = outcome.as_ref().copied().unwrap_or(1);
203 session_setup::teardown(mcp_arcs, network, container, &store, &sid, final_exit).await;
204 if attached
205 && outcome
206 .as_ref()
207 .err()
208 .is_some_and(is_attached_container_stopped)
209 {
210 eprintln!(
211 "error: {}",
212 outcome.as_ref().expect_err("checked err above")
213 );
214 std::process::exit(final_exit.clamp(0, 255));
215 }
216 outcome
217}
218
219fn is_attached_container_stopped(err: &crate::error::CliError) -> bool {
220 matches!(
221 err,
222 crate::error::CliError::Outrig(OutrigError::Configuration(msg))
223 if msg.contains("attached container") && msg.contains("stopped"),
224 )
225}
226
227async fn show_merged(setup: SessionSetup) -> Result<i32> {
228 let SessionSetup {
229 image_cfg,
230 container,
231 sid,
232 store,
233 attached: _,
234 network,
235 cfg: _,
236 image_cfg_name: _,
237 image_tag: _,
238 session: _,
239 session_dir: _,
240 log_dir: _,
241 } = setup;
242
243 let outcome = show_merged_inner(&image_cfg, &container).await;
244 let final_exit = outcome.as_ref().copied().unwrap_or(1);
245 session_setup::teardown(Vec::new(), network, container, &store, &sid, final_exit).await;
246 outcome
247}
248
249fn parse_network_mode(s: &str) -> std::result::Result<NetworkMode, String> {
250 s.parse()
251}
252
253fn parse_listen_addr(s: &str) -> std::result::Result<ListenAddr, String> {
254 if let Some(path) = s.strip_prefix("unix:") {
255 if path.is_empty() {
256 return Err("unix listen address must include a socket path".to_string());
257 }
258 return Ok(ListenAddr::Unix(PathBuf::from(path)));
259 }
260
261 s.parse::<SocketAddr>().map(ListenAddr::Tcp).map_err(|_| {
262 "listen address must be HOST:PORT, [IPv6]:PORT, or unix:/path/to/socket".to_string()
263 })
264}
265
266#[allow(clippy::too_many_arguments)]
267async fn serve_inner(
268 image_cfg_name: &str,
269 image_tag: &ImageTag,
270 container: &Container,
271 log_dir: &Path,
272 session_id: &str,
273 mcp_arcs: &mut Vec<Arc<McpClient>>,
274 mcp: &BTreeMap<String, McpServerSpec>,
275 cli_env: &CliEnvEntries,
276 attached: bool,
277 listen: Option<&ListenAddr>,
278) -> Result<i32> {
279 let connected = session_setup::connect_mcp_clients(container, mcp, log_dir, cli_env).await?;
280 if connected.is_empty() {
281 return Err(OutrigError::Configuration(
282 "outrig mcp with no merged MCP entries has nothing to proxy".to_string(),
283 )
284 .into());
285 }
286 mcp_arcs.extend(connected);
287
288 let proxy = ProxyServer::build(mcp_arcs.clone()).await?;
289 let per_server_counts: Vec<(String, usize)> = proxy
290 .per_server_counts()
291 .into_iter()
292 .map(|(n, c)| (n.to_string(), c))
293 .collect();
294 let public_names: Vec<String> = proxy.iter_public_names().map(str::to_string).collect();
295
296 let transport = match listen {
297 None => "stdio",
298 Some(ListenAddr::Tcp(_) | ListenAddr::Unix(_)) => "streamable-http",
299 };
300
301 print_banner(StartupBanner {
302 container_name: image_cfg_name,
303 image_tag,
304 container_pod_name: container.name(),
305 per_server_counts: &per_server_counts,
306 public_names: &public_names,
307 session_id,
308 attached,
309 transport,
310 });
311
312 match listen {
313 None => serve_stdio_transport(proxy, container, attached).await,
314 Some(addr) => serve_http_transport(proxy, addr, container, attached, mcp_arcs).await,
315 }
316}
317
318async fn serve_stdio_transport(
319 proxy: ProxyServer,
320 container: &Container,
321 attached: bool,
322) -> Result<i32> {
323 let ct = CancellationToken::new();
327 let service =
328 rmcp::service::serve_server_with_ct(proxy, rmcp::transport::stdio(), ct.clone()).await?;
329 eprintln!("[outrig] mcp server ready");
330
331 let mut waiter = tokio::spawn(service.waiting());
332 let mut sigterm = signal(SignalKind::terminate()).map_err(OutrigError::Io)?;
333 let mut monitor = Box::pin(async {
334 if attached {
335 wait_for_attached_container_stop(container.name().to_string()).await
336 } else {
337 std::future::pending::<Result<()>>().await
338 }
339 });
340
341 tokio::select! {
342 biased;
343 _ = tokio::signal::ctrl_c() => {
344 tracing::info!(target: "outrig::cli::mcp", "received SIGINT; shutting down");
345 ct.cancel();
346 }
347 _ = sigterm.recv() => {
348 tracing::info!(target: "outrig::cli::mcp", "received SIGTERM; shutting down");
349 ct.cancel();
350 }
351 result = &mut waiter => {
352 log_waiter_result(result);
353 return Ok(0);
354 }
355 result = &mut monitor => {
356 ct.cancel();
357 match tokio::time::timeout(ATTACH_MONITOR_SHUTDOWN_GRACE, &mut waiter).await {
358 Ok(waiter_result) => log_waiter_result(waiter_result),
359 Err(_) => {
360 waiter.abort();
361 tracing::warn!(
362 target: "outrig::cli::mcp",
363 "rmcp service did not stop after attached container disappeared"
364 );
365 }
366 }
367 return match result {
368 Ok(()) => Err(OutrigError::Configuration(
369 "attached container monitor ended unexpectedly".to_string(),
370 ).into()),
371 Err(e) => Err(e),
372 };
373 }
374 }
375
376 let result = waiter.await;
378 log_waiter_result(result);
379 Ok(0)
380}
381
382async fn serve_http_transport(
383 proxy: ProxyServer,
384 listen: &ListenAddr,
385 container: &Container,
386 attached: bool,
387 backing_clients: &[Arc<McpClient>],
388) -> Result<i32> {
389 let ct = CancellationToken::new();
390 let session_manager = Arc::new(LocalSessionManager::default());
391 let router = streamable_http_router(proxy, listen, ct.child_token(), session_manager.clone());
392
393 let outcome = match listen {
394 ListenAddr::Tcp(addr) => {
395 let listener = tokio::net::TcpListener::bind(addr).await?;
396 let local_addr = listener.local_addr()?;
397 if let Some(warning) = listen_exposure_warning(&ListenAddr::Tcp(local_addr)) {
398 eprintln!("{warning}");
399 }
400 eprintln!(
401 "[outrig] listen: {}",
402 listen_endpoint(&ListenAddr::Tcp(local_addr))
403 );
404 let shutdown = http_shutdown(ct.clone());
405 let server = axum::serve(listener, router).with_graceful_shutdown(shutdown);
406 wait_for_http_shutdown(server, ct, container, attached).await
407 }
408 ListenAddr::Unix(path) => {
409 serve_unix_http_transport(router, path, ct, container, attached).await
410 }
411 };
412 close_http_sessions(&session_manager).await;
413 wait_for_http_session_refs(backing_clients).await;
414 outcome
415}
416
417#[cfg(unix)]
418async fn serve_unix_http_transport(
419 router: axum::Router,
420 path: &Path,
421 ct: CancellationToken,
422 container: &Container,
423 attached: bool,
424) -> Result<i32> {
425 prepare_unix_socket(path)?;
426 let listener = tokio::net::UnixListener::bind(path)?;
427 let _cleanup = UnixSocketCleanup {
428 path: path.to_path_buf(),
429 };
430 eprintln!("[outrig] listen: unix:{}", path.display());
431 let shutdown = http_shutdown(ct.clone());
432 let server = axum::serve(listener, router).with_graceful_shutdown(shutdown);
433 wait_for_http_shutdown(server, ct, container, attached).await
434}
435
436#[cfg(not(unix))]
437async fn serve_unix_http_transport(
438 _router: axum::Router,
439 _path: &Path,
440 _ct: CancellationToken,
441 _container: &Container,
442 _attached: bool,
443) -> Result<i32> {
444 Err(
445 OutrigError::Configuration("unix listen addresses require a Unix platform".to_string())
446 .into(),
447 )
448}
449
450fn streamable_http_router(
451 proxy: ProxyServer,
452 listen: &ListenAddr,
453 ct: CancellationToken,
454 session_manager: Arc<LocalSessionManager>,
455) -> axum::Router {
456 let service = StreamableHttpService::new(
457 move || Ok(proxy.clone()),
458 session_manager,
459 streamable_http_config(listen, ct),
460 );
461 axum::Router::new().nest_service("/mcp", service)
462}
463
464fn streamable_http_config(
465 listen: &ListenAddr,
466 ct: CancellationToken,
467) -> StreamableHttpServerConfig {
468 let config = StreamableHttpServerConfig::default().with_cancellation_token(ct);
469 match listen {
470 ListenAddr::Tcp(addr) if addr.ip().is_loopback() => config,
471 ListenAddr::Tcp(_) | ListenAddr::Unix(_) => config.disable_allowed_hosts(),
472 }
473}
474
475async fn http_shutdown(ct: CancellationToken) {
476 ct.cancelled_owned().await;
477}
478
479async fn wait_for_http_shutdown<F>(
480 server: F,
481 ct: CancellationToken,
482 container: &Container,
483 attached: bool,
484) -> Result<i32>
485where
486 F: IntoFuture<Output = std::io::Result<()>>,
487{
488 eprintln!("[outrig] mcp server ready");
489 let mut server = Box::pin(server.into_future());
490 let mut sigterm = signal(SignalKind::terminate()).map_err(OutrigError::Io)?;
491 let mut monitor = Box::pin(async {
492 if attached {
493 wait_for_attached_container_stop(container.name().to_string()).await
494 } else {
495 std::future::pending::<Result<()>>().await
496 }
497 });
498
499 tokio::select! {
500 biased;
501 _ = tokio::signal::ctrl_c() => {
502 tracing::info!(target: "outrig::cli::mcp", "received SIGINT; shutting down");
503 ct.cancel();
504 }
505 _ = sigterm.recv() => {
506 tracing::info!(target: "outrig::cli::mcp", "received SIGTERM; shutting down");
507 ct.cancel();
508 }
509 result = &mut server => {
510 result?;
511 return Ok(0);
512 }
513 result = &mut monitor => {
514 ct.cancel();
515 match tokio::time::timeout(ATTACH_MONITOR_SHUTDOWN_GRACE, &mut server).await {
516 Ok(server_result) => server_result?,
517 Err(_) => {
518 tracing::warn!(
519 target: "outrig::cli::mcp",
520 "HTTP MCP service did not stop after attached container disappeared"
521 );
522 }
523 }
524 return match result {
525 Ok(()) => Err(OutrigError::Configuration(
526 "attached container monitor ended unexpectedly".to_string(),
527 ).into()),
528 Err(e) => Err(e),
529 };
530 }
531 }
532
533 server.await?;
534 Ok(0)
535}
536
537async fn close_http_sessions(session_manager: &LocalSessionManager) {
538 let session_ids = session_manager
539 .sessions
540 .read()
541 .await
542 .keys()
543 .cloned()
544 .collect::<Vec<_>>();
545 for session_id in session_ids {
546 if let Err(e) = session_manager.close_session(&session_id).await {
547 tracing::warn!(
548 target: "outrig::cli::mcp",
549 "failed to close HTTP MCP session {session_id}: {e}"
550 );
551 }
552 }
553}
554
555async fn wait_for_http_session_refs(backing_clients: &[Arc<McpClient>]) {
556 let released = tokio::time::timeout(HTTP_SESSION_SHUTDOWN_GRACE, async {
557 while backing_clients
558 .iter()
559 .any(|client| Arc::strong_count(client) > 1)
560 {
561 tokio::time::sleep(Duration::from_millis(50)).await;
562 }
563 })
564 .await;
565
566 if released.is_err() {
567 let counts = backing_clients
568 .iter()
569 .map(|client| format!("{}={}", client.name(), Arc::strong_count(client)))
570 .collect::<Vec<_>>()
571 .join(", ");
572 tracing::warn!(
573 target: "outrig::cli::mcp",
574 "HTTP MCP sessions still hold backing clients after shutdown grace: {counts}"
575 );
576 }
577}
578
579fn listen_endpoint(listen: &ListenAddr) -> String {
580 match listen {
581 ListenAddr::Tcp(addr) => format!("http://{addr}/mcp"),
582 ListenAddr::Unix(path) => format!("unix:{}", path.display()),
583 }
584}
585
586fn listen_exposure_warning(listen: &ListenAddr) -> Option<String> {
587 match listen {
588 ListenAddr::Tcp(addr) if !addr.ip().is_loopback() => Some(format!(
589 "[outrig] WARNING: listening on {addr} exposes this container's MCP tool surface \
590 to anything that can reach the port; v1 has no built-in auth"
591 )),
592 _ => None,
593 }
594}
595
596#[cfg(unix)]
597fn prepare_unix_socket(path: &Path) -> Result<()> {
598 if let Some(parent) = path.parent()
599 && !parent.as_os_str().is_empty()
600 && !parent.exists()
601 {
602 return Err(OutrigError::Configuration(format!(
603 "unix listen socket parent directory does not exist: {}",
604 parent.display()
605 ))
606 .into());
607 }
608
609 match std::fs::metadata(path) {
610 Ok(meta) if meta.file_type().is_socket() => {
611 std::fs::remove_file(path)?;
612 Ok(())
613 }
614 Ok(_) => Err(OutrigError::Configuration(format!(
615 "unix listen path exists and is not a socket: {}",
616 path.display()
617 ))
618 .into()),
619 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
620 Err(e) => Err(OutrigError::Io(e).into()),
621 }
622}
623
624#[cfg(unix)]
625struct UnixSocketCleanup {
626 path: PathBuf,
627}
628
629#[cfg(unix)]
630impl Drop for UnixSocketCleanup {
631 fn drop(&mut self) {
632 let _ = std::fs::remove_file(&self.path);
633 }
634}
635
636async fn wait_for_attached_container_stop(container_name: String) -> Result<()> {
637 let mut child = tokio::process::Command::new("podman")
638 .arg("wait")
639 .arg(&container_name)
640 .stdin(Stdio::null())
641 .stdout(Stdio::null())
642 .stderr(Stdio::null())
643 .kill_on_drop(true)
644 .spawn()?;
645 let status = child.wait().await?;
646 if !status.success() {
647 tracing::warn!(
648 target: "outrig::cli::mcp",
649 "podman wait for attached container {container_name:?} exited with {status}"
650 );
651 }
652 Err(OutrigError::Configuration(format!(
653 "attached container {container_name:?} stopped while `outrig mcp` was attached"
654 ))
655 .into())
656}
657
658async fn show_merged_inner(image_cfg: &ImageConfig, container: &Container) -> Result<i32> {
659 let mcp = session_setup::merged_mcp(container, image_cfg).await?;
660 write_merged_mcp(&mcp)?;
661 Ok(0)
662}
663
664fn write_merged_mcp(mcp: &BTreeMap<String, McpServerSpec>) -> Result<()> {
665 #[derive(Serialize)]
666 struct MergedMcpView<'a> {
667 mcp: &'a BTreeMap<String, McpServerSpec>,
668 }
669
670 let rendered = if mcp.is_empty() {
671 "[mcp]\n".to_string()
672 } else {
673 toml::to_string_pretty(&MergedMcpView { mcp }).map_err(|source| {
674 OutrigError::Configuration(format!("serialize merged MCP TOML: {source}"))
675 })?
676 };
677
678 let mut stdout = std::io::stdout().lock();
679 stdout.write_all(rendered.as_bytes())?;
680 stdout.flush()?;
681 Ok(())
682}
683
684fn log_waiter_result(
685 result: std::result::Result<
686 std::result::Result<rmcp::service::QuitReason, tokio::task::JoinError>,
687 tokio::task::JoinError,
688 >,
689) {
690 match result {
691 Ok(Ok(reason)) => {
692 tracing::debug!(
693 target: "outrig::cli::mcp",
694 "rmcp service exited: {reason:?}"
695 );
696 }
697 Ok(Err(join_err)) => {
698 tracing::warn!(
699 target: "outrig::cli::mcp",
700 "rmcp dispatcher join error: {join_err}"
701 );
702 }
703 Err(join_err) => {
704 tracing::warn!(
705 target: "outrig::cli::mcp",
706 "rmcp waiter join error: {join_err}"
707 );
708 }
709 }
710}
711
712struct StartupBanner<'a> {
713 container_name: &'a str,
714 image_tag: &'a ImageTag,
715 container_pod_name: &'a str,
716 per_server_counts: &'a [(String, usize)],
717 public_names: &'a [String],
718 session_id: &'a str,
719 attached: bool,
720 transport: &'a str,
721}
722
723fn print_banner(banner: StartupBanner<'_>) {
724 let mut buf = String::new();
725 let _ = writeln!(buf, "[outrig] image-config: {}", banner.container_name);
726 let _ = writeln!(buf, "[outrig] image: {}", banner.image_tag);
727 let container_action = if banner.attached {
728 "attached"
729 } else {
730 "started"
731 };
732 let _ = writeln!(
733 buf,
734 "[outrig] container {container_action}: {}",
735 banner.container_pod_name
736 );
737 for (name, count) in banner.per_server_counts {
738 let plural = if *count == 1 { "tool" } else { "tools" };
739 let _ = writeln!(buf, "[outrig] mcp {name}: initialized ({count} {plural})");
740 }
741 let names_joined = banner
742 .public_names
743 .iter()
744 .map(String::as_str)
745 .collect::<Vec<_>>()
746 .join(", ");
747 let _ = writeln!(buf, "[outrig] tools available: {names_joined}");
748 let _ = writeln!(buf, "[outrig] session id: {}", banner.session_id);
749 let _ = writeln!(buf, "[outrig] transport: {}", banner.transport);
750 eprint!("{buf}");
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
758 fn parse_listen_addr_accepts_tcp_socket_addr() {
759 let parsed = parse_listen_addr("127.0.0.1:7331").expect("parse listen addr");
760 assert_eq!(
761 parsed,
762 ListenAddr::Tcp("127.0.0.1:7331".parse().expect("socket addr"))
763 );
764 }
765
766 #[test]
767 fn parse_listen_addr_accepts_unix_prefix() {
768 let parsed = parse_listen_addr("unix:/tmp/outrig.sock").expect("parse listen addr");
769 assert_eq!(parsed, ListenAddr::Unix(PathBuf::from("/tmp/outrig.sock")));
770 }
771
772 #[test]
773 fn parse_listen_addr_rejects_missing_port() {
774 let err = parse_listen_addr("127.0.0.1").expect_err("missing port should fail");
775 assert!(
776 err.contains("HOST:PORT"),
777 "error should explain accepted forms: {err}"
778 );
779 }
780
781 #[test]
782 fn mcp_args_parse_listen_flag() {
783 let args =
784 McpArgs::try_parse_from(["mcp", "--listen", "127.0.0.1:7331"]).expect("arg parses");
785 assert_eq!(
786 args.listen,
787 Some(ListenAddr::Tcp(
788 "127.0.0.1:7331".parse().expect("socket addr")
789 ))
790 );
791 }
792
793 #[test]
794 fn mcp_args_parse_volume_flag() {
795 let args = McpArgs::try_parse_from(["mcp", "--volume", "/h:/c:rw"]).expect("arg parses");
796 assert_eq!(args.volume.len(), 1);
797 assert_eq!(args.volume[0].container, std::path::PathBuf::from("/c"));
798 }
799
800 #[test]
801 fn listen_exposure_warning_only_for_non_loopback_tcp() {
802 let loopback = ListenAddr::Tcp("127.0.0.1:7331".parse().expect("socket addr"));
803 assert!(listen_exposure_warning(&loopback).is_none());
804
805 let public = ListenAddr::Tcp("0.0.0.0:7331".parse().expect("socket addr"));
806 let warning = listen_exposure_warning(&public).expect("warning");
807 assert!(warning.contains("WARNING"));
808 assert!(warning.contains("no built-in auth"));
809 assert!(warning.contains("MCP tool surface"));
810
811 let unix = ListenAddr::Unix(PathBuf::from("/tmp/outrig.sock"));
812 assert!(listen_exposure_warning(&unix).is_none());
813 }
814}