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 entries(&self) -> &[ToolEntry] {
156        &self.entries
157    }
158
159    pub fn get(&self, name: &str) -> Option<&ToolEntry> {
160        self.by_name.get(name).map(|index| &self.entries[*index])
161    }
162
163    pub fn len(&self) -> usize {
164        self.entries.len()
165    }
166
167    pub fn is_empty(&self) -> bool {
168        self.entries.is_empty()
169    }
170
171    /// The tools this server offers, in name order.
172    ///
173    /// A tool the gate does not permit is absent, not present-and-failing: a
174    /// model that can see a tool will reach for it.
175    pub fn visible(&self, gate: &Gate) -> Vec<&ToolEntry> {
176        let mut visible: Vec<&ToolEntry> = self
177            .entries
178            .iter()
179            .filter(|entry| gate.permits(&entry.meta))
180            .collect();
181        visible.sort_by_key(|entry| entry.meta.name);
182        visible
183    }
184
185    /// Look a call up, applying the gate and the confirmation rule.
186    ///
187    /// Returns the entry and the arguments with the confirmation flag removed,
188    /// so a handler never has to know the flag exists.
189    pub fn resolve(
190        &self,
191        name: &str,
192        mut args: JsonObject,
193        gate: &Gate,
194    ) -> ToolResult<(&ToolEntry, JsonObject)> {
195        let entry = self
196            .get(name)
197            .ok_or_else(|| ToolError::not_found(&format!("the tool `{name}`")))?;
198
199        if !gate.permits(&entry.meta) {
200            return Err(ToolError::not_permitted(name, &gate.needs(&entry.meta)));
201        }
202
203        if entry.meta.requires_confirmation {
204            let confirmed = match args.remove(CONFIRM_PARAM) {
205                Some(Value::Bool(confirmed)) => confirmed,
206                None | Some(Value::Null) => false,
207                Some(other) => {
208                    return Err(ToolError::invalid_args(format!(
209                        "`{CONFIRM_PARAM}` must be true or false, not {other}"
210                    )));
211                }
212            };
213            if !confirmed {
214                return Err(ToolError::confirmation_required(
215                    name,
216                    confirmation_consequence(&entry.meta),
217                ));
218            }
219        }
220
221        Ok((entry, args))
222    }
223}
224
225/// The clause that follows the tool name in a confirmation message.
226fn confirmation_consequence(meta: &crate::meta::ToolMeta) -> &'static str {
227    if meta.self_severing {
228        "can disconnect the node this server runs on, so it needs `confirm: true`"
229    } else {
230        "cannot be undone, so it needs `confirm: true`"
231    }
232}
233
234/// A table that could not be served.
235#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
236pub enum RegistryError {
237    #[error("two tools are both named `{0}`")]
238    DuplicateName(&'static str),
239
240    #[error("the tool name `{name}` {reason}")]
241    BadName {
242        name: &'static str,
243        reason: &'static str,
244    },
245
246    #[error("`{0}` is self-severing but does not require confirmation")]
247    SelfSeveringWithoutConfirmation(&'static str),
248
249    #[error("`{0}` severs the local node but its parameters carry no `confirm`")]
250    SeversLocalWithoutConfirmParameter(&'static str),
251
252    #[error("the parameters of `{name}` do not make a valid input schema: {reason}")]
253    BadSchema { name: &'static str, reason: String },
254}
255
256/// The protocol's rules for a tool name, plus ours.
257fn validate_name(name: &'static str) -> Result<(), RegistryError> {
258    let bad = |reason| Err(RegistryError::BadName { name, reason });
259    if name.is_empty() {
260        return bad("is empty");
261    }
262    if name.len() > MAX_NAME_LEN {
263        return bad("is longer than the 128 characters the protocol allows");
264    }
265    if !name
266        .bytes()
267        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
268    {
269        return bad("contains a character outside [A-Za-z0-9_.-]");
270    }
271    if !name.starts_with("tailscale_") && !name.starts_with("tailnet_") {
272        return bad("does not begin with `tailscale_` or `tailnet_`");
273    }
274    Ok(())
275}
276
277/// Declare tools.
278///
279/// One block per toolset module; one entry per tool. The entry names its
280/// parameter type and its handler, and states its toolset and tier — omit
281/// either and this does not compile, which is the point.
282///
283/// ```ignore
284/// tools! {
285///     /// Report the state of the local node and the peers it knows about.
286///     tailscale_status => StatusParams, handlers::status,
287///         toolset: LocalStatus, tier: Read, idempotent: true;
288/// }
289/// ```
290///
291/// Optional trailing settings, each defaulting to the safe answer:
292/// `idempotent` (false), `confirm` (false), `severing` (false, and implies
293/// `confirm`), `severs_local` (false, and requires a `confirm` parameter),
294/// `varying` (false), `since` (no minimum version).
295#[macro_export]
296macro_rules! tools {
297    (
298        $(
299            $(#[doc = $summary:literal])+
300            $name:ident => $params:ty, $handler:path,
301                toolset: $toolset:ident,
302                tier: $tier:ident
303                $(, idempotent: $idempotent:literal)?
304                $(, confirm: $confirm:literal)?
305                $(, severing: $severing:literal)?
306                $(, severs_local: $severs_local:literal)?
307                $(, varying: $varying:literal)?
308                $(, since: $since:literal)?
309                $(, platforms: [$($platform:literal),+ $(,)?])?
310                $(,)?
311            ;
312        )*
313    ) => {
314        /// The metadata of each tool declared in this module, one constant per
315        /// tool, so that a handler needing its own row — for the version it
316        /// requires, or for its name in an error — names it rather than
317        /// searching for it.
318        pub mod metas {
319            #![allow(non_upper_case_globals)]
320
321            $(
322                #[doc = ::std::concat!($($summary),*)]
323                pub const $name: $crate::meta::ToolMeta = {
324                    #[allow(unused_mut, unused_assignments)]
325                    let mut severing = false;
326                    $( severing = $severing; )?
327                    #[allow(unused_mut, unused_assignments)]
328                    let mut confirm = severing;
329                    $( confirm = $confirm || severing; )?
330                    #[allow(unused_mut, unused_assignments)]
331                    let mut severs_local_node = false;
332                    $( severs_local_node = $severs_local; )?
333                    #[allow(unused_mut, unused_assignments)]
334                    let mut idempotent = false;
335                    $( idempotent = $idempotent; )?
336                    #[allow(unused_mut, unused_assignments)]
337                    let mut varying_tier = false;
338                    $( varying_tier = $varying; )?
339                    #[allow(unused_mut, unused_assignments)]
340                    let mut since: ::std::option::Option<&'static str> =
341                        ::std::option::Option::None;
342                    $( since = ::std::option::Option::Some($since); )?
343                    #[allow(unused_mut, unused_assignments)]
344                    let mut platforms: ::std::option::Option<&'static [&'static str]> =
345                        ::std::option::Option::None;
346                    $( platforms = ::std::option::Option::Some(&[$($platform),+]); )?
347
348                    $crate::meta::ToolMeta {
349                        name: ::std::stringify!($name),
350                        toolset: $crate::meta::Toolset::$toolset,
351                        tier: $crate::meta::Tier::$tier,
352                        summary: ::std::concat!($($summary),*).trim_ascii(),
353                        self_severing: severing,
354                        severs_local_node,
355                        requires_confirmation: confirm,
356                        idempotent,
357                        varying_tier,
358                        min_version: since,
359                        platforms,
360                    }
361                };
362            )*
363        }
364
365        /// Every tool declared in this module, in declaration order.
366        pub fn entries() -> ::std::vec::Vec<$crate::registry::ToolEntry> {
367            ::std::vec![
368                $(
369                    {
370                        // Each tool gets its own shim so that the table can
371                        // hold plain function pointers rather than boxed
372                        // closures over twenty different parameter types.
373                        fn invoke(
374                            ctx: ::std::sync::Arc<$crate::context::ToolContext>,
375                            args: ::rmcp::model::JsonObject,
376                        ) -> ::tailscale_cli::BoxFuture<
377                            'static,
378                            $crate::error::ToolResult<::serde_json::Value>,
379                        > {
380                            ::std::boxed::Box::pin(async move {
381                                let params: $params = $crate::registry::parse_params(
382                                    ::std::stringify!($name),
383                                    args,
384                                )?;
385                                $handler(&ctx, params).await
386                            })
387                        }
388
389                        $crate::registry::ToolEntry {
390                            meta: self::metas::$name,
391                            schema: || {
392                                ::rmcp::handler::server::tool::schema_for_input::<$params>()
393                            },
394                            invoke,
395                        }
396                    },
397                )*
398            ]
399        }
400    };
401}
402
403/// Turn a call's arguments into a handler's parameter type.
404///
405/// A shape mismatch is `invalid_args` rather than a protocol error: the caller
406/// is a model, and a structured answer naming the problem is something it can
407/// act on, whereas a JSON-RPC error is opaque to it.
408pub fn parse_params<T: serde::de::DeserializeOwned>(tool: &str, args: JsonObject) -> ToolResult<T> {
409    serde_json::from_value(Value::Object(args)).map_err(|e| {
410        ToolError::invalid_args(format!("`{tool}` was called with unusable arguments: {e}"))
411    })
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::meta::{Tier, Toolset};
418    use schemars::JsonSchema;
419    use serde::Deserialize;
420    use std::collections::BTreeSet;
421
422    #[derive(Debug, Deserialize, JsonSchema)]
423    struct Empty {}
424
425    /// A parameter struct exercising both naming conventions in one tool, as
426    /// ADR-0004 requires: what we own is snake_case, what Tailscale owns keeps
427    /// the shape Tailscale gave it.
428    // The fields exist to be reflected into a schema, not to be read.
429    #[allow(dead_code)]
430    #[derive(Debug, Deserialize, JsonSchema)]
431    struct MixedParams {
432        /// Ours: how the result should be shaped.
433        include_peers: bool,
434        /// Theirs: forwarded to the control plane unchanged.
435        #[serde(rename = "keyExpiryDisabled")]
436        key_expiry_disabled: bool,
437    }
438
439    async fn ok_handler<P>(_ctx: &ToolContext, _params: P) -> ToolResult<Value> {
440        Ok(Value::Null)
441    }
442
443    mod declared {
444        use super::*;
445
446        crate::tools! {
447            /// Report the state of the local node.
448            tailscale_status => Empty, super::ok_handler,
449                toolset: LocalStatus, tier: Read, idempotent: true;
450
451            /// Disconnect this node from the tailnet.
452            tailscale_down => Empty, super::ok_handler,
453                toolset: LocalPrefs, tier: Destructive, severing: true;
454
455            /// Delete a device from the tailnet.
456            tailnet_device_delete => MixedParams, super::ok_handler,
457                toolset: TailnetDevices, tier: Destructive, confirm: true, since: "1.60";
458        }
459    }
460
461    fn registry() -> Registry {
462        Registry::new(declared::entries()).expect("a well-formed table")
463    }
464
465    fn open_gate() -> Gate {
466        Gate::unchecked(
467            Toolset::ALL.iter().copied().collect(),
468            Tier::Destructive,
469            BTreeSet::new(),
470        )
471    }
472
473    #[test]
474    fn a_declaration_produces_its_metadata_row() {
475        let registry = registry();
476        assert_eq!(registry.len(), 3);
477
478        let status = registry.get("tailscale_status").expect("declared");
479        assert_eq!(status.meta.toolset, Toolset::LocalStatus);
480        assert_eq!(status.meta.tier, Tier::Read);
481        assert_eq!(status.meta.summary, "Report the state of the local node.");
482        assert!(status.meta.idempotent);
483        assert!(!status.meta.requires_confirmation);
484        assert_eq!(status.meta.min_version, None);
485    }
486
487    #[test]
488    fn a_tool_that_severs_the_local_node_must_offer_somewhere_to_say_so() {
489        mod without {
490            use crate::registry::tests::Empty;
491
492            crate::tools! {
493                /// Delete a device, claiming to sever without asking.
494                tailnet_device_delete => Empty, super::ok_handler,
495                    toolset: TailnetDevices, tier: Destructive, severs_local: true;
496            }
497        }
498        // The flag alone would be a claim the schema does not keep: `Empty`
499        // has no `confirm`, so nothing a caller sends could confirm anything.
500        assert_eq!(
501            Registry::new(without::entries()).expect_err("no `confirm` to give"),
502            RegistryError::SeversLocalWithoutConfirmParameter("tailnet_device_delete")
503        );
504    }
505
506    #[test]
507    fn a_self_severing_tool_requires_confirmation_without_being_told_twice() {
508        let down = registry().get("tailscale_down").expect("declared").meta;
509        assert!(down.self_severing);
510        assert!(
511            down.requires_confirmation,
512            "severing must imply confirmation"
513        );
514    }
515
516    #[test]
517    fn optional_settings_land_where_they_are_given() {
518        let delete = registry()
519            .get("tailnet_device_delete")
520            .expect("declared")
521            .meta;
522        assert!(delete.requires_confirmation);
523        assert!(!delete.self_severing);
524        assert_eq!(delete.min_version, Some("1.60"));
525    }
526
527    #[test]
528    fn every_tool_has_exactly_one_row_and_a_usable_name() {
529        let registry = registry();
530        let mut names: Vec<&str> = registry.entries().iter().map(|e| e.meta.name).collect();
531        let before = names.len();
532        names.sort_unstable();
533        names.dedup();
534        assert_eq!(before, names.len(), "a tool appears twice");
535
536        for name in names {
537            assert!(!name.is_empty());
538            assert!(name.len() <= MAX_NAME_LEN, "{name} is too long");
539            assert!(
540                name.bytes()
541                    .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.'),
542                "{name} uses a character the protocol does not allow"
543            );
544            assert!(
545                name.starts_with("tailscale_") || name.starts_with("tailnet_"),
546                "{name} does not say which surface it acts on"
547            );
548        }
549    }
550
551    #[test]
552    fn a_duplicate_name_is_refused_at_construction() {
553        let mut entries = declared::entries();
554        entries.push(entries[0].clone());
555        assert_eq!(
556            Registry::new(entries).err(),
557            Some(RegistryError::DuplicateName("tailscale_status"))
558        );
559    }
560
561    #[test]
562    fn annotations_are_derived_from_the_tier_not_declared_beside_it() {
563        let registry = registry();
564        let status = registry
565            .get("tailscale_status")
566            .expect("declared")
567            .describe()
568            .expect("a valid schema");
569        let a = status.annotations.expect("annotations are set");
570        assert_eq!(a.read_only_hint, Some(true));
571        assert_eq!(a.destructive_hint, Some(false));
572        assert_eq!(a.idempotent_hint, Some(true));
573        assert_eq!(a.open_world_hint, Some(true));
574
575        let down = registry
576            .get("tailscale_down")
577            .expect("declared")
578            .describe()
579            .expect("a valid schema");
580        let a = down.annotations.expect("annotations are set");
581        assert_eq!(a.read_only_hint, Some(false));
582        assert_eq!(a.destructive_hint, Some(true));
583    }
584
585    #[test]
586    fn both_naming_conventions_survive_schema_generation() {
587        let tool = registry()
588            .get("tailnet_device_delete")
589            .expect("declared")
590            .describe()
591            .expect("a valid schema");
592        let properties = tool
593            .input_schema
594            .get("properties")
595            .and_then(Value::as_object)
596            .expect("an object schema with properties");
597
598        // Ours, in the casing we chose.
599        assert!(
600            properties.contains_key("include_peers"),
601            "server-owned parameters stay snake_case: {properties:?}"
602        );
603        // Theirs, in the casing they chose.
604        assert!(
605            properties.contains_key("keyExpiryDisabled"),
606            "control-plane fields keep their own shape: {properties:?}"
607        );
608    }
609
610    #[test]
611    fn the_confirmation_flag_appears_only_where_it_is_required() {
612        let registry = registry();
613        let confirming = registry
614            .get("tailscale_down")
615            .expect("declared")
616            .describe()
617            .expect("a valid schema");
618        let properties = confirming
619            .input_schema
620            .get("properties")
621            .and_then(Value::as_object)
622            .expect("properties");
623        assert!(properties.contains_key(CONFIRM_PARAM));
624
625        let plain = registry
626            .get("tailscale_status")
627            .expect("declared")
628            .describe()
629            .expect("a valid schema");
630        // A parameterless tool need not have a `properties` key at all; what
631        // matters is that nothing added one behind our back.
632        let properties = plain
633            .input_schema
634            .get("properties")
635            .and_then(Value::as_object);
636        assert!(properties.is_none_or(|p| !p.contains_key(CONFIRM_PARAM)));
637    }
638
639    #[test]
640    fn a_confirming_tool_refuses_until_the_caller_says_so() {
641        let registry = registry();
642        let gate = open_gate();
643
644        let err = registry
645            .resolve("tailscale_down", JsonObject::new(), &gate)
646            .expect_err("must not run unconfirmed");
647        assert_eq!(err.code, crate::error::ErrorCode::ConfirmationRequired);
648        assert!(err.message.contains("disconnect"), "{}", err.message);
649
650        let mut args = JsonObject::new();
651        args.insert(CONFIRM_PARAM.to_owned(), Value::Bool(true));
652        let (entry, rest) = registry
653            .resolve("tailscale_down", args, &gate)
654            .expect("a confirmed call runs");
655        assert_eq!(entry.meta.name, "tailscale_down");
656        assert!(
657            !rest.contains_key(CONFIRM_PARAM),
658            "the handler should not see the flag"
659        );
660    }
661
662    #[test]
663    fn a_confirmation_flag_of_the_wrong_shape_is_a_bad_argument() {
664        let mut args = JsonObject::new();
665        args.insert(CONFIRM_PARAM.to_owned(), Value::String("yes".to_owned()));
666        let err = registry()
667            .resolve("tailscale_down", args, &open_gate())
668            .expect_err("`yes` is not a boolean");
669        assert_eq!(err.code, crate::error::ErrorCode::InvalidArgs);
670    }
671
672    #[test]
673    fn the_router_is_the_table_filtered_by_the_gate() {
674        let registry = registry();
675        let gate = Gate::unchecked(
676            BTreeSet::from([Toolset::LocalStatus, Toolset::LocalPrefs]),
677            Tier::Read,
678            BTreeSet::new(),
679        );
680        let visible: Vec<&str> = registry
681            .visible(&gate)
682            .iter()
683            .map(|e| e.meta.name)
684            .collect();
685        assert_eq!(visible, ["tailscale_status"]);
686
687        // Hidden, but a call still gets a reasoned refusal rather than a
688        // confusing "no such tool".
689        let err = registry
690            .resolve("tailscale_down", JsonObject::new(), &gate)
691            .expect_err("hidden tools do not run");
692        assert_eq!(err.code, crate::error::ErrorCode::NotPermitted);
693        assert!(
694            err.hint
695                .as_deref()
696                .is_some_and(|h| h.contains("--allow-destructive")),
697            "{err:?}"
698        );
699    }
700
701    #[test]
702    fn an_unknown_name_is_not_found() {
703        let err = registry()
704            .resolve("tailscale_nonesuch", JsonObject::new(), &open_gate())
705            .expect_err("no such tool");
706        assert_eq!(err.code, crate::error::ErrorCode::NotFound);
707    }
708
709    #[test]
710    fn visible_tools_are_listed_in_a_stable_order() {
711        let registry = registry();
712        let names: Vec<&str> = registry
713            .visible(&open_gate())
714            .iter()
715            .map(|e| e.meta.name)
716            .collect();
717        let mut sorted = names.clone();
718        sorted.sort_unstable();
719        assert_eq!(names, sorted);
720    }
721}