ringo-mcp 0.1.0

MCP server over stdio exposing ringo SIP agents for LLM-driven telephony
Documentation
//! `ringo-mcp` — MCP server binary. See `lib.rs` for the architecture.
//!
//! Subcommands:
//! - *(none)* — load the config, spawn the agents, serve MCP on stdio.
//! - `agent` — internal: one worker process, driven by the framed stdio
//!   protocol (the MCP server spawns *itself* with this argument; not for
//!   direct use).

use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use ringo_mcp::{config, hub, serve, trace};
use std::path::PathBuf;
use std::sync::Arc;

#[derive(Parser)]
#[command(
    name = "ringo-mcp",
    version,
    about = "MCP server (stdio) exposing ringo SIP agents",
    long_about = "An MCP server over stdio that gives LLM agents a telephone: SIP agents \
                  configured in a TOML file, driven from any MCP client. See the crate README \
                  for the config format and the full tool reference."
)]
struct Cli {
    /// Config file (default: $RINGO_MCP_CONFIG, or
    /// ~/.config/ringo-mcp/config.toml)
    #[arg(long, value_name = "PATH", global = true)]
    config: Option<PathBuf>,

    /// Comma-separated allowlist of tool groups and/or tool names —
    /// everything else is disabled
    #[arg(long, value_name = "LIST", value_delimiter = ',')]
    enabled_tools: Option<Vec<String>>,

    /// Disable a tool group or a single tool (repeatable; applied on top of
    /// --enabled-tools). Groups: discovery, call-control, audio, headers,
    /// events, recording, lifecycle, trace
    #[arg(long = "disable", value_name = "GROUP|TOOL", value_delimiter = ',')]
    disable: Vec<String>,

    /// Deny dial/transfer targets matching this regex (repeatable; matched
    /// against the dialed number and the full resolved URI). Always wins
    /// over --dial-allow
    #[arg(long = "dial-deny", value_name = "REGEX")]
    dial_deny: Vec<String>,

    /// Only permit dial/transfer targets matching this regex (repeatable;
    /// matched against the dialed number and the full resolved URI). Empty =
    /// unrestricted
    #[arg(long = "dial-allow", value_name = "REGEX")]
    dial_allow: Vec<String>,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// Internal: run a single agent as a worker process over the framed stdio
    /// protocol (spawned by the server as `<exe> agent`; not for direct use).
    #[command(hide = true)]
    Agent,
}

fn main() -> Result<()> {
    // Shell completion: clap_complete invokes us with COMPLETE=<shell> set to
    // emit the script (e.g. the nix flake's postInstall). Must run before
    // anything else — without it, a completion invocation would fall through
    // to serving MCP on stdio and hang the caller.
    clap_complete::env::CompleteEnv::with_factory(Cli::command).complete();

    let cli = Cli::parse();

    // Internal worker process — must run before anything else touches config
    // or the runtime.
    if matches!(cli.command, Some(Commands::Agent)) {
        return ringo_agent::worker::run();
    }

    // Tool surface: validated up front (a typo'd flag must fail loudly).
    let disabled_tools =
        ringo_mcp::resolve_disabled_tools(cli.enabled_tools.as_deref(), &cli.disable)?;
    // Dial policy: validated (regex-compiled) up front like the tool flags.
    let dial_policy = hub::DialPolicy::build(cli.dial_deny, cli.dial_allow)?;

    let path = cli
        .config
        .clone()
        .or_else(|| std::env::var_os("RINGO_MCP_CONFIG").map(PathBuf::from))
        .unwrap_or_else(config::default_path);
    let loaded = config::load(&path)?;

    // SIP traces for the `sip_trace` tool: the workers inherit the destination
    // via env. A user-set value wins (e.g. a pcap for sngrep); otherwise trace
    // as text into a private (0700, randomly named) temp dir that is removed
    // when it drops at exit. Traces carry SIP auth headers — hence private.
    let owned_trace_dir = match std::env::var_os(trace::TRACE_ENV) {
        Some(_) => None,
        None => {
            let base =
                std::env::var_os("XDG_RUNTIME_DIR").map_or_else(std::env::temp_dir, PathBuf::from);
            let mut builder = tempfile::Builder::new();
            builder.prefix("ringo-mcp-");
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                builder.permissions(std::fs::Permissions::from_mode(0o700));
            }
            let dir = builder
                .tempdir_in(&base)
                .with_context(|| format!("create SIP trace dir in {}", base.display()))?;
            // SAFETY: set once at startup, before the runtime and any worker
            // threads exist.
            unsafe { std::env::set_var(trace::TRACE_ENV, dir.path().join("sip.trace")) };
            Some(dir)
        }
    };
    let sip_traces =
        trace::SipTraceFiles::from_env_value(&std::env::var(trace::TRACE_ENV).unwrap_or_default());

    // MCP stdio + broadcast/timers are async; the agent workers themselves run
    // as child processes (their own blocking loops), so a multi-thread runtime
    // only serves the server's I/O here.
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .context("build tokio runtime")?;
    let n = loaded.agents.len();
    let hub = Arc::new(hub::Hub::new(loaded, dial_policy));
    eprintln!(
        "ringo-mcp: {n} agent(s) configured (started on first use); {} tool(s) enabled",
        ringo_mcp::total_tool_count() - disabled_tools.len()
    );
    let served = rt.block_on(async { serve(Arc::clone(&hub), sip_traces, disabled_tools).await });

    // The client is gone: ask the workers to deregister and exit, then give
    // the runtime a bounded window to drain — the per-agent event-poll tasks
    // only end once the workers' streams close, so a plain `drop(rt)` could
    // wait forever and a hard kill would skip the de-REGISTER. The grace
    // exceeds the worker's shutdown budget (de-REGISTER wait + RE-thread
    // stop, see ringo-agent's SHUTDOWN_GRACE).
    hub.shutdown();
    rt.shutdown_timeout(std::time::Duration::from_secs(35));
    // After the workers are gone: removes the trace dir.
    drop(owned_trace_dir);
    served
}