Skip to main content

tailscale_mcp/
registry.rs

1//! The tool table, and the macro that fills it.
2//!
3//! Every tool is declared once. That declaration produces its parameter type's
4//! schema, the shim that deserialises into it, and the metadata row — so there
5//! is no way to add a tool and forget to classify it, and no second list to
6//! fall out of step with the first.
7//!
8//! The router is this table. `list_tools` filters it through the
9//! [`crate::gating::Gate`]; `call_tool` looks a name up in it. There is
10//! no parallel registration anywhere.
11
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use rmcp::model::{JsonObject, Tool, ToolAnnotations};
16use serde_json::Value;
17use tailscale_cli::BoxFuture;
18
19use crate::context::ToolContext;
20use crate::error::{ToolError, ToolResult};
21use crate::gating::Gate;
22use crate::meta::ToolMeta;
23
24/// The name of the parameter that carries the caller's intent.
25///
26/// Injected into the schema of every tool that requires it, rather than being
27/// repeated in twenty parameter structs, so that the rule and its enforcement
28/// are the same line of code.
29pub const CONFIRM_PARAM: &str = "confirm";
30
31/// The longest name the protocol permits.
32pub const MAX_NAME_LEN: usize = 128;
33
34/// How a tool is actually run.
35pub type InvokeFn = fn(Arc<ToolContext>, JsonObject) -> BoxFuture<'static, ToolResult<Value>>;
36
37/// One tool: its row in the table, its schema, and its handler.
38#[derive(Clone)]
39pub struct ToolEntry {
40    pub meta: ToolMeta,
41    /// Built on demand and cached by the SDK, keyed on the parameter type.
42    pub schema: fn() -> Result<Arc<JsonObject>, String>,
43    pub invoke: InvokeFn,
44}
45
46impl std::fmt::Debug for ToolEntry {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("ToolEntry")
49            .field("meta", &self.meta)
50            .finish_non_exhaustive()
51    }
52}
53
54impl ToolEntry {
55    /// The tool as a client sees it.
56    ///
57    /// Two things are added here rather than at the declaration: the
58    /// annotations, which are derived from the tier so they cannot contradict
59    /// it, and the confirmation parameter, which is added to exactly the tools
60    /// whose row asks for it.
61    pub fn describe(&self) -> Result<Tool, String> {
62        let mut schema = (self.schema)()?.as_ref().clone();
63        if self.meta.requires_confirmation {
64            add_confirm_property(&mut schema);
65        }
66
67        let a = self.meta.annotations();
68        Ok(
69            Tool::new(self.meta.name, self.meta.summary, Arc::new(schema)).with_annotations(
70                ToolAnnotations::new()
71                    .read_only(a.read_only)
72                    .destructive(a.destructive)
73                    .idempotent(a.idempotent)
74                    .open_world(a.open_world),
75            ),
76        )
77    }
78}
79
80/// Add the confirmation flag to a generated schema.
81fn add_confirm_property(schema: &mut JsonObject) {
82    let properties = schema
83        .entry("properties")
84        .or_insert_with(|| Value::Object(JsonObject::new()));
85    if let Some(properties) = properties.as_object_mut() {
86        properties.insert(
87            CONFIRM_PARAM.to_owned(),
88            serde_json::json!({
89                "type": "boolean",
90                "default": false,
91                "description":
92                    "Set to true to confirm this operation. It is irreversible, \
93                     or it can disconnect the node this server runs on, so it \
94                     will not run without an explicit intent.",
95            }),
96        );
97    }
98}
99
100/// The tool table.
101///
102/// Built once from the declarations, then shared. Lookup is by name because
103/// that is what a call carries; listing walks the ordered vector so that the
104/// output is stable between runs.
105#[derive(Debug, Clone)]
106pub struct Registry {
107    entries: Vec<ToolEntry>,
108    by_name: BTreeMap<&'static str, usize>,
109}
110
111impl Registry {
112    /// Build a registry, rejecting a table that could not be served correctly.
113    ///
114    /// The checks are cheap and run at startup rather than in a test alone,
115    /// because a duplicate name silently shadowing a tool is the kind of bug
116    /// that survives a release.
117    pub fn new(entries: Vec<ToolEntry>) -> Result<Self, RegistryError> {
118        let mut by_name = BTreeMap::new();
119        for (index, entry) in entries.iter().enumerate() {
120            let name = entry.meta.name;
121            validate_name(name)?;
122            if by_name.insert(name, index).is_some() {
123                return Err(RegistryError::DuplicateName(name));
124            }
125            if entry.meta.self_severing && !entry.meta.requires_confirmation {
126                return Err(RegistryError::SelfSeveringWithoutConfirmation(name));
127            }
128            match (entry.schema)() {
129                Err(reason) => return Err(RegistryError::BadSchema { name, reason }),
130                // A tool that severs when it is aimed at us has to give the
131                // caller somewhere to say so. The flag alone would be a claim
132                // the schema does not keep (Q83).
133                Ok(schema) if entry.meta.severs_local_node => {
134                    let asks = schema
135                        .get("properties")
136                        .and_then(Value::as_object)
137                        .is_some_and(|properties| properties.contains_key(CONFIRM_PARAM));
138                    if !asks {
139                        return Err(RegistryError::SeversLocalWithoutConfirmParameter(name));
140                    }
141                }
142                Ok(_) => {}
143            }
144        }
145        Ok(Self { entries, by_name })
146    }
147
148    /// The metadata rows, for anything that needs the table without the
149    /// handlers: the gate's emptiness check, the `tools` subcommand, the
150    /// generated documentation.
151    pub fn metas(&self) -> Vec<ToolMeta> {
152        self.entries.iter().map(|e| e.meta).collect()
153    }
154
155    pub fn get(&self, name: &str) -> Option<&ToolEntry> {
156        self.by_name.get(name).map(|index| &self.entries[*index])
157    }
158
159    pub fn len(&self) -> usize {
160        self.entries.len()
161    }
162
163    pub fn is_empty(&self) -> bool {
164        self.entries.is_empty()
165    }
166
167    /// The tools this server offers, in name order.
168    ///
169    /// A tool the gate does not permit is absent, not present-and-failing: a
170    /// model that can see a tool will reach for it.
171    pub fn visible(&self, gate: &Gate) -> Vec<&ToolEntry> {
172        let mut visible: Vec<&ToolEntry> = self
173            .entries
174            .iter()
175            .filter(|entry| gate.permits(&entry.meta))
176            .collect();
177        visible.sort_by_key(|entry| entry.meta.name);
178        visible
179    }
180
181    /// Look a call up, applying the gate and the confirmation rule.
182    ///
183    /// Returns the entry and the arguments with the confirmation flag removed,
184    /// so a handler never has to know the flag exists.
185    pub fn resolve(
186        &self,
187        name: &str,
188        mut args: JsonObject,
189        gate: &Gate,
190    ) -> ToolResult<(&ToolEntry, JsonObject)> {
191        let entry = self
192            .get(name)
193            .ok_or_else(|| ToolError::not_found(&format!("the tool `{name}`")))?;
194
195        if !gate.permits(&entry.meta) {
196            return Err(ToolError::not_permitted(name, &gate.needs(&entry.meta)));
197        }
198
199        if entry.meta.requires_confirmation {
200            let confirmed = match args.remove(CONFIRM_PARAM) {
201                Some(Value::Bool(confirmed)) => confirmed,
202                None | Some(Value::Null) => false,
203                Some(other) => {
204                    return Err(ToolError::invalid_args(format!(
205                        "`{CONFIRM_PARAM}` must be true or false, not {other}"
206                    )));
207                }
208            };
209            if !confirmed {
210                return Err(ToolError::confirmation_required(
211                    name,
212                    confirmation_consequence(&entry.meta),
213                ));
214            }
215        }
216
217        Ok((entry, args))
218    }
219}
220
221/// The clause that follows the tool name in a confirmation message.
222fn confirmation_consequence(meta: &crate::meta::ToolMeta) -> &'static str {
223    if meta.self_severing {
224        "can disconnect the node this server runs on, so it needs `confirm: true`"
225    } else {
226        "cannot be undone, so it needs `confirm: true`"
227    }
228}
229
230/// A table that could not be served.
231#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
232pub enum RegistryError {
233    #[error("two tools are both named `{0}`")]
234    DuplicateName(&'static str),
235
236    #[error("the tool name `{name}` {reason}")]
237    BadName {
238        name: &'static str,
239        reason: &'static str,
240    },
241
242    #[error("`{0}` is self-severing but does not require confirmation")]
243    SelfSeveringWithoutConfirmation(&'static str),
244
245    #[error("`{0}` severs the local node but its parameters carry no `confirm`")]
246    SeversLocalWithoutConfirmParameter(&'static str),
247
248    #[error("the parameters of `{name}` do not make a valid input schema: {reason}")]
249    BadSchema { name: &'static str, reason: String },
250}
251
252/// The protocol's rules for a tool name, plus ours.
253fn validate_name(name: &'static str) -> Result<(), RegistryError> {
254    let bad = |reason| Err(RegistryError::BadName { name, reason });
255    if name.is_empty() {
256        return bad("is empty");
257    }
258    if name.len() > MAX_NAME_LEN {
259        return bad("is longer than the 128 characters the protocol allows");
260    }
261    if !name
262        .bytes()
263        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
264    {
265        return bad("contains a character outside [A-Za-z0-9_.-]");
266    }
267    if !name.starts_with("tailscale_") && !name.starts_with("tailnet_") {
268        return bad("does not begin with `tailscale_` or `tailnet_`");
269    }
270    Ok(())
271}
272
273/// Declare tools.
274///
275/// One block per toolset module; one entry per tool. The entry names its
276/// parameter type and its handler, and states its toolset and tier — omit
277/// either and this does not compile, which is the point.
278///
279/// ```ignore
280/// tools! {
281///     /// Report the state of the local node and the peers it knows about.
282///     tailscale_status => StatusParams, handlers::status,
283///         toolset: LocalStatus, tier: Read, idempotent: true;
284/// }
285/// ```
286///
287/// Optional trailing settings, each defaulting to the safe answer:
288/// `idempotent` (false), `confirm` (false), `severing` (false, and implies
289/// `confirm`), `severs_local` (false, and requires a `confirm` parameter),
290/// `varying` (false), `since` (no minimum version).
291#[macro_export]
292macro_rules! tools {
293    (
294        $(
295            $(#[doc = $summary:literal])+
296            $name:ident => $params:ty, $handler:path,
297                toolset: $toolset:ident,
298                tier: $tier:ident
299                $(, idempotent: $idempotent:literal)?
300                $(, confirm: $confirm:literal)?
301                $(, severing: $severing:literal)?
302                $(, severs_local: $severs_local:literal)?
303                $(, varying: $varying:literal)?
304                $(, since: $since:literal)?
305                $(, platforms: [$($platform:literal),+ $(,)?])?
306                $(,)?
307            ;
308        )*
309    ) => {
310        /// The metadata of each tool declared in this module, one constant per
311        /// tool, so that a handler needing its own row — for the version it
312        /// requires, or for its name in an error — names it rather than
313        /// searching for it.
314        pub mod metas {
315            #![allow(non_upper_case_globals)]
316
317            $(
318                #[doc = ::std::concat!($($summary),*)]
319                pub const $name: $crate::meta::ToolMeta = {
320                    #[allow(unused_mut, unused_assignments)]
321                    let mut severing = false;
322                    $( severing = $severing; )?
323                    #[allow(unused_mut, unused_assignments)]
324                    let mut confirm = severing;
325                    $( confirm = $confirm || severing; )?
326                    #[allow(unused_mut, unused_assignments)]
327                    let mut severs_local_node = false;
328                    $( severs_local_node = $severs_local; )?
329                    #[allow(unused_mut, unused_assignments)]
330                    let mut idempotent = false;
331                    $( idempotent = $idempotent; )?
332                    #[allow(unused_mut, unused_assignments)]
333                    let mut varying_tier = false;
334                    $( varying_tier = $varying; )?
335                    #[allow(unused_mut, unused_assignments)]
336                    let mut since: ::std::option::Option<&'static str> =
337                        ::std::option::Option::None;
338                    $( since = ::std::option::Option::Some($since); )?
339                    #[allow(unused_mut, unused_assignments)]
340                    let mut platforms: ::std::option::Option<&'static [&'static str]> =
341                        ::std::option::Option::None;
342                    $( platforms = ::std::option::Option::Some(&[$($platform),+]); )?
343
344                    $crate::meta::ToolMeta {
345                        name: ::std::stringify!($name),
346                        toolset: $crate::meta::Toolset::$toolset,
347                        tier: $crate::meta::Tier::$tier,
348                        summary: ::std::concat!($($summary),*).trim_ascii(),
349                        self_severing: severing,
350                        severs_local_node,
351                        requires_confirmation: confirm,
352                        idempotent,
353                        varying_tier,
354                        min_version: since,
355                        platforms,
356                    }
357                };
358            )*
359        }
360
361        /// Every tool declared in this module, in declaration order.
362        pub fn entries() -> ::std::vec::Vec<$crate::registry::ToolEntry> {
363            ::std::vec![
364                $(
365                    {
366                        // Each tool gets its own shim so that the table can
367                        // hold plain function pointers rather than boxed
368                        // closures over twenty different parameter types.
369                        fn invoke(
370                            ctx: ::std::sync::Arc<$crate::context::ToolContext>,
371                            args: ::rmcp::model::JsonObject,
372                        ) -> ::tailscale_cli::BoxFuture<
373                            'static,
374                            $crate::error::ToolResult<::serde_json::Value>,
375                        > {
376                            ::std::boxed::Box::pin(async move {
377                                let params: $params = $crate::registry::parse_params(
378                                    ::std::stringify!($name),
379                                    args,
380                                )?;
381                                $handler(&ctx, params).await
382                            })
383                        }
384
385                        $crate::registry::ToolEntry {
386                            meta: self::metas::$name,
387                            schema: || {
388                                ::rmcp::handler::server::tool::schema_for_input::<$params>()
389                            },
390                            invoke,
391                        }
392                    },
393                )*
394            ]
395        }
396    };
397}
398
399/// Turn a call's arguments into a handler's parameter type.
400///
401/// A shape mismatch is `invalid_args` rather than a protocol error: the caller
402/// is a model, and a structured answer naming the problem is something it can
403/// act on, whereas a JSON-RPC error is opaque to it.
404///
405/// An argument the tool does not take is refused rather than dropped, at any
406/// depth the schema names: dropped, a misspelling becomes a call that reports
407/// success for something it did not do (Q172). An object the schema gives no
408/// property list, such as a document in Tailscale's own shape, stays open.
409pub fn parse_params<T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static>(
410    tool: &str,
411    args: JsonObject,
412) -> ToolResult<T> {
413    let args = Value::Object(args);
414    if let Ok(schema) = rmcp::handler::server::tool::schema_for_input::<T>() {
415        let root = Value::Object(schema.as_ref().clone());
416        if let Some((path, accepted)) = unknown_argument_in(&root, &args) {
417            let takes = if accepted.is_empty() {
418                "no arguments".to_owned()
419            } else {
420                accepted.join(", ")
421            };
422            return Err(ToolError::invalid_args(format!(
423                "`{tool}` takes no argument `{path}`; it takes {takes}"
424            )));
425        }
426    }
427    serde_json::from_value(args).map_err(|e| {
428        ToolError::invalid_args(format!("`{tool}` was called with unusable arguments: {e}"))
429    })
430}
431
432/// The first argument in `args` that a tool's input `schema` has no property
433/// for, by path, with the names accepted at that level.
434pub(crate) fn unknown_argument_in(schema: &Value, args: &Value) -> Option<(String, Vec<String>)> {
435    unknown_argument(schema, schema, args, "")
436}
437
438/// The first argument in `value` that `schema` has no property for, with the
439/// names it does have there.
440fn unknown_argument(
441    root: &Value,
442    schema: &Value,
443    value: &Value,
444    path: &str,
445) -> Option<(String, Vec<String>)> {
446    let branches = branches(root, schema);
447    match value {
448        Value::Object(fields) => {
449            let open = branches.iter().any(|b| {
450                b.get("additionalProperties")
451                    .is_some_and(|extra| extra != &Value::Bool(false))
452            });
453            let properties: Vec<&JsonObject> = branches
454                .iter()
455                .filter_map(|b| b.get("properties")?.as_object())
456                .collect();
457            // A struct with no fields declares an object and lists nothing; a
458            // free-form value declares neither.
459            let declared = !properties.is_empty() || branches.iter().any(|b| is_object_type(b));
460            if open || !declared {
461                return None;
462            }
463            fields.iter().find_map(|(key, field)| {
464                let here = if path.is_empty() {
465                    key.clone()
466                } else {
467                    format!("{path}.{key}")
468                };
469                match properties.iter().find_map(|p| p.get(key)) {
470                    Some(property) => unknown_argument(root, property, field, &here),
471                    None => {
472                        let mut accepted: Vec<String> = properties
473                            .iter()
474                            .flat_map(|p| p.keys().map(|k| format!("`{k}`")))
475                            .collect();
476                        accepted.sort();
477                        accepted.dedup();
478                        Some((here, accepted))
479                    }
480                }
481            })
482        }
483        Value::Array(items) => {
484            let item = branches.iter().find_map(|b| b.get("items"))?;
485            items.iter().enumerate().find_map(|(i, element)| {
486                unknown_argument(root, item, element, &format!("{path}[{i}]"))
487            })
488        }
489        _ => None,
490    }
491}
492
493/// Whether a schema says its value is an object.
494fn is_object_type(schema: &JsonObject) -> bool {
495    match schema.get("type") {
496        Some(Value::String(kind)) => kind == "object",
497        Some(Value::Array(kinds)) => kinds.iter().any(|kind| kind == "object"),
498        _ => false,
499    }
500}
501
502/// A schema and every alternative it is made of, with references followed.
503fn branches<'a>(root: &'a Value, schema: &'a Value) -> Vec<&'a JsonObject> {
504    let Some(object) = schema.as_object() else {
505        return Vec::new();
506    };
507    if let Some(target) = object.get("$ref").and_then(Value::as_str) {
508        let resolved = target
509            .strip_prefix("#/")
510            .and_then(|pointer| root.pointer(&format!("/{pointer}")));
511        return resolved.map_or_else(Vec::new, |r| branches(root, r));
512    }
513    let mut all = vec![object];
514    for key in ["allOf", "anyOf", "oneOf"] {
515        if let Some(alternatives) = object.get(key).and_then(Value::as_array) {
516            all.extend(alternatives.iter().flat_map(|a| branches(root, a)));
517        }
518    }
519    all
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::meta::{Tier, Toolset};
526    use schemars::JsonSchema;
527    use serde::Deserialize;
528    use std::collections::BTreeSet;
529
530    #[derive(Debug, Deserialize, JsonSchema)]
531    struct Empty {}
532
533    /// A parameter struct exercising both naming conventions in one tool, as
534    /// ADR-0004 requires: what we own is snake_case, what Tailscale owns keeps
535    /// the shape Tailscale gave it.
536    // The fields exist to be reflected into a schema, not to be read.
537    #[allow(dead_code)]
538    #[derive(Debug, Deserialize, JsonSchema)]
539    struct MixedParams {
540        /// Ours: how the result should be shaped.
541        include_peers: bool,
542        /// Theirs: forwarded to the control plane unchanged.
543        #[serde(rename = "keyExpiryDisabled")]
544        key_expiry_disabled: bool,
545    }
546
547    async fn ok_handler<P>(_ctx: &ToolContext, _params: P) -> ToolResult<Value> {
548        Ok(Value::Null)
549    }
550
551    mod declared {
552        use super::*;
553
554        crate::tools! {
555            /// Report the state of the local node.
556            tailscale_status => Empty, super::ok_handler,
557                toolset: LocalStatus, tier: Read, idempotent: true;
558
559            /// Disconnect this node from the tailnet.
560            tailscale_down => Empty, super::ok_handler,
561                toolset: LocalPrefs, tier: Destructive, severing: true;
562
563            /// Delete a device from the tailnet.
564            tailnet_device_delete => MixedParams, super::ok_handler,
565                toolset: TailnetDevices, tier: Destructive, confirm: true, since: "1.60";
566        }
567    }
568
569    fn registry() -> Registry {
570        Registry::new(declared::entries()).expect("a well-formed table")
571    }
572
573    fn open_gate() -> Gate {
574        Gate::unchecked(
575            Toolset::ALL.iter().copied().collect(),
576            Tier::Destructive,
577            BTreeSet::new(),
578        )
579    }
580
581    #[test]
582    fn a_declaration_produces_its_metadata_row() {
583        let registry = registry();
584        assert_eq!(registry.len(), 3);
585
586        let status = registry.get("tailscale_status").expect("declared");
587        assert_eq!(status.meta.toolset, Toolset::LocalStatus);
588        assert_eq!(status.meta.tier, Tier::Read);
589        assert_eq!(status.meta.summary, "Report the state of the local node.");
590        assert!(status.meta.idempotent);
591        assert!(!status.meta.requires_confirmation);
592        assert_eq!(status.meta.min_version, None);
593    }
594
595    #[test]
596    fn a_tool_that_severs_the_local_node_must_offer_somewhere_to_say_so() {
597        mod without {
598            use crate::registry::tests::Empty;
599
600            crate::tools! {
601                /// Delete a device, claiming to sever without asking.
602                tailnet_device_delete => Empty, super::ok_handler,
603                    toolset: TailnetDevices, tier: Destructive, severs_local: true;
604            }
605        }
606        // The flag alone would be a claim the schema does not keep: `Empty`
607        // has no `confirm`, so nothing a caller sends could confirm anything.
608        assert_eq!(
609            Registry::new(without::entries()).expect_err("no `confirm` to give"),
610            RegistryError::SeversLocalWithoutConfirmParameter("tailnet_device_delete")
611        );
612    }
613
614    #[test]
615    fn optional_settings_land_where_they_are_given() {
616        let delete = registry()
617            .get("tailnet_device_delete")
618            .expect("declared")
619            .meta;
620        assert!(delete.requires_confirmation);
621        assert!(!delete.self_severing);
622        assert_eq!(delete.min_version, Some("1.60"));
623    }
624
625    #[test]
626    fn a_duplicate_name_is_refused_at_construction() {
627        let mut entries = declared::entries();
628        entries.push(entries[0].clone());
629        assert_eq!(
630            Registry::new(entries).err(),
631            Some(RegistryError::DuplicateName("tailscale_status"))
632        );
633    }
634
635    #[test]
636    fn a_name_the_protocol_or_our_prefixes_would_not_allow_is_refused() {
637        let too_long: &'static str =
638            Box::leak(format!("tailscale_{}", "a".repeat(MAX_NAME_LEN)).into_boxed_str());
639        for (name, reason) in [
640            ("", "empty"),
641            (too_long, "128 characters"),
642            ("tailscale_ping peer", "outside [A-Za-z0-9_.-]"),
643            ("ping", "does not begin"),
644            ("tailscaleping", "does not begin"),
645        ] {
646            match validate_name(name) {
647                Err(RegistryError::BadName {
648                    name: refused,
649                    reason: said,
650                }) => {
651                    assert_eq!(refused, name);
652                    assert!(said.contains(reason), "`{name}` {said}");
653                }
654                other => panic!("`{name}` should be refused: {other:?}"),
655            }
656        }
657        assert_eq!(validate_name("tailnet_split-dns.v2"), Ok(()));
658    }
659
660    #[test]
661    fn a_confirming_tool_refuses_until_the_caller_says_so() {
662        let registry = registry();
663        let gate = open_gate();
664
665        let err = registry
666            .resolve("tailscale_down", JsonObject::new(), &gate)
667            .expect_err("must not run unconfirmed");
668        assert_eq!(err.code, crate::error::ErrorCode::ConfirmationRequired);
669        assert!(err.message.contains("disconnect"), "{}", err.message);
670
671        let mut args = JsonObject::new();
672        args.insert(CONFIRM_PARAM.to_owned(), Value::Bool(true));
673        let (entry, rest) = registry
674            .resolve("tailscale_down", args, &gate)
675            .expect("a confirmed call runs");
676        assert_eq!(entry.meta.name, "tailscale_down");
677        assert!(
678            !rest.contains_key(CONFIRM_PARAM),
679            "the handler should not see the flag"
680        );
681    }
682
683    #[test]
684    fn a_confirmation_flag_of_the_wrong_shape_is_a_bad_argument() {
685        let mut args = JsonObject::new();
686        args.insert(CONFIRM_PARAM.to_owned(), Value::String("yes".to_owned()));
687        let err = registry()
688            .resolve("tailscale_down", args, &open_gate())
689            .expect_err("`yes` is not a boolean");
690        assert_eq!(err.code, crate::error::ErrorCode::InvalidArgs);
691    }
692}