Skip to main content

runx_cli/
mcp.rs

1use std::ffi::OsString;
2use std::io::Write;
3use std::path::PathBuf;
4use std::process::ExitCode;
5use std::{collections::BTreeMap, env};
6
7use crate::cli_args::{flag_value, optional_flag_value_or, os_arg, split_flag};
8
9#[derive(Debug, Eq, PartialEq)]
10pub struct McpPlan {
11    pub refs: Vec<PathBuf>,
12    pub receipt_dir: Option<PathBuf>,
13    pub runner: Option<String>,
14    /// When set, serve the governed MCP server over streamable HTTP at this
15    /// address instead of over stdio.
16    pub http_listen: Option<String>,
17    pub http_allow_non_loopback: bool,
18}
19
20// rust-style-allow: long-function -- flag parsing is kept in one linear pass so
21// CLI usage errors preserve exact native argument semantics.
22pub fn parse_mcp_plan(args: &[OsString]) -> Result<McpPlan, String> {
23    let subcommand = os_arg(args, 1, "mcp")?;
24    if subcommand != "serve" {
25        return Err(format!("unknown mcp subcommand {subcommand}"));
26    }
27    let mut refs = Vec::new();
28    let mut receipt_dir = None;
29    let mut runner = None;
30    let mut http_listen = None;
31    let mut http_allow_non_loopback = false;
32    let mut index = 2;
33    while index < args.len() {
34        let token = os_arg(args, index, "mcp")?;
35        if !token.starts_with("--") {
36            refs.push(PathBuf::from(token));
37            index += 1;
38            continue;
39        }
40        let (flag, inline_value) = split_flag(token);
41        match flag {
42            "--receipt-dir" => {
43                let (value, next_index) = flag_value(args, index, flag, inline_value, "mcp")?;
44                receipt_dir = Some(PathBuf::from(value));
45                index = next_index;
46            }
47            "--runner" => {
48                let (value, next_index) = flag_value(args, index, flag, inline_value, "mcp")?;
49                runner = Some(value);
50                index = next_index;
51            }
52            "--http-listen" => {
53                let (value, next_index) = optional_flag_value_or(
54                    args,
55                    index,
56                    inline_value,
57                    runx_runtime::adapters::mcp::DEFAULT_MCP_HTTP_LISTEN_ADDR,
58                    "mcp",
59                )?;
60                http_listen = Some(value);
61                index = next_index;
62            }
63            "--http-allow-non-loopback" => {
64                if inline_value.is_some() {
65                    return Err("--http-allow-non-loopback does not take a value".to_owned());
66                }
67                http_allow_non_loopback = true;
68                index += 1;
69            }
70            _ => return Err(format!("unknown mcp serve flag {flag}")),
71        }
72    }
73    if refs.is_empty() {
74        return Err("runx mcp serve requires at least one skill reference.".to_owned());
75    }
76    Ok(McpPlan {
77        refs,
78        receipt_dir,
79        runner,
80        http_listen,
81        http_allow_non_loopback,
82    })
83}
84
85// rust-style-allow: long-function -- native MCP startup owns one cohesive
86// stdio-vs-HTTP transport selection and error presentation boundary.
87pub fn run_native_mcp(plan: McpPlan) -> ExitCode {
88    let options =
89        match runx_runtime::adapters::mcp::McpServerOptions::from_skill_paths_with_execution(
90            &plan.refs,
91            "runx-cli",
92            env!("CARGO_PKG_VERSION"),
93            runx_runtime::adapters::mcp::McpServerExecutionOptions {
94                runner: plan.runner,
95                receipt_dir: plan.receipt_dir,
96                env: mcp_execution_env(),
97            },
98        ) {
99            Ok(options) => options,
100            Err(error) => {
101                let _ignored = writeln!(std::io::stderr(), "runx: {error}");
102                return ExitCode::from(1);
103            }
104        };
105    let result = match &plan.http_listen {
106        Some(listen_addr) => {
107            let bearer_token = match runx_runtime::adapters::mcp::generate_mcp_http_bearer_token() {
108                Ok(token) => token,
109                Err(error) => {
110                    let _ignored = writeln!(std::io::stderr(), "runx: {error}");
111                    return ExitCode::from(1);
112                }
113            };
114            let _ignored = writeln!(
115                std::io::stderr(),
116                "runx MCP HTTP bearer token: {bearer_token}"
117            );
118            let _ignored = writeln!(
119                std::io::stderr(),
120                "runx MCP HTTP requires: Authorization: Bearer {bearer_token}"
121            );
122            if plan.http_allow_non_loopback {
123                let _ignored = writeln!(
124                    std::io::stderr(),
125                    "runx MCP HTTP non-loopback listen explicitly enabled."
126                );
127            }
128            runx_runtime::adapters::mcp::serve_mcp_http_server_blocking(
129                listen_addr,
130                options,
131                runx_runtime::adapters::mcp::McpHttpServerSecurity {
132                    bearer_token,
133                    allow_non_loopback: plan.http_allow_non_loopback,
134                },
135            )
136        }
137        None => runx_runtime::adapters::mcp::serve_mcp_json_rpc(
138            std::io::stdin(),
139            std::io::stdout(),
140            options,
141        ),
142    };
143    match result {
144        Ok(()) => ExitCode::SUCCESS,
145        Err(error) => {
146            let _ignored = writeln!(std::io::stderr(), "runx: {error}");
147            ExitCode::from(1)
148        }
149    }
150}
151
152fn mcp_execution_env() -> BTreeMap<String, String> {
153    let mut env = env::vars().collect::<BTreeMap<_, _>>();
154    if !env.contains_key(runx_runtime::RUNX_CWD_ENV)
155        && let Ok(cwd) = env::current_dir()
156    {
157        env.insert(
158            runx_runtime::RUNX_CWD_ENV.to_owned(),
159            cwd.to_string_lossy().into_owned(),
160        );
161    }
162    env
163}