Skip to main content

xianyun_mcp/
config.rs

1//! Builds the [`brontes::Config`] that governs which `xianyun` subcommands
2//! become MCP tools, how they're annotated, and which ones require explicit
3//! client confirmation before they run.
4//!
5//! # The allowlist, in full
6//!
7//! `brontes::Config::default()` (equivalently, passing `None` to
8//! [`brontes::command`]) exposes *every* reachable command as a tool. This
9//! module never does that: [`build`] always returns a `Config` whose
10//! [`Selector::cmd`] matchers are an explicit allowlist, built from
11//! [`selectors::allow_cmds`] over exact, fully-qualified command paths. A
12//! command that isn't named in one of the three groups below is invisible
13//! to MCP clients, full stop — see `tests::guardrail` in the crate's
14//! integration tests for the check that keeps a newly added `xianyun`
15//! subcommand from silently falling through the cracks.
16//!
17//! Three allowlist tiers, each with its own annotation shape:
18//!
19//! - [`READ_ONLY`]: pure queries. `readOnlyHint: true`.
20//! - [`STATE_CHANGING`]: expected, reversible side effects (a local cluster
21//!   comes up, a Helm chart gets upgraded, a CA key gets lazily generated).
22//!   `destructiveHint: false`, but *not* `readOnlyHint`.
23//! - [`GATED`]: commands that tear something down, rewrite a file outside
24//!   the project (`/etc/hosts`), or touch the host's trust store (often
25//!   under `sudo`). `destructiveHint: true`, and wrapped in the
26//!   confirmation-gating [`Middleware`] from
27//!   [`middleware`].
28//!
29//! Everything else in `xianyun`'s command tree — `run`, `tool`, `kubectl`,
30//! `k9s`, `stern`, `edit-mode-process-supervisor` — is deliberately excluded.
31//! See [`EXCLUDED`] for the reasons, one per command.
32
33use std::collections::BTreeMap;
34use std::sync::Arc;
35
36use brontes::{
37    Config, ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction, ElicitationSchema,
38    InputRequest, InputRequests, InputRequiredResult, Middleware, MiddlewareOutcome, Selector,
39    ToolAnnotations, ToolOutput, selectors,
40};
41
42/// Commands that only read state: exact `xianyun`-rooted paths, matching the
43/// `path` strings [`brontes`'s walker](brontes) produces (space-joined,
44/// starting at the CLI's own name).
45pub const READ_ONLY: &[&str] = &[
46    "xianyun version",
47    "xianyun status",
48    "xianyun logs",
49    "xianyun doctor",
50    "xianyun config validate",
51    "xianyun config print",
52    "xianyun registry status",
53];
54
55/// Commands with real side effects that are expected, local-only, and
56/// reversible by running the CLI again (bringing a cluster up, redeploying,
57/// pulling repository updates, merging a kubeconfig entry, migrating a
58/// config file to the latest schema, or lazily generating/reading the dev CA
59/// — `ca inspect` can write a CA keypair to disk on first run via
60/// `xianyun_pki::ca::load_or_generate`, which disqualifies it from
61/// [`READ_ONLY`] despite its name).
62pub const STATE_CHANGING: &[&str] = &[
63    "xianyun up",
64    "xianyun deploy",
65    "xianyun update",
66    "xianyun set-kube-context",
67    "xianyun config migrate",
68    "xianyun registry start",
69    "xianyun registry stop",
70    "xianyun ca inspect",
71];
72
73/// Commands that tear down a cluster and its data, rewrite `/etc/hosts`, or
74/// install/remove a certificate in the host's trust store (the latter two
75/// typically require `sudo`). Allowlisted, but every call through
76/// `middleware::confirmation_gate` first.
77pub const GATED: &[&str] = &[
78    "xianyun down",
79    "xianyun destroy",
80    "xianyun update-etc-hosts",
81    "xianyun ca trust",
82    "xianyun ca untrust",
83];
84
85/// Commands intentionally excluded from the MCP tool surface, with the
86/// reason each one is excluded. Checked against the real command tree by
87/// the crate's guardrail test, so an unclassified new subcommand fails that
88/// test instead of silently inheriting `brontes`'s open-by-default exposure.
89///
90/// Paths are given without the `xianyun ` root prefix, matching how
91/// [`brontes::Config`] accepts bare command names — these are informational
92/// only, deliberately *not* fed into any selector.
93pub const EXCLUDED: &[(&str, &str)] = &[
94    (
95        "run",
96        "long-running, interactive reloader supervisor; holds the process until interrupted, which has no sane mapping onto a single request/response tools/call.",
97    ),
98    (
99        "tool",
100        "runs an arbitrary executable named by its first positional argument with access to the cluster -- exactly the glab #8153 shape (an unreviewed command becoming an arbitrary-command-execution primitive). Never allowlist this.",
101    ),
102    (
103        "kubectl",
104        "forwards all trailing arguments verbatim to a real kubectl binary; the flags are not clap-typed (trailing_var_arg), so brontes can't derive a meaningful schema, and an LLM-driven kubectl invocation is its own unreviewed command-execution surface on top of that.",
105    ),
106    (
107        "k9s",
108        "interactive, full-screen terminal UI that needs a live TTY and runs indefinitely; not a request/response fit, and brontes's subprocess exec has no terminal to hand it.",
109    ),
110    (
111        "stern",
112        "streams logs indefinitely until interrupted; a tools/call that never returns blocks the MCP connection for no benefit over just running `xianyun logs` (which IS allowlisted, see READ_ONLY).",
113    ),
114    (
115        "edit-mode-process-supervisor",
116        "hidden internal plumbing `xianyun run` spawns one of per reloader; not a user-facing command (clap's `hide = true` means brontes's own `is_hide_set` filter already drops it from the walked tree, but it's listed here too so the guardrail test's reasoning is complete without needing to know that detail).",
117    ),
118];
119
120/// Key under which the gated middleware's elicitation answer is looked up in
121/// `ctx.input_responses`.
122const CONFIRM: &str = "confirm";
123
124/// Per-command-path confirmation message shown to the MCP client before a
125/// [`GATED`] command actually runs.
126fn consequence_for(path: &str) -> &'static str {
127    match path {
128        "xianyun down" => {
129            "This stops the local cluster. Deployed state is preserved and `xianyun up` will bring it back."
130        }
131        "xianyun destroy" => {
132            "This destroys the local cluster AND DELETES ALL ITS DATA. This cannot be undone."
133        }
134        "xianyun update-etc-hosts" => {
135            "This rewrites /etc/hosts to add or remove cluster hostnames, typically requiring elevated privileges."
136        }
137        "xianyun ca trust" => {
138            "This installs the Caproni dev CA certificate into the system trust store, typically under sudo."
139        }
140        "xianyun ca untrust" => {
141            "This removes the Caproni dev CA certificate from the system trust store, typically under sudo."
142        }
143        _ => "This performs a destructive or privileged action.",
144    }
145}
146
147/// Builds the confirmation-gating [`Middleware`] applied to every selector
148/// over [`GATED`] commands.
149///
150/// Mirrors the shipped MRTR example from `brontes`'s own docs: the first
151/// call (no `ctx.input_responses`) returns
152/// [`MiddlewareOutcome::InputRequired`] carrying an elicitation request; a
153/// retry that carries an accepted answer under `CONFIRM` proceeds to
154/// `next(ctx)`; a retry carrying a decline returns
155/// [`MiddlewareOutcome::Complete`] with a non-zero exit and no side effects.
156pub fn middleware() -> Middleware {
157    Arc::new(|ctx, next| {
158        Box::pin(async move {
159            let Some(answers) = ctx.input_responses.as_ref() else {
160                let mut requests = InputRequests::new();
161                requests.insert(
162                    CONFIRM.to_owned(),
163                    InputRequest::Elicitation(ElicitRequest::new(
164                        ElicitRequestParams::FormElicitationParams {
165                            meta: None,
166                            message: format!(
167                                "Really run `{}`? {}",
168                                ctx.tool_name,
169                                consequence_for(&path_for_tool_name(&ctx.tool_name))
170                            ),
171                            requested_schema: ElicitationSchema::new(BTreeMap::new()),
172                        },
173                    )),
174                );
175                return Ok(InputRequiredResult::new(Some(requests), None).into());
176            };
177
178            let accepted = answers
179                .get(CONFIRM)
180                .cloned()
181                .and_then(|v| serde_json::from_value::<ElicitResult>(v).ok())
182                .is_some_and(|r| r.action == ElicitationAction::Accept);
183
184            if accepted {
185                next(ctx).await
186            } else {
187                Ok(MiddlewareOutcome::Complete(ToolOutput {
188                    stdout: String::new(),
189                    stderr: "aborted at the user's request\n".to_owned(),
190                    exit_code: 1,
191                }))
192            }
193        })
194    })
195}
196
197/// Reconstructs a `"xianyun ..."` command path from a `brontes` tool name
198/// (`xianyun_ca_trust` -> `"xianyun ca trust"`), purely for picking a
199/// consequence message. Falls back to replacing every underscore with a
200/// space if the name doesn't start with the expected root token, which
201/// still produces a reasonable (if slightly wrong on hyphenated segments)
202/// message rather than panicking.
203fn path_for_tool_name(tool_name: &str) -> String {
204    tool_name.replace('_', " ")
205}
206
207/// Annotation applied to every [`READ_ONLY`] command.
208fn read_only_annotation() -> ToolAnnotations {
209    ToolAnnotations {
210        title: None,
211        read_only_hint: Some(true),
212        destructive_hint: Some(false),
213        idempotent_hint: Some(true),
214        open_world_hint: Some(false),
215    }
216}
217
218/// Annotation applied to every [`STATE_CHANGING`] command.
219fn state_changing_annotation() -> ToolAnnotations {
220    ToolAnnotations {
221        title: None,
222        read_only_hint: Some(false),
223        destructive_hint: Some(false),
224        idempotent_hint: Some(true),
225        open_world_hint: Some(false),
226    }
227}
228
229/// Annotation applied to every [`GATED`] command.
230fn gated_annotation() -> ToolAnnotations {
231    ToolAnnotations {
232        title: None,
233        read_only_hint: Some(false),
234        destructive_hint: Some(true),
235        idempotent_hint: Some(true),
236        open_world_hint: Some(false),
237    }
238}
239
240/// Builds the full [`brontes::Config`] for the `xianyun` command tree:
241/// the three-tier allowlist above, an annotation for every allowlisted
242/// command, and the confirmation-gating middleware wired to [`GATED`] alone.
243#[must_use]
244pub fn build() -> Config {
245    let mut cfg = Config::default()
246        .selector(Selector {
247            cmd: Some(selectors::allow_cmds(READ_ONLY.iter().copied())),
248            ..Default::default()
249        })
250        .selector(Selector {
251            cmd: Some(selectors::allow_cmds(STATE_CHANGING.iter().copied())),
252            ..Default::default()
253        })
254        .selector(Selector {
255            cmd: Some(selectors::allow_cmds(GATED.iter().copied())),
256            middleware: Some(middleware()),
257            ..Default::default()
258        });
259
260    for path in READ_ONLY {
261        cfg = cfg.annotation(*path, read_only_annotation());
262    }
263    for path in STATE_CHANGING {
264        cfg = cfg.annotation(*path, state_changing_annotation());
265    }
266    for path in GATED {
267        cfg = cfg.annotation(*path, gated_annotation());
268    }
269
270    cfg
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn allowlist_tiers_are_disjoint() {
279        let mut seen = std::collections::HashSet::new();
280        for path in READ_ONLY.iter().chain(STATE_CHANGING).chain(GATED) {
281            assert!(seen.insert(*path), "{path} listed in more than one tier");
282        }
283    }
284
285    #[test]
286    fn excluded_reasons_are_non_empty() {
287        for (name, reason) in EXCLUDED {
288            assert!(!reason.is_empty(), "{name} has no exclusion reason");
289        }
290    }
291
292    #[test]
293    fn build_does_not_panic() {
294        let _ = build();
295    }
296
297    #[test]
298    fn path_for_tool_name_round_trips_simple_cases() {
299        assert_eq!(path_for_tool_name("xianyun_status"), "xianyun status");
300        assert_eq!(path_for_tool_name("xianyun_ca_trust"), "xianyun ca trust");
301    }
302}