Skip to main content

sim_mcp_server/
cli.rs

1use sim_kernel::{CapabilityName, Error, Result};
2use sim_lib_mcp::McpProfile;
3
4/// Parsed command-line options for the MCP server.
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct CliOptions {
7    /// Transport to serve on.
8    pub transport: Transport,
9    /// Visibility profile filtering the surface.
10    pub profile: McpProfile,
11    /// Capabilities granted to the session.
12    pub capabilities: Vec<CapabilityName>,
13    /// Whether to log diagnostics to stderr.
14    pub log_stderr: bool,
15}
16
17/// Transport the MCP server listens on.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum Transport {
20    /// Line-delimited MCP over standard input and output.
21    Stdio,
22    /// MCP over HTTP (currently rejected by [`run`](crate::run)).
23    Http {
24        /// `host:port` address to bind.
25        address: String,
26        /// HTTP route path serving MCP.
27        route: String,
28    },
29}
30
31impl CliOptions {
32    /// Parses options from the process arguments (skipping the program name).
33    pub fn parse() -> Result<Self> {
34        Self::parse_from(std::env::args().skip(1))
35    }
36
37    /// Parses options from an explicit argument iterator.
38    pub fn parse_from(args: impl IntoIterator<Item = String>) -> Result<Self> {
39        let mut transport = None;
40        let mut profile = McpProfile::all();
41        let mut capabilities = Vec::new();
42        let mut route = "/mcp".to_owned();
43        let mut log_stderr = false;
44
45        let mut iter = args.into_iter();
46        while let Some(arg) = iter.next() {
47            match arg.as_str() {
48                "--stdio" => set_transport(&mut transport, Transport::Stdio)?,
49                "--http" => {
50                    let address = next_arg(&mut iter, "--http expects host:port")?;
51                    set_transport(
52                        &mut transport,
53                        Transport::Http {
54                            address,
55                            route: route.clone(),
56                        },
57                    )?;
58                }
59                "--route" => {
60                    route = next_arg(&mut iter, "--route expects a path")?;
61                    if let Some(Transport::Http {
62                        route: http_route, ..
63                    }) = &mut transport
64                    {
65                        *http_route = route.clone();
66                    }
67                }
68                "--profile" => {
69                    let name = next_arg(&mut iter, "--profile expects a name")?;
70                    profile = parse_profile(&name)?;
71                }
72                "--allow-tool" => {
73                    profile = profile.with_allowed_name(next_arg(
74                        &mut iter,
75                        "--allow-tool expects a name or glob",
76                    )?);
77                }
78                "--deny-tool" => {
79                    profile = profile.with_denied_name(next_arg(
80                        &mut iter,
81                        "--deny-tool expects a name or glob",
82                    )?);
83                }
84                "--cap" => {
85                    capabilities.push(CapabilityName::new(next_arg(
86                        &mut iter,
87                        "--cap expects a capability name",
88                    )?));
89                }
90                "--no-default-tools" => {
91                    profile = profile.with_denied_name("*");
92                }
93                "--log-stderr" => log_stderr = true,
94                other => {
95                    return Err(Error::Eval(format!(
96                        "unknown sim-mcp-server option {other}"
97                    )));
98                }
99            }
100        }
101
102        Ok(Self {
103            transport: transport.unwrap_or(Transport::Stdio),
104            profile,
105            capabilities,
106            log_stderr,
107        })
108    }
109}
110
111fn set_transport(slot: &mut Option<Transport>, transport: Transport) -> Result<()> {
112    if slot.is_some() {
113        return Err(Error::Eval(
114            "sim-mcp-server accepts one transport option".to_owned(),
115        ));
116    }
117    *slot = Some(transport);
118    Ok(())
119}
120
121fn parse_profile(name: &str) -> Result<McpProfile> {
122    match name {
123        "default" => Ok(McpProfile::all()),
124        other => Err(Error::Eval(format!("unknown MCP profile {other}"))),
125    }
126}
127
128fn next_arg(iter: &mut impl Iterator<Item = String>, message: &'static str) -> Result<String> {
129    iter.next().ok_or_else(|| Error::Eval(message.to_owned()))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn parses_stdio_profile_caps_and_filters() {
138        let opts = CliOptions::parse_from([
139            "--stdio".to_owned(),
140            "--profile".to_owned(),
141            "default".to_owned(),
142            "--allow-tool".to_owned(),
143            "core.*".to_owned(),
144            "--deny-tool".to_owned(),
145            "*.danger*".to_owned(),
146            "--cap".to_owned(),
147            "mcp.tools.call".to_owned(),
148            "--log-stderr".to_owned(),
149        ])
150        .unwrap();
151
152        assert_eq!(opts.transport, Transport::Stdio);
153        assert_eq!(
154            opts.capabilities,
155            vec![CapabilityName::new("mcp.tools.call")]
156        );
157        assert!(opts.log_stderr);
158        assert!(opts.profile.allows_name("core.echo"));
159        assert!(!opts.profile.allows_name("core.dangerous"));
160    }
161
162    #[test]
163    fn duplicate_transport_is_rejected() {
164        let err =
165            CliOptions::parse_from(["--stdio".to_owned(), "--http".to_owned(), "x:1".to_owned()])
166                .unwrap_err();
167
168        assert!(format!("{err}").contains("one transport"));
169    }
170}