use sim_kernel::{CapabilityName, Error, Result};
use sim_lib_mcp::McpProfile;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliOptions {
pub transport: Transport,
pub profile: McpProfile,
pub capabilities: Vec<CapabilityName>,
pub log_stderr: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Transport {
Stdio,
Http {
address: String,
route: String,
},
}
impl CliOptions {
pub fn parse() -> Result<Self> {
Self::parse_from(std::env::args().skip(1))
}
pub fn parse_from(args: impl IntoIterator<Item = String>) -> Result<Self> {
let mut transport = None;
let mut profile = McpProfile::all();
let mut capabilities = Vec::new();
let mut route = "/mcp".to_owned();
let mut log_stderr = false;
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--stdio" => set_transport(&mut transport, Transport::Stdio)?,
"--http" => {
let address = next_arg(&mut iter, "--http expects host:port")?;
set_transport(
&mut transport,
Transport::Http {
address,
route: route.clone(),
},
)?;
}
"--route" => {
route = next_arg(&mut iter, "--route expects a path")?;
if let Some(Transport::Http {
route: http_route, ..
}) = &mut transport
{
*http_route = route.clone();
}
}
"--profile" => {
let name = next_arg(&mut iter, "--profile expects a name")?;
profile = parse_profile(&name)?;
}
"--allow-tool" => {
profile = profile.with_allowed_name(next_arg(
&mut iter,
"--allow-tool expects a name or glob",
)?);
}
"--deny-tool" => {
profile = profile.with_denied_name(next_arg(
&mut iter,
"--deny-tool expects a name or glob",
)?);
}
"--cap" => {
capabilities.push(CapabilityName::new(next_arg(
&mut iter,
"--cap expects a capability name",
)?));
}
"--no-default-tools" => {
profile = profile.with_denied_name("*");
}
"--log-stderr" => log_stderr = true,
other => {
return Err(Error::Eval(format!(
"unknown sim-mcp-server option {other}"
)));
}
}
}
Ok(Self {
transport: transport.unwrap_or(Transport::Stdio),
profile,
capabilities,
log_stderr,
})
}
}
fn set_transport(slot: &mut Option<Transport>, transport: Transport) -> Result<()> {
if slot.is_some() {
return Err(Error::Eval(
"sim-mcp-server accepts one transport option".to_owned(),
));
}
*slot = Some(transport);
Ok(())
}
fn parse_profile(name: &str) -> Result<McpProfile> {
match name {
"default" => Ok(McpProfile::all()),
other => Err(Error::Eval(format!("unknown MCP profile {other}"))),
}
}
fn next_arg(iter: &mut impl Iterator<Item = String>, message: &'static str) -> Result<String> {
iter.next().ok_or_else(|| Error::Eval(message.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_stdio_profile_caps_and_filters() {
let opts = CliOptions::parse_from([
"--stdio".to_owned(),
"--profile".to_owned(),
"default".to_owned(),
"--allow-tool".to_owned(),
"core.*".to_owned(),
"--deny-tool".to_owned(),
"*.danger*".to_owned(),
"--cap".to_owned(),
"mcp.tools.call".to_owned(),
"--log-stderr".to_owned(),
])
.unwrap();
assert_eq!(opts.transport, Transport::Stdio);
assert_eq!(
opts.capabilities,
vec![CapabilityName::new("mcp.tools.call")]
);
assert!(opts.log_stderr);
assert!(opts.profile.allows_name("core.echo"));
assert!(!opts.profile.allows_name("core.dangerous"));
}
#[test]
fn duplicate_transport_is_rejected() {
let err =
CliOptions::parse_from(["--stdio".to_owned(), "--http".to_owned(), "x:1".to_owned()])
.unwrap_err();
assert!(format!("{err}").contains("one transport"));
}
}