1use std::collections::BTreeSet;
2use std::ffi::OsStr;
3use std::ffi::OsString;
4use std::fmt;
5use std::future::Future;
6use std::sync::{Arc, OnceLock};
7use std::time::Duration;
8
9use libtmux::Server;
10use rmcp::model::ErrorData;
11use serde::{Deserialize, Serialize};
12
13use crate::tail::Tails;
14use crate::{CallerIdentity, TmuxTools, schema, tools};
15
16pub const RETIRED_RUST_SAFETY_ENV: &str = "TMUX_MCP_SAFETY";
18
19pub const TOOLSETS_ENV: &str = "LIBTMUX_TOOLSETS";
21
22pub const TOOLS_ENV: &str = "LIBTMUX_TOOLS";
24
25pub const EXCLUDE_TOOLS_ENV: &str = "LIBTMUX_EXCLUDE_TOOLS";
27
28pub const RETIRED_SAFETY_ENV: &str = "LIBTMUX_SAFETY";
30
31pub const ENVIRONMENT_VALUES_ENV: &str = "LIBTMUX_ENVIRONMENT_VALUES";
33
34const MAX_ENVIRONMENT_VALUES: usize = 32;
36
37pub fn parse_environment_values(value: Option<&str>) -> Result<BTreeSet<String>, SurfaceError> {
47 let names = parse_optional_names(value, ENVIRONMENT_VALUES_ENV)?;
48 if let Some(name) = names.iter().find(|name| name.contains(['=', '\0'])) {
49 return Err(SurfaceError::new(format!(
50 "{ENVIRONMENT_VALUES_ENV} names {name:?}, which is not a variable name"
51 )));
52 }
53 if names.len() > MAX_ENVIRONMENT_VALUES {
54 return Err(SurfaceError::new(format!(
55 "{ENVIRONMENT_VALUES_ENV} allows at most {MAX_ENVIRONMENT_VALUES} names"
56 )));
57 }
58 Ok(names)
59}
60
61pub fn environment_values_from_env() -> Result<BTreeSet<String>, SurfaceError> {
68 parse_environment_values(unicode_env(ENVIRONMENT_VALUES_ENV)?.as_deref())
69}
70
71#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum Toolset {
75 Inspect,
77 Manage,
79 Execute,
81 Teardown,
83}
84
85impl Toolset {
86 const ALL: [Self; 4] = [Self::Inspect, Self::Manage, Self::Execute, Self::Teardown];
87
88 fn parse(name: &str) -> Option<Self> {
89 match name {
90 "inspect" => Some(Self::Inspect),
91 "manage" => Some(Self::Manage),
92 "execute" => Some(Self::Execute),
93 "teardown" => Some(Self::Teardown),
94 _ => None,
95 }
96 }
97
98 #[must_use]
100 pub const fn name(self) -> &'static str {
101 match self {
102 Self::Inspect => "inspect",
103 Self::Manage => "manage",
104 Self::Execute => "execute",
105 Self::Teardown => "teardown",
106 }
107 }
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct Selection {
113 toolsets: Vec<Toolset>,
114 include: BTreeSet<String>,
115 exclude: BTreeSet<String>,
116}
117
118impl Selection {
119 pub fn parse(
125 toolsets: Option<&str>,
126 include: Option<&str>,
127 exclude: Option<&str>,
128 ) -> Result<Self, SurfaceError> {
129 Self::parse_for_socket(toolsets, include, exclude, false)
130 }
131
132 pub fn parse_for_socket(
138 toolsets: Option<&str>,
139 include: Option<&str>,
140 exclude: Option<&str>,
141 default_teardown: bool,
142 ) -> Result<Self, SurfaceError> {
143 let toolsets = match toolsets {
144 None if default_teardown => Toolset::ALL.to_vec(),
145 None => vec![Toolset::Inspect, Toolset::Manage, Toolset::Execute],
146 Some("") => Vec::new(),
147 Some(value) => parse_names(value, "LIBTMUX_TOOLSETS")?
148 .into_iter()
149 .map(|name| {
150 Toolset::parse(&name).ok_or_else(|| {
151 SurfaceError::new(format!(
152 "unknown toolset {name:?}; expected inspect, manage, execute, or teardown"
153 ))
154 })
155 })
156 .collect::<Result<BTreeSet<_>, _>>()?
157 .into_iter()
158 .collect(),
159 };
160 Ok(Self {
161 toolsets,
162 include: parse_optional_names(include, "LIBTMUX_TOOLS")?,
163 exclude: parse_optional_names(exclude, "LIBTMUX_EXCLUDE_TOOLS")?,
164 })
165 }
166
167 pub fn from_env(default_teardown: bool) -> Result<Self, SurfaceError> {
173 for retired in [RETIRED_SAFETY_ENV, RETIRED_RUST_SAFETY_ENV] {
174 if std::env::var_os(retired).is_some() {
175 return Err(SurfaceError::new(format!(
176 "{retired} has been removed; use {TOOLSETS_ENV}"
177 )));
178 }
179 }
180 let toolsets = unicode_env(TOOLSETS_ENV)?;
181 let include = unicode_env(TOOLS_ENV)?;
182 let exclude = unicode_env(EXCLUDE_TOOLS_ENV)?;
183 Self::parse_for_socket(
184 toolsets.as_deref(),
185 include.as_deref(),
186 exclude.as_deref(),
187 default_teardown,
188 )
189 }
190
191 #[must_use]
193 pub fn toolsets(&self) -> &[Toolset] {
194 &self.toolsets
195 }
196
197 pub(super) fn includes(&self, name: &str) -> bool {
198 self.include.contains(name)
199 }
200
201 pub(super) fn excludes(&self, name: &str) -> bool {
202 self.exclude.contains(name)
203 }
204
205 pub(super) fn included_names(&self) -> &BTreeSet<String> {
206 &self.include
207 }
208
209 pub(super) fn excluded_names(&self) -> &BTreeSet<String> {
210 &self.exclude
211 }
212}
213
214fn unicode_env(name: &'static str) -> Result<Option<String>, SurfaceError> {
215 match std::env::var(name) {
216 Ok(value) => Ok(Some(value)),
217 Err(std::env::VarError::NotPresent) => Ok(None),
218 Err(std::env::VarError::NotUnicode(value)) => Err(non_unicode(name, value)),
219 }
220}
221
222fn non_unicode(name: &'static str, _value: OsString) -> SurfaceError {
223 SurfaceError::new(format!("{name} must be valid UTF-8"))
224}
225
226fn parse_optional_names(
227 value: Option<&str>,
228 variable: &'static str,
229) -> Result<BTreeSet<String>, SurfaceError> {
230 match value {
231 None | Some("") => Ok(BTreeSet::new()),
232 Some(value) => Ok(parse_names(value, variable)?.into_iter().collect()),
233 }
234}
235
236fn parse_names(value: &str, variable: &'static str) -> Result<Vec<String>, SurfaceError> {
237 value
238 .split(',')
239 .map(|raw| {
240 let name = raw.trim();
241 if name.is_empty() {
242 Err(SurfaceError::new(format!(
243 "{variable} contains an empty name"
244 )))
245 } else {
246 Ok(name.to_owned())
247 }
248 })
249 .collect()
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct SurfaceError(String);
255
256impl SurfaceError {
257 pub(crate) fn new(message: impl Into<String>) -> Self {
258 Self(message.into())
259 }
260}
261
262impl fmt::Display for SurfaceError {
263 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264 formatter.write_str(&self.0)
265 }
266}
267
268impl std::error::Error for SurfaceError {}
269
270#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum SocketProvenance {
274 DedicatedMinimal,
276 DedicatedExisting,
278 OperatorSelected,
280 OperatorSelectedAbsent,
282 UserConfigured,
284 UserConfiguredExisting,
286 #[default]
288 Unknown,
289}
290
291impl SocketProvenance {
292 #[must_use]
294 pub const fn defaults_to_teardown(self) -> bool {
295 matches!(self, Self::DedicatedMinimal)
296 }
297
298 fn report(self, server: &Server) -> crate::manifest::SocketReport {
299 let selector = server.socket_name().map_or_else(
300 || format!("path:{}", server.socket_path().display()),
301 |name| format!("name:{}", name.to_string_lossy()),
302 );
303 let (selection_provenance, server_state, configuration_provenance) = match self {
304 Self::DedicatedMinimal => ("default-dedicated", "created", "minimal"),
305 Self::DedicatedExisting => ("default-dedicated", "existing", "unknown"),
306 Self::OperatorSelected | Self::UserConfiguredExisting => {
307 ("operator-current", "existing", "unknown")
308 }
309 Self::OperatorSelectedAbsent => ("operator-current", "absent", "unknown"),
310 Self::UserConfigured => ("operator-current", "absent", "user-configured"),
311 Self::Unknown => ("unknown", "unknown", "unknown"),
312 };
313 crate::manifest::SocketReport {
314 selector,
315 selection_provenance,
316 server_state,
317 configuration_provenance,
318 namespace_boundary: "tmux-objects-only",
319 }
320 }
321}
322
323fn shell_quote(value: &OsStr) -> String {
324 let value = value.to_string_lossy();
325 format!("'{}'", value.replace('\'', "'\"'\"'"))
326}
327
328fn connection_report(
329 socket: &crate::manifest::SocketReport,
330 server: &Server,
331) -> crate::manifest::ConnectionReport {
332 let path = server.socket_path().as_os_str();
333 crate::manifest::ConnectionReport {
334 socket_selector: socket.selector.clone(),
335 socket_provenance: socket.selection_provenance,
336 resolved_socket_path: server.socket_path().to_string_lossy().into_owned(),
337 server_state: socket.server_state,
338 configuration_provenance: socket.configuration_provenance,
339 attach_command: format!(
340 "{} -N -S {} attach",
341 shell_quote(server.tmux_executable()),
342 shell_quote(path),
343 ),
344 }
345}
346
347#[derive(Debug)]
349pub struct Builder {
350 server: Server,
351 caller: Option<CallerIdentity>,
352 selection: Selection,
353 socket_provenance: SocketProvenance,
354 environment_values: BTreeSet<String>,
355}
356
357impl Builder {
358 #[must_use]
365 pub fn environment_values(mut self, names: BTreeSet<String>) -> Self {
366 self.environment_values = names;
367 self
368 }
369
370 #[must_use]
372 pub fn caller(mut self, caller: Option<CallerIdentity>) -> Self {
373 self.caller = caller;
374 self
375 }
376
377 #[must_use]
379 pub fn selection(mut self, selection: Selection) -> Self {
380 self.selection = selection;
381 self
382 }
383
384 #[must_use]
386 pub const fn socket_provenance(mut self, provenance: SocketProvenance) -> Self {
387 self.socket_provenance = provenance;
388 self
389 }
390
391 #[must_use]
397 #[allow(
398 clippy::expect_used,
399 reason = "build preserves the existing infallible constructor contract"
400 )]
401 pub fn build(self) -> TmuxTools {
402 self.try_build()
403 .expect("native tool routes and the requested surface are valid")
404 }
405
406 pub fn try_build(self) -> Result<TmuxTools, SurfaceError> {
412 let identity = Arc::new(crate::identity::InstanceIdentity::new());
413 let mut router = tools::router();
414 for route in router.map.values_mut() {
415 schema::strip_unknown_formats(Arc::make_mut(&mut route.attr.input_schema));
416 if let Some(schema) = route.attr.output_schema.as_mut() {
417 schema::strip_unknown_formats(Arc::make_mut(schema));
418 }
419 }
420 let mut resolved = crate::manifest::resolve(router, &self.selection)?;
421 let socket = self.socket_provenance.report(&self.server);
422 resolved.report.connection = connection_report(&socket, &self.server);
423 resolved.report.socket = socket;
424 let router = resolved.router;
425 Ok(TmuxTools {
426 server: Arc::new(self.server),
427 caller: self.caller.map(Arc::new),
428 capability_report: Arc::new(resolved.report),
429 socket: Arc::new(OnceLock::new()),
430 tails: Arc::new(Tails::new(identity)),
431 echoes: Arc::new(crate::echo::PaneEchoes::new()),
432 tool_router: router,
433 nested_tool_router: resolved.nested_router,
434 environment_values: Arc::new(self.environment_values),
435 })
436 }
437}
438
439#[derive(Clone, Debug)]
446struct Progress {
447 peer: rmcp::service::Peer<rmcp::RoleServer>,
448 token: rmcp::model::ProgressToken,
449}
450
451#[derive(Clone, Debug, Default)]
458pub struct Reporter(Option<Progress>);
459
460impl Reporter {
461 #[must_use]
463 pub const fn none() -> Self {
464 Self(None)
465 }
466}
467
468impl<C> rmcp::handler::server::common::FromContextPart<C> for Reporter
469where
470 C: rmcp::handler::server::common::AsRequestContext,
471{
472 fn from_context_part(context: &mut C) -> Result<Self, ErrorData> {
473 let context = context.as_request_context();
474 Ok(Self(context.meta.get_progress_token().map(|token| {
475 Progress {
476 peer: context.peer.clone(),
477 token,
478 }
479 })))
480 }
481}
482
483impl Progress {
484 async fn say(&self, so_far: f64, message: impl Into<String>) {
493 let mut param = rmcp::model::ProgressNotificationParam::new(self.token.clone(), so_far);
494 param.message = Some(message.into());
495 let _ = self.peer.notify_progress(param).await;
496 }
497}
498
499pub(super) async fn reporting<T>(
505 reporter: Reporter,
506 what: &str,
507 work: impl Future<Output = T>,
508) -> T {
509 let Some(progress) = reporter.0 else {
510 return work.await;
511 };
512
513 let began = tokio::time::Instant::now();
514 let ticker = async {
515 let mut every = tokio::time::interval(PROGRESS_EVERY);
516 every.tick().await;
518 loop {
519 every.tick().await;
520 let elapsed = began.elapsed().as_secs();
521 progress
522 .say(
523 f64::from(u32::try_from(elapsed).unwrap_or(u32::MAX)),
524 format!("{what}, {elapsed}s so far"),
525 )
526 .await;
527 }
528 };
529
530 tokio::select! {
531 outcome = work => outcome,
532 () = ticker => unreachable!("the ticker loops forever"),
533 }
534}
535
536const PROGRESS_EVERY: Duration = Duration::from_secs(5);
538
539impl TmuxTools {
540 #[must_use]
542 pub fn new(server: Server) -> Self {
543 Self::builder(server).build()
544 }
545
546 #[must_use]
553 pub fn builder(server: Server) -> Builder {
554 Builder {
555 server,
556 caller: CallerIdentity::from_env(),
557 selection: Selection {
558 toolsets: vec![Toolset::Inspect, Toolset::Manage, Toolset::Execute],
559 include: BTreeSet::new(),
560 exclude: BTreeSet::new(),
561 },
562 socket_provenance: SocketProvenance::Unknown,
563 environment_values: BTreeSet::new(),
564 }
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::{Selection, Toolset};
571 use crate::manifest::{OutputClass, ProcessReach, TmuxEffect};
572 use std::collections::BTreeSet;
573
574 #[test]
575 fn toolset_selection_distinguishes_empty_from_empty_tokens() {
576 let empty = Selection::parse(Some(""), None, None).expect("empty surface");
577 assert!(empty.toolsets().is_empty());
578
579 let inspect = Selection::parse(Some("inspect"), None, None).expect("one toolset");
580 assert_eq!(inspect.toolsets(), &[Toolset::Inspect]);
581
582 for malformed in [",inspect", "inspect,", "inspect,,manage"] {
583 let error = Selection::parse(Some(malformed), None, None).expect_err("empty token");
584 assert!(error.to_string().contains("empty"), "{malformed}: {error}");
585 }
586 }
587
588 #[test]
589 fn registered_routes_are_the_capability_manifest() {
590 let selection =
591 Selection::parse_for_socket(None, None, None, true).expect("dedicated minimal surface");
592 let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
593 .expect("complete manifest");
594 let listed = resolved.router.list_all();
595 let reported: Vec<_> = resolved
596 .report
597 .tools
598 .iter()
599 .map(|tool| tool.name.as_str())
600 .collect();
601 let names: Vec<_> = listed.iter().map(|tool| tool.name.as_ref()).collect();
602
603 assert_eq!(names, reported);
604 assert!(listed.iter().all(|tool| {
605 let description = tool.description.as_deref().expect("description");
606 resolved
607 .report
608 .tools
609 .iter()
610 .any(|row| row.name == tool.name && description.ends_with(row.controlled_opener()))
611 }));
612 }
613
614 #[test]
623 fn every_tools_first_sentence_is_distinct() {
624 let selection =
625 Selection::parse_for_socket(None, None, None, true).expect("dedicated minimal surface");
626 let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
627 .expect("complete manifest");
628
629 let listed = resolved.router.list_all();
630 let mut by_first_sentence: std::collections::HashMap<&str, Vec<&str>> =
631 std::collections::HashMap::new();
632 for tool in &listed {
633 let description = tool.description.as_deref().expect("description");
634 let first_sentence = description.split(". ").next().unwrap_or(description);
635 by_first_sentence
636 .entry(first_sentence)
637 .or_default()
638 .push(tool.name.as_ref());
639 }
640 let collisions: Vec<_> = by_first_sentence
641 .into_iter()
642 .filter(|(_, names)| names.len() > 1)
643 .collect();
644 assert!(
645 collisions.is_empty(),
646 "tools sharing a first sentence, indistinguishable by a caller that reads only that \
647 far: {collisions:?}",
648 );
649 }
650
651 #[test]
655 fn every_tools_own_description_ends_its_sentence() {
656 let unfinished: Vec<_> = crate::tools::router()
657 .list_all()
658 .into_iter()
659 .filter(|tool| {
660 !tool
661 .description
662 .as_deref()
663 .unwrap_or_default()
664 .trim_end()
665 .ends_with(['.', '!', '?'])
666 })
667 .map(|tool| tool.name.into_owned())
668 .collect();
669
670 assert!(unfinished.is_empty(), "no closing period: {unfinished:?}");
671 }
672
673 #[test]
676 fn annotations_follow_each_tools_capability_row() {
677 let selection = Selection::parse(Some("inspect,manage,execute,teardown"), None, None)
678 .expect("selection");
679 let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
680 .expect("complete manifest");
681 let mut distinct = BTreeSet::new();
682 let mut destructive_tools = BTreeSet::new();
683
684 for tool in resolved.router.list_all() {
685 let row = &resolved
686 .report
687 .tools
688 .iter()
689 .find(|row| row.name == tool.name)
690 .expect("report row")
691 .capability;
692 let hints = tool.annotations.as_ref().expect("annotations");
693 let read_only = hints.read_only_hint.expect("readOnlyHint");
694 let destructive = hints.destructive_hint.expect("destructiveHint");
695 let changes_tmux = row.toolset != Toolset::Inspect
696 || row.process_reach != ProcessReach::None
697 || row
698 .tmux_effects
699 .iter()
700 .any(|effect| *effect != TmuxEffect::Observe);
701 let can_destroy = row.tmux_effects.contains(&TmuxEffect::Delete)
702 || matches!(
703 row.process_reach,
704 ProcessReach::PaneInput | ProcessReach::PaneCommand
705 );
706
707 assert_eq!(read_only, !changes_tmux, "{} readOnlyHint", tool.name);
708 assert_eq!(destructive, can_destroy, "{} destructiveHint", tool.name);
709 if destructive {
710 destructive_tools.insert(tool.name.to_string());
711 }
712 distinct.insert((
713 read_only,
714 destructive,
715 hints.idempotent_hint.expect("idempotentHint"),
716 hints.open_world_hint.expect("openWorldHint"),
717 ));
718 }
719 assert!(distinct.len() > 3, "hints barely vary: {distinct:?}");
720 assert!(destructive_tools.contains("set_history_limit"));
723 }
724
725 #[test]
726 fn configured_value_reads_disclose_configured_command_output() {
727 let selection = Selection::parse(Some("inspect"), None, None).expect("selection");
728 let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
729 .expect("complete manifest");
730
731 for name in ["get_tmux_variables", "show_option"] {
732 let tool = resolved
733 .report
734 .tools
735 .iter()
736 .find(|tool| tool.name == name)
737 .expect("tool row");
738 assert_eq!(
739 tool.capability.output_classes,
740 [OutputClass::TmuxMetadata, OutputClass::ConfiguredCommand]
741 .into_iter()
742 .collect(),
743 "{name}",
744 );
745 assert!(
746 tool.controlled_opener()
747 .starts_with("Read configured tmux commands;")
748 );
749 }
750 }
751
752 #[test]
753 fn synchronize_panes_is_the_only_declared_input_amplifier() {
754 let selection = Selection::parse(Some("inspect,manage,execute,teardown"), None, None)
755 .expect("selection");
756 let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
757 .expect("complete manifest");
758 let report = serde_json::to_value(resolved.report).expect("report serializes");
759 let tools = report["tools"].as_array().expect("tool rows");
760
761 assert!(
762 tools
763 .iter()
764 .all(|tool| tool["amplifiesFutureInput"].is_boolean()),
765 "every manifest row carries the amplification fact"
766 );
767 let amplified: Vec<_> = tools
768 .iter()
769 .filter(|tool| tool["amplifiesFutureInput"] == true)
770 .map(|tool| tool["name"].as_str().expect("tool name"))
771 .collect();
772 assert_eq!(amplified, ["set_synchronize_panes"]);
773 let synchronize = resolved
774 .router
775 .list_all()
776 .into_iter()
777 .find(|tool| tool.name == "set_synchronize_panes")
778 .expect("synchronize route");
779 let description = synchronize.description.as_deref().expect("description");
780 for claim in ["window default", "Individual pane overrides", "can amplify"] {
781 assert!(description.contains(claim), "missing {claim:?}");
782 }
783 }
784
785 #[test]
786 fn unknown_provenance_defaults_without_teardown() {
787 let selection = Selection::parse(None, None, None).expect("conservative default");
788 assert_eq!(
789 selection.toolsets(),
790 &[Toolset::Inspect, Toolset::Manage, Toolset::Execute]
791 );
792 }
793
794 #[test]
795 fn spawn_routes_accept_no_command_or_environment_payload() {
796 let router = crate::tools::router();
797 for name in [
798 "create_session",
799 "create_window",
800 "split_window",
801 "respawn_pane",
802 ] {
803 let schema = &router.get(name).expect("spawn route").input_schema;
804 let keys: BTreeSet<_> = schema
805 .get("properties")
806 .and_then(serde_json::Value::as_object)
807 .expect("object schema")
808 .keys()
809 .map(String::as_str)
810 .collect();
811 assert!(!keys.contains("command"), "{name}");
812 assert!(!keys.contains("environment"), "{name}");
813 assert!(!keys.contains("env"), "{name}");
814 }
815 }
816
817 #[test]
818 fn exclusion_removes_aggregate_nested_authority() {
819 let selection =
820 Selection::parse(Some("inspect"), None, Some("capture_pane")).expect("selection");
821 let resolved =
822 crate::manifest::resolve(crate::tools::router(), &selection).expect("resolved surface");
823 let batch = resolved
824 .report
825 .tools
826 .iter()
827 .find(|tool| tool.name == "call_read_tools_batch")
828 .expect("batch route");
829
830 assert!(!batch.capability.nested_authority.contains("capture_pane"));
831 }
832
833 #[test]
834 fn read_batch_covers_every_non_self_bounded_inspect_route() {
835 let selection = Selection::parse(Some("inspect"), None, None).expect("selection");
836 let resolved =
837 crate::manifest::resolve(crate::tools::router(), &selection).expect("resolved surface");
838 let expected: BTreeSet<_> = resolved
839 .report
840 .tools
841 .iter()
842 .filter(|tool| {
843 tool.capability.toolset == Toolset::Inspect
844 && tool.name != "call_read_tools_batch"
845 && tool.name != "wait_for_text"
846 })
847 .map(|tool| tool.name.clone())
848 .collect();
849 let batch = resolved
850 .report
851 .tools
852 .iter()
853 .find(|tool| tool.name == "call_read_tools_batch")
854 .expect("batch route");
855
856 assert_eq!(batch.capability.nested_authority, expected);
857 let description = resolved
858 .router
859 .list_all()
860 .into_iter()
861 .find(|tool| tool.name == "call_read_tools_batch")
862 .and_then(|tool| tool.description.map(std::borrow::Cow::into_owned))
863 .expect("batch description");
864 assert!(
865 description.contains("inner tools do not receive separate client approval"),
866 "{description}"
867 );
868 }
869}