Skip to main content

outrig_cli/cli/
mcp.rs

1//! `outrig mcp` orchestrator: wire the shared `SessionSetup` bootstrap to a
2//! [`ProxyServer`] served over rmcp's stdio transport by default, or
3//! Streamable HTTP when `--listen` is set. Runs as a server (not a REPL):
4//! the external stdio client speaks JSON-RPC on the binary's stdout, and
5//! everything else (banner, tracing) goes to stderr.
6//!
7//! The exit triggers -- stdio stdin EOF (peer disconnect), SIGINT, SIGTERM,
8//! and attached-container stop -- all funnel through the same teardown order
9//! as `outrig run`: cancel the rmcp service so its dispatcher quiesces ->
10//! `McpClient::shutdown` per backing server -> `Container::stop` ->
11//! `SessionStore::finalize`. Backing MCPs are `podman exec` processes whose
12//! pipes ride through the container, so tearing the container down before
13//! stopping the rmcp service races them.
14
15#![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    /// Pick a `[images.<name>]` block. Falls back to top-level
63    /// `default-image` only -- `outrig mcp` has no agent, so there is no
64    /// `agent.image` to consult. An explicit value that doesn't match config
65    /// is used as a local Podman image ref, never pulled.
66    #[arg(long, global = true, value_name = "NAME-OR-LOCAL-REF")]
67    pub image: Option<String>,
68
69    /// Write the session into an explicit, already-existing directory. The
70    /// session root gets a symlink at `<root>/<sid>` pointing at this path.
71    #[arg(long = "session-dir", global = true, value_name = "PATH")]
72    pub session_dir: Option<PathBuf>,
73
74    /// Serve MCP over Streamable HTTP at a TCP address or Unix socket
75    /// (`127.0.0.1:7331`, `0.0.0.0:7331`, or `unix:/tmp/outrig.sock`).
76    #[arg(long, value_name = "ADDR", value_parser = parse_listen_addr)]
77    pub listen: Option<ListenAddr>,
78
79    /// Attach to an existing outrig session id or podman container name
80    /// instead of starting a fresh container.
81    #[arg(long, global = true, value_name = "SESSION_OR_CONTAINER")]
82    pub attach: Option<String>,
83
84    /// Add or override env vars for MCP servers. Repeatable.
85    /// `KEY=VALUE` applies to every server; `SERVER:KEY=VALUE` targets one.
86    #[arg(long = "env", global = true, value_name = "KEY=VALUE", action = ArgAction::Append)]
87    pub env: Vec<String>,
88
89    /// Override network monitoring for this session.
90    #[arg(long = "network", global = true, value_name = "MODE", value_parser = parse_network_mode)]
91    pub network: Option<NetworkMode>,
92
93    /// Mount an extra host directory into the container. Repeatable. Format
94    /// `HOST:CONTAINER[:ro|rw]` (default read-only; the host dir must exist).
95    #[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    /// Serve OutRig's self-description tools over stdio.
102    #[command(name = "self")]
103    SelfDescription,
104    /// Print the image/config merged MCP table and exit.
105    ShowMerged,
106}
107
108impl McpArgs {
109    pub fn is_self_description(&self) -> bool {
110        matches!(self.cmd, Some(McpCommand::SelfDescription))
111    }
112}
113
114/// Run one `outrig mcp` invocation end-to-end. Returns the process exit code.
115pub 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    // Validate per-server env entries against the resolved MCP map.
176    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    // `serve_server_with_ct` lets us hold the cancellation token outside the
324    // service, which is otherwise consumed by `waiting()`. Cancel-on-signal
325    // -> dispatcher quiesces -> `waiting()` returns -> teardown runs.
326    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    // Signal path: wait for the service to wind down after cancellation.
377    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}