1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
//! `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
}