Skip to main content

turbomcp_server/
visibility.rs

1//! Progressive disclosure through component visibility control.
2//!
3//! This module provides the ability to dynamically show/hide tools, resources,
4//! and prompts based on tags, exact component names, and tool annotations. This
5//! enables patterns like:
6//!
7//! - Hiding admin tools until explicitly unlocked
8//! - Progressive disclosure of advanced features
9//! - Role-based component visibility
10//! - Smaller `tools/list` responses for clients with tool-count or context limits
11//! - Read-only MCP profiles that hide write/destructive tools from LLM clients
12//!
13//! # Memory Management
14//!
15//! Session visibility overrides are stored in a per-layer map keyed by session ID.
16//! **IMPORTANT**: You must ensure cleanup happens when sessions end to prevent
17//! memory leaks. Use one of these approaches:
18//!
19//! 1. **Recommended**: Use [`VisibilitySessionGuard`] which automatically cleans up on drop
20//! 2. **Manual**: Call [`VisibilityLayer::clear_session`] when a session disconnects
21//!
22//! # Example
23//!
24//! ```rust,ignore
25//! use turbomcp_server::{VisibilityConfig, VisibilityLayer, VisibilitySessionGuard};
26//! use turbomcp_types::component::ComponentFilter;
27//!
28//! // Create a visibility layer that hides admin tools by default
29//! let layer = VisibilityLayer::new(server)
30//!     .with_disabled(ComponentFilter::with_tags(["admin"]))
31//!     .with_disabled_tools(["delete_all", "reset_database"]);
32//!
33//! // Or apply a config loaded by a consumer such as TurboVault
34//! let layer = VisibilityLayer::new(server)
35//!     .with_visibility_config(
36//!         VisibilityConfig::new()
37//!             .with_allowed_tools(["search", "read_note", "list_notes"])
38//!             .require_read_only_tools(),
39//!     );
40//!
41//! // Tools, resources, and prompts tagged with "admin" won't appear
42//! // until explicitly enabled via the RequestContext
43//!
44//! async fn handle_session(layer: &VisibilityLayer<MyHandler>, session_id: &str) {
45//!     // Guard ensures cleanup when it goes out of scope
46//!     let _guard = layer.session_guard(session_id);
47//!
48//!     // Enable admin tools for this session
49//!     layer.enable_for_session(session_id, &["admin".to_string()]);
50//!
51//!     // ... handle requests ...
52//!
53//! } // Guard dropped here, session state automatically cleaned up
54//! ```
55
56use std::collections::{BTreeMap, BTreeSet, HashSet};
57use std::sync::Arc;
58
59use parking_lot::RwLock;
60use serde::{Deserialize, Serialize};
61use turbomcp_core::context::RequestContext;
62use turbomcp_core::error::{McpError, McpResult};
63use turbomcp_core::handler::McpHandler;
64use turbomcp_types::{
65    ComponentFilter, ComponentMeta, Prompt, PromptResult, Resource, ResourceResult,
66    ResourceTemplate, Tool, ToolResult,
67};
68
69/// Type alias for session visibility maps to reduce complexity.
70type SessionVisibilityMap = Arc<dashmap::DashMap<String, HashSet<String>>>;
71
72/// Type alias for the cached component registry used by dispatch authorization.
73type SharedComponentRegistry = Arc<RwLock<ComponentRegistryCache>>;
74
75#[derive(Debug, Clone, Default)]
76struct ComponentRegistryCache {
77    tools: Option<BTreeMap<String, Tool>>,
78    resources_by_uri: Option<BTreeMap<String, Resource>>,
79    resource_templates_by_uri_template: Option<BTreeMap<String, ResourceTemplate>>,
80    prompts: Option<BTreeMap<String, Prompt>>,
81}
82
83enum RegistryLookup<T> {
84    Uninitialized,
85    Found(T),
86    Missing,
87}
88
89impl ComponentRegistryCache {
90    fn replace_tools(&mut self, tools: Vec<Tool>) {
91        let mut registry = BTreeMap::new();
92        for tool in tools {
93            registry.entry(tool.name.clone()).or_insert(tool);
94        }
95        self.tools = Some(registry);
96    }
97
98    fn replace_resources(&mut self, resources: Vec<Resource>) {
99        let mut registry = BTreeMap::new();
100        for resource in resources {
101            registry.entry(resource.uri.clone()).or_insert(resource);
102        }
103        self.resources_by_uri = Some(registry);
104    }
105
106    fn replace_resource_templates(&mut self, templates: Vec<ResourceTemplate>) {
107        let mut registry = BTreeMap::new();
108        for template in templates {
109            registry
110                .entry(template.uri_template.clone())
111                .or_insert(template);
112        }
113        self.resource_templates_by_uri_template = Some(registry);
114    }
115
116    fn replace_prompts(&mut self, prompts: Vec<Prompt>) {
117        let mut registry = BTreeMap::new();
118        for prompt in prompts {
119            registry.entry(prompt.name.clone()).or_insert(prompt);
120        }
121        self.prompts = Some(registry);
122    }
123
124    fn tool(&self, name: &str) -> RegistryLookup<Tool> {
125        match &self.tools {
126            Some(tools) => tools
127                .get(name)
128                .cloned()
129                .map_or(RegistryLookup::Missing, RegistryLookup::Found),
130            None => RegistryLookup::Uninitialized,
131        }
132    }
133
134    fn resource_by_uri(&self, uri: &str) -> RegistryLookup<Resource> {
135        match &self.resources_by_uri {
136            Some(resources) => resources
137                .get(uri)
138                .cloned()
139                .map_or(RegistryLookup::Missing, RegistryLookup::Found),
140            None => RegistryLookup::Uninitialized,
141        }
142    }
143
144    fn prompt(&self, name: &str) -> RegistryLookup<Prompt> {
145        match &self.prompts {
146            Some(prompts) => prompts
147                .get(name)
148                .cloned()
149                .map_or(RegistryLookup::Missing, RegistryLookup::Found),
150            None => RegistryLookup::Uninitialized,
151        }
152    }
153
154    fn clear(&mut self) {
155        *self = Self::default();
156    }
157
158    fn is_initialized(&self) -> bool {
159        self.tools.is_some()
160            || self.resources_by_uri.is_some()
161            || self.resource_templates_by_uri_template.is_some()
162            || self.prompts.is_some()
163    }
164}
165
166/// Exact-name visibility rules for one MCP component family.
167///
168/// Matching is case-sensitive and exact. Deny rules win over allow rules. When
169/// `allow` is `Some`, only matching identifiers are enabled; when it is `None`,
170/// every identifier is enabled unless it appears in `deny`. Hidden identifiers
171/// remain callable/readable/gettable, but are omitted from `list_*` responses.
172///
173/// Resources and resource templates are matched by both `name` and URI/URI
174/// template, so config authors can use whichever identifier is most stable for
175/// their server.
176#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(default)]
178pub struct ComponentVisibilityRules {
179    /// Exact identifiers to enable. `None` means no allowlist is configured.
180    ///
181    /// An empty set inside `Some` intentionally disables the entire component
182    /// family.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub allow: Option<BTreeSet<String>>,
185
186    /// Exact identifiers to disable from both listing and direct use.
187    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
188    pub deny: BTreeSet<String>,
189
190    /// Exact identifiers to omit from lists while still permitting direct use.
191    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
192    pub hide: BTreeSet<String>,
193}
194
195impl ComponentVisibilityRules {
196    /// Create rules that enable and list everything unless denied or hidden.
197    #[must_use]
198    pub fn new() -> Self {
199        Self::default()
200    }
201
202    /// Create rules that enable only the given exact identifiers.
203    #[must_use]
204    pub fn allow<I, S>(names: I) -> Self
205    where
206        I: IntoIterator<Item = S>,
207        S: Into<String>,
208    {
209        Self {
210            allow: Some(collect_names(names)),
211            deny: BTreeSet::new(),
212            hide: BTreeSet::new(),
213        }
214    }
215
216    /// Create rules that disable the given exact identifiers.
217    #[must_use]
218    pub fn deny<I, S>(names: I) -> Self
219    where
220        I: IntoIterator<Item = S>,
221        S: Into<String>,
222    {
223        Self {
224            allow: None,
225            deny: collect_names(names),
226            hide: BTreeSet::new(),
227        }
228    }
229
230    /// Create rules that omit the given exact identifiers from lists.
231    #[must_use]
232    pub fn hide<I, S>(names: I) -> Self
233    where
234        I: IntoIterator<Item = S>,
235        S: Into<String>,
236    {
237        Self {
238            allow: None,
239            deny: BTreeSet::new(),
240            hide: collect_names(names),
241        }
242    }
243
244    /// Replace the allowlist with the given exact identifiers.
245    #[must_use]
246    pub fn with_allowed<I, S>(mut self, names: I) -> Self
247    where
248        I: IntoIterator<Item = S>,
249        S: Into<String>,
250    {
251        self.allow = Some(collect_names(names));
252        self
253    }
254
255    /// Replace the denylist with the given exact identifiers.
256    #[must_use]
257    pub fn with_disabled<I, S>(mut self, names: I) -> Self
258    where
259        I: IntoIterator<Item = S>,
260        S: Into<String>,
261    {
262        self.deny = collect_names(names);
263        self
264    }
265
266    /// Replace the hidden list with the given exact identifiers.
267    #[must_use]
268    pub fn with_hidden<I, S>(mut self, names: I) -> Self
269    where
270        I: IntoIterator<Item = S>,
271        S: Into<String>,
272    {
273        self.hide = collect_names(names);
274        self
275    }
276
277    /// Check whether a single exact identifier is enabled for direct use.
278    #[must_use]
279    pub fn is_enabled(&self, identifier: &str) -> bool {
280        self.is_enabled_any([identifier])
281    }
282
283    /// Check whether any identifier for the same component is enabled for direct use.
284    ///
285    /// Denying any identifier disables the component. When an allowlist is
286    /// present, at least one identifier must be allowlisted.
287    #[must_use]
288    pub fn is_enabled_any<'a, I>(&self, identifiers: I) -> bool
289    where
290        I: IntoIterator<Item = &'a str>,
291    {
292        let identifiers = identifiers.into_iter().collect::<Vec<_>>();
293
294        if identifiers
295            .iter()
296            .any(|identifier| self.deny.contains(*identifier))
297        {
298            return false;
299        }
300
301        self.allow.as_ref().is_none_or(|allow| {
302            identifiers
303                .iter()
304                .any(|identifier| allow.contains(*identifier))
305        })
306    }
307
308    /// Check whether a single exact identifier should appear in list responses.
309    #[must_use]
310    pub fn is_listed(&self, identifier: &str) -> bool {
311        self.is_listed_any([identifier])
312    }
313
314    /// Check whether any identifier for the same component should appear in lists.
315    #[must_use]
316    pub fn is_listed_any<'a, I>(&self, identifiers: I) -> bool
317    where
318        I: IntoIterator<Item = &'a str>,
319    {
320        let identifiers = identifiers.into_iter().collect::<Vec<_>>();
321
322        self.is_enabled_any(identifiers.iter().copied())
323            && !identifiers
324                .iter()
325                .any(|identifier| self.hide.contains(*identifier))
326    }
327}
328
329/// Complete runtime visibility configuration for an MCP server.
330///
331/// This type is intentionally serializable so applications can deserialize a
332/// user-facing config file and pass it directly to
333/// [`VisibilityLayer::with_visibility_config`].
334#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(default)]
336pub struct VisibilityConfig {
337    /// Exact-name rules for tools.
338    pub tools: ComponentVisibilityRules,
339    /// Exact-name rules for resources. Matches resource `name` or `uri`.
340    pub resources: ComponentVisibilityRules,
341    /// Exact-name rules for resource templates. Matches `name` or `uriTemplate`.
342    pub resource_templates: ComponentVisibilityRules,
343    /// Exact-name rules for prompts.
344    pub prompts: ComponentVisibilityRules,
345    /// Hide every tool that is not explicitly annotated `readOnlyHint: true`.
346    ///
347    /// Tools marked `destructiveHint: true` are hidden even if they also carry a
348    /// read-only hint, because conflicting safety hints should fail closed.
349    #[serde(skip_serializing_if = "is_false")]
350    pub require_read_only_tools: bool,
351}
352
353impl VisibilityConfig {
354    /// Create an empty visibility config.
355    #[must_use]
356    pub fn new() -> Self {
357        Self::default()
358    }
359
360    /// Enable only the named tools.
361    #[must_use]
362    pub fn with_allowed_tools<I, S>(mut self, names: I) -> Self
363    where
364        I: IntoIterator<Item = S>,
365        S: Into<String>,
366    {
367        self.tools = self.tools.with_allowed(names);
368        self
369    }
370
371    /// Disable the named tools from both listing and direct calls.
372    #[must_use]
373    pub fn with_disabled_tools<I, S>(mut self, names: I) -> Self
374    where
375        I: IntoIterator<Item = S>,
376        S: Into<String>,
377    {
378        self.tools = self.tools.with_disabled(names);
379        self
380    }
381
382    /// Hide the named tools from `tools/list` while still permitting direct calls.
383    #[must_use]
384    pub fn with_hidden_tools<I, S>(mut self, names: I) -> Self
385    where
386        I: IntoIterator<Item = S>,
387        S: Into<String>,
388    {
389        self.tools = self.tools.with_hidden(names);
390        self
391    }
392
393    /// Enable only the named resources. Names and URIs both match.
394    #[must_use]
395    pub fn with_allowed_resources<I, S>(mut self, identifiers: I) -> Self
396    where
397        I: IntoIterator<Item = S>,
398        S: Into<String>,
399    {
400        self.resources = self.resources.with_allowed(identifiers);
401        self
402    }
403
404    /// Disable the named resources from both listing and direct reads.
405    #[must_use]
406    pub fn with_disabled_resources<I, S>(mut self, identifiers: I) -> Self
407    where
408        I: IntoIterator<Item = S>,
409        S: Into<String>,
410    {
411        self.resources = self.resources.with_disabled(identifiers);
412        self
413    }
414
415    /// Hide the named resources from list responses while still permitting reads.
416    #[must_use]
417    pub fn with_hidden_resources<I, S>(mut self, identifiers: I) -> Self
418    where
419        I: IntoIterator<Item = S>,
420        S: Into<String>,
421    {
422        self.resources = self.resources.with_hidden(identifiers);
423        self
424    }
425
426    /// Enable only the named resource templates. Names and URI templates both match.
427    #[must_use]
428    pub fn with_allowed_resource_templates<I, S>(mut self, identifiers: I) -> Self
429    where
430        I: IntoIterator<Item = S>,
431        S: Into<String>,
432    {
433        self.resource_templates = self.resource_templates.with_allowed(identifiers);
434        self
435    }
436
437    /// Disable the named resource templates from list responses.
438    #[must_use]
439    pub fn with_disabled_resource_templates<I, S>(mut self, identifiers: I) -> Self
440    where
441        I: IntoIterator<Item = S>,
442        S: Into<String>,
443    {
444        self.resource_templates = self.resource_templates.with_disabled(identifiers);
445        self
446    }
447
448    /// Hide the named resource templates from list responses.
449    #[must_use]
450    pub fn with_hidden_resource_templates<I, S>(mut self, identifiers: I) -> Self
451    where
452        I: IntoIterator<Item = S>,
453        S: Into<String>,
454    {
455        self.resource_templates = self.resource_templates.with_hidden(identifiers);
456        self
457    }
458
459    /// Enable only the named prompts.
460    #[must_use]
461    pub fn with_allowed_prompts<I, S>(mut self, names: I) -> Self
462    where
463        I: IntoIterator<Item = S>,
464        S: Into<String>,
465    {
466        self.prompts = self.prompts.with_allowed(names);
467        self
468    }
469
470    /// Disable the named prompts from both listing and direct gets.
471    #[must_use]
472    pub fn with_disabled_prompts<I, S>(mut self, names: I) -> Self
473    where
474        I: IntoIterator<Item = S>,
475        S: Into<String>,
476    {
477        self.prompts = self.prompts.with_disabled(names);
478        self
479    }
480
481    /// Hide the named prompts from `prompts/list` while still permitting gets.
482    #[must_use]
483    pub fn with_hidden_prompts<I, S>(mut self, names: I) -> Self
484    where
485        I: IntoIterator<Item = S>,
486        S: Into<String>,
487    {
488        self.prompts = self.prompts.with_hidden(names);
489        self
490    }
491
492    /// Hide every tool that is not explicitly annotated read-only.
493    #[must_use]
494    pub fn require_read_only_tools(mut self) -> Self {
495        self.require_read_only_tools = true;
496        self
497    }
498}
499
500fn collect_names<I, S>(names: I) -> BTreeSet<String>
501where
502    I: IntoIterator<Item = S>,
503    S: Into<String>,
504{
505    names.into_iter().map(Into::into).collect()
506}
507
508fn is_false(value: &bool) -> bool {
509    !*value
510}
511
512fn is_explicit_read_only_tool(tool: &Tool) -> bool {
513    tool.annotations.as_ref().is_some_and(|annotations| {
514        annotations.read_only_hint == Some(true) && annotations.destructive_hint != Some(true)
515    })
516}
517
518/// RAII guard that automatically cleans up session visibility state when dropped.
519///
520/// This is the recommended way to manage session visibility lifetime. Create a guard
521/// at the start of a session and let it clean up automatically when the session ends.
522///
523/// # Example
524///
525/// ```rust,ignore
526/// use turbomcp_server::VisibilityLayer;
527///
528/// async fn handle_connection<H: McpHandler>(layer: &VisibilityLayer<H>, session_id: &str) {
529///     let _guard = layer.session_guard(session_id);
530///
531///     // Enable admin tools for this session
532///     layer.enable_for_session(session_id, &["admin".to_string()]);
533///
534///     // ... handle requests ...
535///
536/// } // State automatically cleaned up here
537/// ```
538#[derive(Debug)]
539pub struct VisibilitySessionGuard {
540    session_id: String,
541    session_enabled: SessionVisibilityMap,
542    session_disabled: SessionVisibilityMap,
543}
544
545impl VisibilitySessionGuard {
546    /// Get the session ID this guard is managing.
547    pub fn session_id(&self) -> &str {
548        &self.session_id
549    }
550}
551
552impl Drop for VisibilitySessionGuard {
553    fn drop(&mut self) {
554        self.session_enabled.remove(&self.session_id);
555        self.session_disabled.remove(&self.session_id);
556    }
557}
558
559/// A visibility layer that wraps an `McpHandler` and filters components.
560///
561/// This allows per-session control over which tools, resources, and prompts
562/// are visible to clients through the `list_*` methods.
563///
564/// **Warning**: Session overrides stored in this layer must be manually cleaned up
565/// via [`clear_session`](Self::clear_session) or by using a [`VisibilitySessionGuard`]
566/// to prevent unbounded memory growth.
567pub struct VisibilityLayer<H> {
568    /// The wrapped handler
569    inner: H,
570    /// Globally disabled component filters
571    global_disabled: Arc<RwLock<Vec<ComponentFilter>>>,
572    /// Exact-name visibility rules for tools
573    tool_rules: ComponentVisibilityRules,
574    /// Exact-name visibility rules for resources
575    resource_rules: ComponentVisibilityRules,
576    /// Exact-name visibility rules for resource templates
577    resource_template_rules: ComponentVisibilityRules,
578    /// Exact-name visibility rules for prompts
579    prompt_rules: ComponentVisibilityRules,
580    /// When true, only explicitly read-only tools are visible/callable
581    read_only_tools_required: bool,
582    /// Registry snapshot used to authorize direct dispatch without re-listing
583    component_registry: SharedComponentRegistry,
584    /// Session-specific visibility overrides (keyed by session_id)
585    ///
586    /// **Warning**: Entries must be manually cleaned up via [`clear_session`](Self::clear_session)
587    /// or [`session_guard`](Self::session_guard) to prevent unbounded memory growth.
588    session_enabled: SessionVisibilityMap,
589    session_disabled: SessionVisibilityMap,
590}
591
592impl<H: Clone> Clone for VisibilityLayer<H> {
593    fn clone(&self) -> Self {
594        Self {
595            inner: self.inner.clone(),
596            global_disabled: Arc::new(RwLock::new(self.global_disabled.read().clone())),
597            tool_rules: self.tool_rules.clone(),
598            resource_rules: self.resource_rules.clone(),
599            resource_template_rules: self.resource_template_rules.clone(),
600            prompt_rules: self.prompt_rules.clone(),
601            read_only_tools_required: self.read_only_tools_required,
602            component_registry: Arc::clone(&self.component_registry),
603            session_enabled: Arc::clone(&self.session_enabled),
604            session_disabled: Arc::clone(&self.session_disabled),
605        }
606    }
607}
608
609impl<H: std::fmt::Debug> std::fmt::Debug for VisibilityLayer<H> {
610    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611        f.debug_struct("VisibilityLayer")
612            .field("inner", &self.inner)
613            .field("global_disabled_count", &self.global_disabled.read().len())
614            .field(
615                "tool_allow_count",
616                &self.tool_rules.allow.as_ref().map(BTreeSet::len),
617            )
618            .field("tool_deny_count", &self.tool_rules.deny.len())
619            .field("tool_hide_count", &self.tool_rules.hide.len())
620            .field("read_only_tools_required", &self.read_only_tools_required)
621            .field(
622                "component_registry_initialized",
623                &self.component_registry.read().is_initialized(),
624            )
625            .field("session_enabled_count", &self.session_enabled.len())
626            .field("session_disabled_count", &self.session_disabled.len())
627            .finish()
628    }
629}
630
631impl<H: McpHandler> VisibilityLayer<H> {
632    /// Create a new visibility layer wrapping the given handler.
633    pub fn new(inner: H) -> Self {
634        Self {
635            inner,
636            global_disabled: Arc::new(RwLock::new(Vec::new())),
637            tool_rules: ComponentVisibilityRules::new(),
638            resource_rules: ComponentVisibilityRules::new(),
639            resource_template_rules: ComponentVisibilityRules::new(),
640            prompt_rules: ComponentVisibilityRules::new(),
641            read_only_tools_required: false,
642            component_registry: Arc::new(RwLock::new(ComponentRegistryCache::default())),
643            session_enabled: Arc::new(dashmap::DashMap::new()),
644            session_disabled: Arc::new(dashmap::DashMap::new()),
645        }
646    }
647
648    /// Disable components matching the filter globally.
649    ///
650    /// This affects all sessions unless explicitly enabled per-session.
651    #[must_use]
652    pub fn with_disabled(self, filter: ComponentFilter) -> Self {
653        self.global_disabled.write().push(filter);
654        self
655    }
656
657    /// Disable components with the given tags globally.
658    #[must_use]
659    pub fn disable_tags<I, S>(self, tags: I) -> Self
660    where
661        I: IntoIterator<Item = S>,
662        S: Into<String>,
663    {
664        self.with_disabled(ComponentFilter::with_tags(tags))
665    }
666
667    /// Replace exact-name rules with a complete visibility configuration.
668    ///
669    /// This is the easiest integration point for applications that expose
670    /// user-facing config. Tag/session visibility configured through
671    /// [`with_disabled`](Self::with_disabled) and
672    /// [`enable_for_session`](Self::enable_for_session) remains independent.
673    #[must_use]
674    pub fn with_visibility_config(mut self, config: VisibilityConfig) -> Self {
675        self.tool_rules = config.tools;
676        self.resource_rules = config.resources;
677        self.resource_template_rules = config.resource_templates;
678        self.prompt_rules = config.prompts;
679        self.read_only_tools_required = config.require_read_only_tools;
680        self
681    }
682
683    /// Return the currently configured exact-name visibility rules.
684    #[must_use]
685    pub fn visibility_config(&self) -> VisibilityConfig {
686        VisibilityConfig {
687            tools: self.tool_rules.clone(),
688            resources: self.resource_rules.clone(),
689            resource_templates: self.resource_template_rules.clone(),
690            prompts: self.prompt_rules.clone(),
691            require_read_only_tools: self.read_only_tools_required,
692        }
693    }
694
695    /// Refresh the component registry used to authorize direct dispatch.
696    ///
697    /// `VisibilityLayer` updates this registry when clients request list
698    /// responses and lazily initializes the relevant component family on first
699    /// direct use. Call this method when the wrapped handler's advertised
700    /// tools, resources, templates, or prompts change outside those flows.
701    pub fn refresh_component_registry(&self) {
702        let tools = self.inner.list_tools();
703        let resources = self.inner.list_resources();
704        let resource_templates = self.inner.list_resource_templates();
705        let prompts = self.inner.list_prompts();
706
707        let mut registry = self.component_registry.write();
708        registry.replace_tools(tools);
709        registry.replace_resources(resources);
710        registry.replace_resource_templates(resource_templates);
711        registry.replace_prompts(prompts);
712    }
713
714    /// Clear the cached component registry.
715    ///
716    /// The next direct dispatch check rebuilds the needed component family from
717    /// the wrapped handler before applying visibility policy. This is useful
718    /// for dynamic handlers that add or remove components at runtime.
719    pub fn clear_component_registry(&self) {
720        self.component_registry.write().clear();
721    }
722
723    /// Enable only the named tools.
724    ///
725    /// This filters both `tools/list` and `tools/call`. Exact denies configured
726    /// through [`with_disabled_tools`](Self::with_disabled_tools) still win.
727    #[must_use]
728    pub fn with_allowed_tools<I, S>(mut self, names: I) -> Self
729    where
730        I: IntoIterator<Item = S>,
731        S: Into<String>,
732    {
733        self.tool_rules = self.tool_rules.with_allowed(names);
734        self
735    }
736
737    /// Disable the named tools from `tools/list` and reject matching calls as not found.
738    #[must_use]
739    pub fn with_disabled_tools<I, S>(mut self, names: I) -> Self
740    where
741        I: IntoIterator<Item = S>,
742        S: Into<String>,
743    {
744        self.tool_rules = self.tool_rules.with_disabled(names);
745        self
746    }
747
748    /// Hide the named tools from `tools/list` while still permitting direct calls.
749    #[must_use]
750    pub fn with_hidden_tools<I, S>(mut self, names: I) -> Self
751    where
752        I: IntoIterator<Item = S>,
753        S: Into<String>,
754    {
755        self.tool_rules = self.tool_rules.with_hidden(names);
756        self
757    }
758
759    /// Enable only the named resources. Resource names and URIs both match.
760    #[must_use]
761    pub fn with_allowed_resources<I, S>(mut self, identifiers: I) -> Self
762    where
763        I: IntoIterator<Item = S>,
764        S: Into<String>,
765    {
766        self.resource_rules = self.resource_rules.with_allowed(identifiers);
767        self
768    }
769
770    /// Disable the named resources. Resource names and URIs both match.
771    #[must_use]
772    pub fn with_disabled_resources<I, S>(mut self, identifiers: I) -> Self
773    where
774        I: IntoIterator<Item = S>,
775        S: Into<String>,
776    {
777        self.resource_rules = self.resource_rules.with_disabled(identifiers);
778        self
779    }
780
781    /// Hide the named resources from list responses while still permitting reads.
782    #[must_use]
783    pub fn with_hidden_resources<I, S>(mut self, identifiers: I) -> Self
784    where
785        I: IntoIterator<Item = S>,
786        S: Into<String>,
787    {
788        self.resource_rules = self.resource_rules.with_hidden(identifiers);
789        self
790    }
791
792    /// Enable only the named resource templates. Names and URI templates both match.
793    #[must_use]
794    pub fn with_allowed_resource_templates<I, S>(mut self, identifiers: I) -> Self
795    where
796        I: IntoIterator<Item = S>,
797        S: Into<String>,
798    {
799        self.resource_template_rules = self.resource_template_rules.with_allowed(identifiers);
800        self
801    }
802
803    /// Disable the named resource templates. Names and URI templates both match.
804    #[must_use]
805    pub fn with_disabled_resource_templates<I, S>(mut self, identifiers: I) -> Self
806    where
807        I: IntoIterator<Item = S>,
808        S: Into<String>,
809    {
810        self.resource_template_rules = self.resource_template_rules.with_disabled(identifiers);
811        self
812    }
813
814    /// Hide the named resource templates from list responses.
815    #[must_use]
816    pub fn with_hidden_resource_templates<I, S>(mut self, identifiers: I) -> Self
817    where
818        I: IntoIterator<Item = S>,
819        S: Into<String>,
820    {
821        self.resource_template_rules = self.resource_template_rules.with_hidden(identifiers);
822        self
823    }
824
825    /// Enable only the named prompts.
826    #[must_use]
827    pub fn with_allowed_prompts<I, S>(mut self, names: I) -> Self
828    where
829        I: IntoIterator<Item = S>,
830        S: Into<String>,
831    {
832        self.prompt_rules = self.prompt_rules.with_allowed(names);
833        self
834    }
835
836    /// Disable the named prompts.
837    #[must_use]
838    pub fn with_disabled_prompts<I, S>(mut self, names: I) -> Self
839    where
840        I: IntoIterator<Item = S>,
841        S: Into<String>,
842    {
843        self.prompt_rules = self.prompt_rules.with_disabled(names);
844        self
845    }
846
847    /// Hide the named prompts from `prompts/list` while still permitting gets.
848    #[must_use]
849    pub fn with_hidden_prompts<I, S>(mut self, names: I) -> Self
850    where
851        I: IntoIterator<Item = S>,
852        S: Into<String>,
853    {
854        self.prompt_rules = self.prompt_rules.with_hidden(names);
855        self
856    }
857
858    /// Hide every tool that is not explicitly annotated `readOnlyHint: true`.
859    ///
860    /// This is useful for AI clients that should not be offered mutating
861    /// operations. Tools with no annotation are hidden; annotation gaps should
862    /// fail closed.
863    #[must_use]
864    pub fn require_read_only_tools(mut self) -> Self {
865        self.read_only_tools_required = true;
866        self
867    }
868
869    fn register_tools(&self, tools: Vec<Tool>) {
870        self.component_registry.write().replace_tools(tools);
871    }
872
873    fn register_resources(&self, resources: Vec<Resource>) {
874        self.component_registry.write().replace_resources(resources);
875    }
876
877    fn register_resource_templates(&self, templates: Vec<ResourceTemplate>) {
878        self.component_registry
879            .write()
880            .replace_resource_templates(templates);
881    }
882
883    fn register_prompts(&self, prompts: Vec<Prompt>) {
884        self.component_registry.write().replace_prompts(prompts);
885    }
886
887    fn registered_tool(&self, name: &str) -> Option<Tool> {
888        let lookup = { self.component_registry.read().tool(name) };
889        match lookup {
890            RegistryLookup::Found(tool) => return Some(tool),
891            RegistryLookup::Missing => return None,
892            RegistryLookup::Uninitialized => {}
893        }
894
895        let tools = self.inner.list_tools();
896        let tool = tools.iter().find(|tool| tool.name == name).cloned();
897        self.register_tools(tools);
898        tool
899    }
900
901    fn registered_resource(&self, uri: &str) -> Option<Resource> {
902        let lookup = { self.component_registry.read().resource_by_uri(uri) };
903        match lookup {
904            RegistryLookup::Found(resource) => return Some(resource),
905            RegistryLookup::Missing => return None,
906            RegistryLookup::Uninitialized => {}
907        }
908
909        let resources = self.inner.list_resources();
910        let resource = resources
911            .iter()
912            .find(|resource| resource.uri == uri)
913            .cloned();
914        self.register_resources(resources);
915        resource
916    }
917
918    fn registered_prompt(&self, name: &str) -> Option<Prompt> {
919        let lookup = { self.component_registry.read().prompt(name) };
920        match lookup {
921            RegistryLookup::Found(prompt) => return Some(prompt),
922            RegistryLookup::Missing => return None,
923            RegistryLookup::Uninitialized => {}
924        }
925
926        let prompts = self.inner.list_prompts();
927        let prompt = prompts.iter().find(|prompt| prompt.name == name).cloned();
928        self.register_prompts(prompts);
929        prompt
930    }
931
932    /// Check if a component is visible given its metadata and session.
933    fn is_visible(&self, meta: &ComponentMeta, session_id: Option<&str>) -> bool {
934        // Check global disabled filters
935        let global_disabled = self.global_disabled.read();
936        let globally_hidden = global_disabled.iter().any(|filter| filter.matches(meta));
937
938        if !globally_hidden {
939            // Not globally hidden - check if session explicitly disabled it
940            if let Some(sid) = session_id
941                && let Some(disabled) = self.session_disabled.get(sid)
942                && meta.tags.iter().any(|t| disabled.contains(t))
943            {
944                return false;
945            }
946            return true;
947        }
948
949        // Globally hidden - check if session explicitly enabled it
950        if let Some(sid) = session_id
951            && let Some(enabled) = self.session_enabled.get(sid)
952            && meta.tags.iter().any(|t| enabled.contains(t))
953        {
954            return true;
955        }
956
957        false
958    }
959
960    /// Check if a tool is enabled/callable under exact-name, annotation, tag, and session rules.
961    fn is_tool_enabled(&self, tool: &Tool, session_id: Option<&str>) -> bool {
962        if !self.tool_rules.is_enabled(&tool.name) {
963            return false;
964        }
965
966        if self.read_only_tools_required && !is_explicit_read_only_tool(tool) {
967            return false;
968        }
969
970        let meta = ComponentMeta::from_meta_value(tool.meta.as_ref());
971        self.is_visible(&meta, session_id)
972    }
973
974    /// Check if a tool is listed under exact-name, annotation, tag, and session rules.
975    fn is_tool_listed(&self, tool: &Tool, session_id: Option<&str>) -> bool {
976        self.is_tool_enabled(tool, session_id) && self.tool_rules.is_listed(&tool.name)
977    }
978
979    /// Check if an unregistered dynamic tool may be called.
980    fn is_unregistered_tool_callable(&self, name: &str) -> bool {
981        self.tool_rules.is_enabled(name) && !self.read_only_tools_required
982    }
983
984    /// Check if a resource is enabled/readable under exact-name, tag, and session rules.
985    fn is_resource_enabled(&self, resource: &Resource, session_id: Option<&str>) -> bool {
986        if !self
987            .resource_rules
988            .is_enabled_any([resource.name.as_str(), resource.uri.as_str()])
989        {
990            return false;
991        }
992
993        let meta = ComponentMeta::from_meta_value(resource.meta.as_ref());
994        self.is_visible(&meta, session_id)
995    }
996
997    /// Check if a resource is listed under exact-name, tag, and session rules.
998    fn is_resource_listed(&self, resource: &Resource, session_id: Option<&str>) -> bool {
999        self.is_resource_enabled(resource, session_id)
1000            && self
1001                .resource_rules
1002                .is_listed_any([resource.name.as_str(), resource.uri.as_str()])
1003    }
1004
1005    /// Check exact-name visibility for a resource read when no registered metadata is available.
1006    fn is_unregistered_resource_readable(&self, uri: &str) -> bool {
1007        self.resource_rules.is_enabled(uri)
1008    }
1009
1010    /// Check if a resource template is listed under exact-name, tag, and session rules.
1011    fn is_resource_template_listed(
1012        &self,
1013        template: &ResourceTemplate,
1014        session_id: Option<&str>,
1015    ) -> bool {
1016        if !self
1017            .resource_template_rules
1018            .is_listed_any([template.name.as_str(), template.uri_template.as_str()])
1019        {
1020            return false;
1021        }
1022
1023        let meta = ComponentMeta::from_meta_value(template.meta.as_ref());
1024        self.is_visible(&meta, session_id)
1025    }
1026
1027    /// Check if a prompt is enabled/gettable under exact-name, tag, and session rules.
1028    fn is_prompt_enabled(&self, prompt: &Prompt, session_id: Option<&str>) -> bool {
1029        if !self.prompt_rules.is_enabled(&prompt.name) {
1030            return false;
1031        }
1032
1033        let meta = ComponentMeta::from_meta_value(prompt.meta.as_ref());
1034        self.is_visible(&meta, session_id)
1035    }
1036
1037    /// Check if a prompt is listed under exact-name, tag, and session rules.
1038    fn is_prompt_listed(&self, prompt: &Prompt, session_id: Option<&str>) -> bool {
1039        self.is_prompt_enabled(prompt, session_id) && self.prompt_rules.is_listed(&prompt.name)
1040    }
1041
1042    /// Check exact-name visibility for a prompt get when no registered metadata is available.
1043    fn is_unregistered_prompt_gettable(&self, name: &str) -> bool {
1044        self.prompt_rules.is_enabled(name)
1045    }
1046
1047    /// Enable components with the given tags for a specific session.
1048    pub fn enable_for_session(&self, session_id: &str, tags: &[String]) {
1049        let mut entry = self
1050            .session_enabled
1051            .entry(session_id.to_string())
1052            .or_default();
1053        entry.extend(tags.iter().cloned());
1054
1055        // Remove from disabled if present
1056        if let Some(mut disabled) = self.session_disabled.get_mut(session_id) {
1057            for tag in tags {
1058                disabled.remove(tag);
1059            }
1060        }
1061    }
1062
1063    /// Disable components with the given tags for a specific session.
1064    pub fn disable_for_session(&self, session_id: &str, tags: &[String]) {
1065        let mut entry = self
1066            .session_disabled
1067            .entry(session_id.to_string())
1068            .or_default();
1069        entry.extend(tags.iter().cloned());
1070
1071        // Remove from enabled if present
1072        if let Some(mut enabled) = self.session_enabled.get_mut(session_id) {
1073            for tag in tags {
1074                enabled.remove(tag);
1075            }
1076        }
1077    }
1078
1079    /// Clear all session-specific overrides.
1080    pub fn clear_session(&self, session_id: &str) {
1081        self.session_enabled.remove(session_id);
1082        self.session_disabled.remove(session_id);
1083    }
1084
1085    /// Create an RAII guard that automatically cleans up session state on drop.
1086    ///
1087    /// This is the recommended way to manage session visibility lifetime.
1088    ///
1089    /// # Example
1090    ///
1091    /// ```rust,ignore
1092    /// async fn handle_connection<H: McpHandler>(layer: &VisibilityLayer<H>, session_id: &str) {
1093    ///     let _guard = layer.session_guard(session_id);
1094    ///
1095    ///     layer.enable_for_session(session_id, &["admin".to_string()]);
1096    ///
1097    ///     // ... handle requests ...
1098    ///
1099    /// } // State automatically cleaned up here
1100    /// ```
1101    pub fn session_guard(&self, session_id: impl Into<String>) -> VisibilitySessionGuard {
1102        VisibilitySessionGuard {
1103            session_id: session_id.into(),
1104            session_enabled: Arc::clone(&self.session_enabled),
1105            session_disabled: Arc::clone(&self.session_disabled),
1106        }
1107    }
1108
1109    /// Get the number of active sessions with visibility overrides.
1110    ///
1111    /// This is useful for monitoring memory usage.
1112    pub fn active_sessions_count(&self) -> usize {
1113        // Count unique session IDs across both maps
1114        let mut sessions = HashSet::new();
1115        for entry in self.session_enabled.iter() {
1116            sessions.insert(entry.key().clone());
1117        }
1118        for entry in self.session_disabled.iter() {
1119            sessions.insert(entry.key().clone());
1120        }
1121        sessions.len()
1122    }
1123
1124    /// Get a reference to the inner handler.
1125    pub fn inner(&self) -> &H {
1126        &self.inner
1127    }
1128
1129    /// Get a mutable reference to the inner handler.
1130    pub fn inner_mut(&mut self) -> &mut H {
1131        self.clear_component_registry();
1132        &mut self.inner
1133    }
1134
1135    /// Unwrap the layer and return the inner handler.
1136    pub fn into_inner(self) -> H {
1137        self.inner
1138    }
1139}
1140
1141#[allow(clippy::manual_async_fn)]
1142impl<H: McpHandler> McpHandler for VisibilityLayer<H> {
1143    fn server_info(&self) -> turbomcp_types::ServerInfo {
1144        self.inner.server_info()
1145    }
1146
1147    fn server_capabilities(&self) -> turbomcp_types::ServerCapabilities {
1148        self.inner.server_capabilities()
1149    }
1150
1151    fn list_tools(&self) -> Vec<Tool> {
1152        let tools = self.inner.list_tools();
1153        self.register_tools(tools.clone());
1154
1155        tools
1156            .into_iter()
1157            .filter(|tool| self.is_tool_listed(tool, None))
1158            .collect()
1159    }
1160
1161    fn list_resources(&self) -> Vec<Resource> {
1162        let resources = self.inner.list_resources();
1163        self.register_resources(resources.clone());
1164
1165        resources
1166            .into_iter()
1167            .filter(|resource| self.is_resource_listed(resource, None))
1168            .collect()
1169    }
1170
1171    fn list_resource_templates(&self) -> Vec<ResourceTemplate> {
1172        let templates = self.inner.list_resource_templates();
1173        self.register_resource_templates(templates.clone());
1174
1175        templates
1176            .into_iter()
1177            .filter(|template| self.is_resource_template_listed(template, None))
1178            .collect()
1179    }
1180
1181    fn list_prompts(&self) -> Vec<Prompt> {
1182        let prompts = self.inner.list_prompts();
1183        self.register_prompts(prompts.clone());
1184
1185        prompts
1186            .into_iter()
1187            .filter(|prompt| self.is_prompt_listed(prompt, None))
1188            .collect()
1189    }
1190
1191    fn call_tool<'a>(
1192        &'a self,
1193        name: &'a str,
1194        args: serde_json::Value,
1195        ctx: &'a RequestContext,
1196    ) -> impl std::future::Future<Output = McpResult<ToolResult>> + turbomcp_core::marker::MaybeSend + 'a
1197    {
1198        async move {
1199            // Check registered tool metadata when available; unregistered
1200            // dynamic tools can still be governed by exact-name rules.
1201            if let Some(tool) = self.registered_tool(name) {
1202                if !self.is_tool_enabled(&tool, ctx.session_id()) {
1203                    return Err(McpError::tool_not_found(name));
1204                }
1205            } else if !self.is_unregistered_tool_callable(name) {
1206                return Err(McpError::tool_not_found(name));
1207            }
1208
1209            self.inner.call_tool(name, args, ctx).await
1210        }
1211    }
1212
1213    fn read_resource<'a>(
1214        &'a self,
1215        uri: &'a str,
1216        ctx: &'a RequestContext,
1217    ) -> impl std::future::Future<Output = McpResult<ResourceResult>>
1218    + turbomcp_core::marker::MaybeSend
1219    + 'a {
1220        async move {
1221            // Check registered resource metadata when available; unregistered
1222            // dynamic resources can still be governed by exact-name rules.
1223            if let Some(resource) = self.registered_resource(uri) {
1224                if !self.is_resource_enabled(&resource, ctx.session_id()) {
1225                    return Err(McpError::resource_not_found(uri));
1226                }
1227            } else if !self.is_unregistered_resource_readable(uri) {
1228                return Err(McpError::resource_not_found(uri));
1229            }
1230
1231            self.inner.read_resource(uri, ctx).await
1232        }
1233    }
1234
1235    fn get_prompt<'a>(
1236        &'a self,
1237        name: &'a str,
1238        args: Option<serde_json::Value>,
1239        ctx: &'a RequestContext,
1240    ) -> impl std::future::Future<Output = McpResult<PromptResult>> + turbomcp_core::marker::MaybeSend + 'a
1241    {
1242        async move {
1243            // Check registered prompt metadata when available; unregistered
1244            // dynamic prompts can still be governed by exact-name rules.
1245            if let Some(prompt) = self.registered_prompt(name) {
1246                if !self.is_prompt_enabled(&prompt, ctx.session_id()) {
1247                    return Err(McpError::prompt_not_found(name));
1248                }
1249            } else if !self.is_unregistered_prompt_gettable(name) {
1250                return Err(McpError::prompt_not_found(name));
1251            }
1252
1253            self.inner.get_prompt(name, args, ctx).await
1254        }
1255    }
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260    use super::*;
1261    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1262    use turbomcp_types::ToolAnnotations;
1263
1264    #[derive(Clone, Debug)]
1265    struct MockHandler;
1266
1267    #[allow(clippy::manual_async_fn)]
1268    impl McpHandler for MockHandler {
1269        fn server_info(&self) -> turbomcp_types::ServerInfo {
1270            turbomcp_types::ServerInfo::new("test", "1.0.0")
1271        }
1272
1273        fn list_tools(&self) -> Vec<Tool> {
1274            vec![
1275                Tool {
1276                    name: "public_tool".to_string(),
1277                    description: Some("Public tool".to_string()),
1278                    annotations: Some(ToolAnnotations::default().with_read_only(true)),
1279                    meta: Some({
1280                        let mut m = std::collections::HashMap::new();
1281                        m.insert("tags".to_string(), serde_json::json!(["public"]));
1282                        m
1283                    }),
1284                    ..Default::default()
1285                },
1286                Tool {
1287                    name: "admin_tool".to_string(),
1288                    description: Some("Admin tool".to_string()),
1289                    annotations: Some(ToolAnnotations::default().with_destructive(true)),
1290                    meta: Some({
1291                        let mut m = std::collections::HashMap::new();
1292                        m.insert("tags".to_string(), serde_json::json!(["admin"]));
1293                        m
1294                    }),
1295                    ..Default::default()
1296                },
1297            ]
1298        }
1299
1300        fn list_resources(&self) -> Vec<Resource> {
1301            vec![
1302                Resource {
1303                    uri: "vault://public".to_string(),
1304                    name: "public_resource".to_string(),
1305                    meta: Some({
1306                        let mut m = std::collections::HashMap::new();
1307                        m.insert("tags".to_string(), serde_json::json!(["public"]));
1308                        m
1309                    }),
1310                    ..Default::default()
1311                },
1312                Resource {
1313                    uri: "vault://admin".to_string(),
1314                    name: "admin_resource".to_string(),
1315                    meta: Some({
1316                        let mut m = std::collections::HashMap::new();
1317                        m.insert("tags".to_string(), serde_json::json!(["admin"]));
1318                        m
1319                    }),
1320                    ..Default::default()
1321                },
1322            ]
1323        }
1324
1325        fn list_resource_templates(&self) -> Vec<ResourceTemplate> {
1326            vec![ResourceTemplate {
1327                uri_template: "vault://notes/{id}".to_string(),
1328                name: "note_template".to_string(),
1329                meta: Some({
1330                    let mut m = std::collections::HashMap::new();
1331                    m.insert("tags".to_string(), serde_json::json!(["public"]));
1332                    m
1333                }),
1334                ..Default::default()
1335            }]
1336        }
1337
1338        fn list_prompts(&self) -> Vec<Prompt> {
1339            vec![
1340                Prompt {
1341                    name: "public_prompt".to_string(),
1342                    meta: Some({
1343                        let mut m = std::collections::HashMap::new();
1344                        m.insert("tags".to_string(), serde_json::json!(["public"]));
1345                        m
1346                    }),
1347                    ..Default::default()
1348                },
1349                Prompt {
1350                    name: "admin_prompt".to_string(),
1351                    meta: Some({
1352                        let mut m = std::collections::HashMap::new();
1353                        m.insert("tags".to_string(), serde_json::json!(["admin"]));
1354                        m
1355                    }),
1356                    ..Default::default()
1357                },
1358            ]
1359        }
1360
1361        fn call_tool<'a>(
1362            &'a self,
1363            name: &'a str,
1364            _args: serde_json::Value,
1365            _ctx: &'a RequestContext,
1366        ) -> impl std::future::Future<Output = McpResult<ToolResult>>
1367        + turbomcp_core::marker::MaybeSend
1368        + 'a {
1369            async move { Ok(ToolResult::text(format!("Called {}", name))) }
1370        }
1371
1372        fn read_resource<'a>(
1373            &'a self,
1374            uri: &'a str,
1375            _ctx: &'a RequestContext,
1376        ) -> impl std::future::Future<Output = McpResult<ResourceResult>>
1377        + turbomcp_core::marker::MaybeSend
1378        + 'a {
1379            async move { Ok(ResourceResult::text(uri, format!("Read {}", uri))) }
1380        }
1381
1382        fn get_prompt<'a>(
1383            &'a self,
1384            name: &'a str,
1385            _args: Option<serde_json::Value>,
1386            _ctx: &'a RequestContext,
1387        ) -> impl std::future::Future<Output = McpResult<PromptResult>>
1388        + turbomcp_core::marker::MaybeSend
1389        + 'a {
1390            async move { Ok(PromptResult::user(format!("Prompt {}", name))) }
1391        }
1392    }
1393
1394    #[derive(Clone, Debug)]
1395    struct DynamicHandler;
1396
1397    #[allow(clippy::manual_async_fn)]
1398    impl McpHandler for DynamicHandler {
1399        fn server_info(&self) -> turbomcp_types::ServerInfo {
1400            turbomcp_types::ServerInfo::new("dynamic", "1.0.0")
1401        }
1402
1403        fn list_tools(&self) -> Vec<Tool> {
1404            vec![]
1405        }
1406
1407        fn list_resources(&self) -> Vec<Resource> {
1408            vec![]
1409        }
1410
1411        fn list_prompts(&self) -> Vec<Prompt> {
1412            vec![]
1413        }
1414
1415        fn call_tool<'a>(
1416            &'a self,
1417            name: &'a str,
1418            _args: serde_json::Value,
1419            _ctx: &'a RequestContext,
1420        ) -> impl std::future::Future<Output = McpResult<ToolResult>>
1421        + turbomcp_core::marker::MaybeSend
1422        + 'a {
1423            async move { Ok(ToolResult::text(format!("Dynamic {}", name))) }
1424        }
1425
1426        fn read_resource<'a>(
1427            &'a self,
1428            uri: &'a str,
1429            _ctx: &'a RequestContext,
1430        ) -> impl std::future::Future<Output = McpResult<ResourceResult>>
1431        + turbomcp_core::marker::MaybeSend
1432        + 'a {
1433            async move { Ok(ResourceResult::text(uri, format!("Dynamic {}", uri))) }
1434        }
1435
1436        fn get_prompt<'a>(
1437            &'a self,
1438            name: &'a str,
1439            _args: Option<serde_json::Value>,
1440            _ctx: &'a RequestContext,
1441        ) -> impl std::future::Future<Output = McpResult<PromptResult>>
1442        + turbomcp_core::marker::MaybeSend
1443        + 'a {
1444            async move { Ok(PromptResult::user(format!("Dynamic {}", name))) }
1445        }
1446    }
1447
1448    #[derive(Debug, Default)]
1449    struct CountingState {
1450        tool_lists: AtomicUsize,
1451        resource_lists: AtomicUsize,
1452        prompt_lists: AtomicUsize,
1453    }
1454
1455    #[derive(Clone, Debug, Default)]
1456    struct CountingHandler {
1457        state: Arc<CountingState>,
1458    }
1459
1460    #[allow(clippy::manual_async_fn)]
1461    impl McpHandler for CountingHandler {
1462        fn server_info(&self) -> turbomcp_types::ServerInfo {
1463            turbomcp_types::ServerInfo::new("counting", "1.0.0")
1464        }
1465
1466        fn list_tools(&self) -> Vec<Tool> {
1467            self.state.tool_lists.fetch_add(1, Ordering::SeqCst);
1468            vec![Tool {
1469                name: "counted_tool".to_string(),
1470                annotations: Some(ToolAnnotations::default().with_read_only(true)),
1471                ..Default::default()
1472            }]
1473        }
1474
1475        fn list_resources(&self) -> Vec<Resource> {
1476            self.state.resource_lists.fetch_add(1, Ordering::SeqCst);
1477            vec![Resource::new("counted://resource", "counted_resource")]
1478        }
1479
1480        fn list_prompts(&self) -> Vec<Prompt> {
1481            self.state.prompt_lists.fetch_add(1, Ordering::SeqCst);
1482            vec![Prompt::new("counted_prompt", "Counted prompt")]
1483        }
1484
1485        fn call_tool<'a>(
1486            &'a self,
1487            name: &'a str,
1488            _args: serde_json::Value,
1489            _ctx: &'a RequestContext,
1490        ) -> impl std::future::Future<Output = McpResult<ToolResult>>
1491        + turbomcp_core::marker::MaybeSend
1492        + 'a {
1493            async move { Ok(ToolResult::text(format!("Called {}", name))) }
1494        }
1495
1496        fn read_resource<'a>(
1497            &'a self,
1498            uri: &'a str,
1499            _ctx: &'a RequestContext,
1500        ) -> impl std::future::Future<Output = McpResult<ResourceResult>>
1501        + turbomcp_core::marker::MaybeSend
1502        + 'a {
1503            async move { Ok(ResourceResult::text(uri, format!("Read {}", uri))) }
1504        }
1505
1506        fn get_prompt<'a>(
1507            &'a self,
1508            name: &'a str,
1509            _args: Option<serde_json::Value>,
1510            _ctx: &'a RequestContext,
1511        ) -> impl std::future::Future<Output = McpResult<PromptResult>>
1512        + turbomcp_core::marker::MaybeSend
1513        + 'a {
1514            async move { Ok(PromptResult::user(format!("Prompt {}", name))) }
1515        }
1516    }
1517
1518    #[derive(Clone, Debug)]
1519    struct MutableToolHandler {
1520        read_only: Arc<AtomicBool>,
1521    }
1522
1523    #[allow(clippy::manual_async_fn)]
1524    impl McpHandler for MutableToolHandler {
1525        fn server_info(&self) -> turbomcp_types::ServerInfo {
1526            turbomcp_types::ServerInfo::new("mutable", "1.0.0")
1527        }
1528
1529        fn list_tools(&self) -> Vec<Tool> {
1530            let annotation = if self.read_only.load(Ordering::SeqCst) {
1531                ToolAnnotations::default().with_read_only(true)
1532            } else {
1533                ToolAnnotations::default().with_destructive(true)
1534            };
1535
1536            vec![Tool {
1537                name: "mutable_tool".to_string(),
1538                annotations: Some(annotation),
1539                ..Default::default()
1540            }]
1541        }
1542
1543        fn list_resources(&self) -> Vec<Resource> {
1544            Vec::new()
1545        }
1546
1547        fn list_prompts(&self) -> Vec<Prompt> {
1548            Vec::new()
1549        }
1550
1551        fn call_tool<'a>(
1552            &'a self,
1553            name: &'a str,
1554            _args: serde_json::Value,
1555            _ctx: &'a RequestContext,
1556        ) -> impl std::future::Future<Output = McpResult<ToolResult>>
1557        + turbomcp_core::marker::MaybeSend
1558        + 'a {
1559            async move { Ok(ToolResult::text(format!("Called {}", name))) }
1560        }
1561
1562        fn read_resource<'a>(
1563            &'a self,
1564            uri: &'a str,
1565            _ctx: &'a RequestContext,
1566        ) -> impl std::future::Future<Output = McpResult<ResourceResult>>
1567        + turbomcp_core::marker::MaybeSend
1568        + 'a {
1569            async move { Err(McpError::resource_not_found(uri)) }
1570        }
1571
1572        fn get_prompt<'a>(
1573            &'a self,
1574            name: &'a str,
1575            _args: Option<serde_json::Value>,
1576            _ctx: &'a RequestContext,
1577        ) -> impl std::future::Future<Output = McpResult<PromptResult>>
1578        + turbomcp_core::marker::MaybeSend
1579        + 'a {
1580            async move { Err(McpError::prompt_not_found(name)) }
1581        }
1582    }
1583
1584    #[derive(Clone, Debug)]
1585    struct DuplicateToolMetadataHandler;
1586
1587    #[allow(clippy::manual_async_fn)]
1588    impl McpHandler for DuplicateToolMetadataHandler {
1589        fn server_info(&self) -> turbomcp_types::ServerInfo {
1590            turbomcp_types::ServerInfo::new("duplicates", "1.0.0")
1591        }
1592
1593        fn list_tools(&self) -> Vec<Tool> {
1594            vec![
1595                Tool {
1596                    name: "duplicate_tool".to_string(),
1597                    annotations: Some(ToolAnnotations::default().with_read_only(true)),
1598                    ..Default::default()
1599                },
1600                Tool {
1601                    name: "duplicate_tool".to_string(),
1602                    annotations: Some(ToolAnnotations::default().with_destructive(true)),
1603                    ..Default::default()
1604                },
1605            ]
1606        }
1607
1608        fn list_resources(&self) -> Vec<Resource> {
1609            Vec::new()
1610        }
1611
1612        fn list_prompts(&self) -> Vec<Prompt> {
1613            Vec::new()
1614        }
1615
1616        fn call_tool<'a>(
1617            &'a self,
1618            name: &'a str,
1619            _args: serde_json::Value,
1620            _ctx: &'a RequestContext,
1621        ) -> impl std::future::Future<Output = McpResult<ToolResult>>
1622        + turbomcp_core::marker::MaybeSend
1623        + 'a {
1624            async move { Ok(ToolResult::text(format!("Called {}", name))) }
1625        }
1626
1627        fn read_resource<'a>(
1628            &'a self,
1629            uri: &'a str,
1630            _ctx: &'a RequestContext,
1631        ) -> impl std::future::Future<Output = McpResult<ResourceResult>>
1632        + turbomcp_core::marker::MaybeSend
1633        + 'a {
1634            async move { Err(McpError::resource_not_found(uri)) }
1635        }
1636
1637        fn get_prompt<'a>(
1638            &'a self,
1639            name: &'a str,
1640            _args: Option<serde_json::Value>,
1641            _ctx: &'a RequestContext,
1642        ) -> impl std::future::Future<Output = McpResult<PromptResult>>
1643        + turbomcp_core::marker::MaybeSend
1644        + 'a {
1645            async move { Err(McpError::prompt_not_found(name)) }
1646        }
1647    }
1648
1649    fn tool_names<H: McpHandler>(layer: &VisibilityLayer<H>) -> Vec<String> {
1650        layer
1651            .list_tools()
1652            .into_iter()
1653            .map(|tool| tool.name)
1654            .collect()
1655    }
1656
1657    #[test]
1658    fn test_component_visibility_rules_deny_wins() {
1659        let rules = ComponentVisibilityRules::allow(["search", "delete"]).with_disabled(["delete"]);
1660
1661        assert!(rules.is_enabled("search"));
1662        assert!(rules.is_listed("search"));
1663        assert!(!rules.is_enabled("delete"));
1664        assert!(!rules.is_listed("delete"));
1665        assert!(!rules.is_enabled("unknown"));
1666    }
1667
1668    #[test]
1669    fn test_component_visibility_rules_match_aliases() {
1670        let rules = ComponentVisibilityRules::allow(["vault://public"]);
1671
1672        assert!(rules.is_enabled_any(["public_resource", "vault://public"]));
1673        assert!(!rules.is_enabled_any(["public_resource", "vault://private"]));
1674    }
1675
1676    #[test]
1677    fn test_component_visibility_rules_can_hide_without_disabling() {
1678        let rules = ComponentVisibilityRules::hide(["advanced_tool"]);
1679
1680        assert!(rules.is_enabled("advanced_tool"));
1681        assert!(!rules.is_listed("advanced_tool"));
1682        assert!(rules.is_enabled("public_tool"));
1683        assert!(rules.is_listed("public_tool"));
1684    }
1685
1686    #[test]
1687    fn test_visibility_config_round_trips_serialization() {
1688        let config = VisibilityConfig::new()
1689            .with_allowed_tools(["search", "read_note"])
1690            .with_disabled_tools(["delete_note"])
1691            .with_hidden_tools(["advanced_graph"])
1692            .with_allowed_resources(["vault://public"])
1693            .with_allowed_prompts(["summarize"])
1694            .require_read_only_tools();
1695
1696        let json = serde_json::to_string(&config).expect("visibility config serializes");
1697        let decoded: VisibilityConfig =
1698            serde_json::from_str(&json).expect("visibility config deserializes");
1699
1700        assert_eq!(decoded, config);
1701    }
1702
1703    #[test]
1704    fn test_empty_tool_allowlist_hides_all_tools() {
1705        let layer =
1706            VisibilityLayer::new(MockHandler).with_allowed_tools(std::iter::empty::<&str>());
1707
1708        assert!(layer.list_tools().is_empty());
1709    }
1710
1711    #[test]
1712    fn test_conflicting_read_only_and_destructive_hints_fail_closed() {
1713        let tool = Tool {
1714            name: "conflicting_tool".to_string(),
1715            annotations: Some(
1716                ToolAnnotations::default()
1717                    .with_read_only(true)
1718                    .with_destructive(true),
1719            ),
1720            ..Default::default()
1721        };
1722
1723        assert!(!is_explicit_read_only_tool(&tool));
1724    }
1725
1726    #[test]
1727    fn test_visibility_layer_hides_admin() {
1728        let layer = VisibilityLayer::new(MockHandler).disable_tags(["admin"]);
1729
1730        let tools = layer.list_tools();
1731        assert_eq!(tools.len(), 1);
1732        assert_eq!(tools[0].name, "public_tool");
1733    }
1734
1735    #[test]
1736    fn test_visibility_layer_shows_all_by_default() {
1737        let layer = VisibilityLayer::new(MockHandler);
1738
1739        let tools = layer.list_tools();
1740        assert_eq!(tools.len(), 2);
1741    }
1742
1743    #[test]
1744    fn test_exact_tool_allowlist_reduces_list_surface() {
1745        let layer = VisibilityLayer::new(MockHandler).with_allowed_tools(["public_tool"]);
1746
1747        assert_eq!(tool_names(&layer), vec!["public_tool"]);
1748    }
1749
1750    #[test]
1751    fn test_exact_tool_denylist_wins_over_allowlist() {
1752        let layer = VisibilityLayer::new(MockHandler)
1753            .with_allowed_tools(["public_tool", "admin_tool"])
1754            .with_disabled_tools(["public_tool"]);
1755
1756        assert_eq!(tool_names(&layer), vec!["admin_tool"]);
1757    }
1758
1759    #[test]
1760    fn test_layer_clone_has_independent_exact_rules() {
1761        let base = VisibilityLayer::new(MockHandler);
1762        let narrowed = base.clone().with_allowed_tools(["public_tool"]);
1763
1764        assert_eq!(base.list_tools().len(), 2);
1765        assert_eq!(tool_names(&narrowed), vec!["public_tool"]);
1766    }
1767
1768    #[test]
1769    fn test_layer_clone_has_independent_tag_filters() {
1770        let base = VisibilityLayer::new(MockHandler);
1771        let narrowed = base.clone().disable_tags(["admin"]);
1772
1773        assert_eq!(base.list_tools().len(), 2);
1774        assert_eq!(tool_names(&narrowed), vec!["public_tool"]);
1775    }
1776
1777    #[test]
1778    fn test_with_disabled_tools_replaces_previous_denylist() {
1779        let layer = VisibilityLayer::new(MockHandler)
1780            .with_disabled_tools(["public_tool"])
1781            .with_disabled_tools(["admin_tool"]);
1782
1783        assert_eq!(tool_names(&layer), vec!["public_tool"]);
1784    }
1785
1786    #[tokio::test]
1787    async fn test_hidden_tool_is_not_listed_but_remains_callable() {
1788        let layer = VisibilityLayer::new(MockHandler).with_hidden_tools(["public_tool"]);
1789        let ctx = RequestContext::default();
1790
1791        assert_eq!(tool_names(&layer), vec!["admin_tool"]);
1792
1793        let result = layer
1794            .call_tool("public_tool", serde_json::json!({}), &ctx)
1795            .await
1796            .expect("hidden but enabled tool should remain callable");
1797        assert_eq!(result.first_text(), Some("Called public_tool"));
1798    }
1799
1800    #[tokio::test]
1801    async fn test_hidden_resource_and_prompt_are_not_listed_but_remain_callable() {
1802        let layer = VisibilityLayer::new(MockHandler)
1803            .with_hidden_resources(["vault://public"])
1804            .with_hidden_prompts(["public_prompt"]);
1805        let ctx = RequestContext::default();
1806
1807        assert_eq!(layer.list_resources().len(), 1);
1808        assert_eq!(layer.list_prompts().len(), 1);
1809
1810        let resource = layer
1811            .read_resource("vault://public", &ctx)
1812            .await
1813            .expect("hidden but enabled resource should remain readable");
1814        assert_eq!(resource.first_text(), Some("Read vault://public"));
1815
1816        let prompt = layer
1817            .get_prompt("public_prompt", None, &ctx)
1818            .await
1819            .expect("hidden but enabled prompt should remain gettable");
1820        assert_eq!(
1821            prompt.messages[0].content.as_text(),
1822            Some("Prompt public_prompt")
1823        );
1824    }
1825
1826    #[test]
1827    fn test_hidden_only_profile_still_advertises_operation_capabilities() {
1828        let layer = VisibilityLayer::new(MockHandler)
1829            .with_hidden_tools(["public_tool", "admin_tool"])
1830            .with_hidden_resources(["vault://public", "vault://admin"])
1831            .with_hidden_resource_templates(["vault://notes/{id}"])
1832            .with_hidden_prompts(["public_prompt", "admin_prompt"]);
1833
1834        assert!(layer.list_tools().is_empty());
1835        assert!(layer.list_resources().is_empty());
1836        assert!(layer.list_resource_templates().is_empty());
1837        assert!(layer.list_prompts().is_empty());
1838
1839        let capabilities = layer.server_capabilities();
1840        assert!(
1841            capabilities.tools.is_some(),
1842            "hidden-but-callable tools still require the tools capability"
1843        );
1844        assert!(
1845            capabilities.resources.is_some(),
1846            "hidden-but-readable resources still require the resources capability"
1847        );
1848        assert!(
1849            capabilities.prompts.is_some(),
1850            "hidden-but-gettable prompts still require the prompts capability"
1851        );
1852    }
1853
1854    #[tokio::test]
1855    async fn test_disabled_tool_wins_over_hidden_tool() {
1856        let layer = VisibilityLayer::new(MockHandler)
1857            .with_hidden_tools(["public_tool"])
1858            .with_disabled_tools(["public_tool"]);
1859        let ctx = RequestContext::default();
1860
1861        assert!(!tool_names(&layer).contains(&"public_tool".to_string()));
1862
1863        let err = layer
1864            .call_tool("public_tool", serde_json::json!({}), &ctx)
1865            .await
1866            .expect_err("disabled tool should not be callable even if hidden");
1867        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
1868    }
1869
1870    #[test]
1871    fn test_tag_disabled_tool_stays_hidden_despite_name_allowlist() {
1872        let layer = VisibilityLayer::new(MockHandler)
1873            .with_allowed_tools(["admin_tool"])
1874            .disable_tags(["admin"]);
1875
1876        assert!(layer.list_tools().is_empty());
1877    }
1878
1879    #[test]
1880    fn test_visibility_config_getter_reflects_builder_methods() {
1881        let layer = VisibilityLayer::new(MockHandler)
1882            .with_allowed_tools(["public_tool"])
1883            .with_disabled_resources(["vault://admin"])
1884            .with_hidden_prompts(["admin_prompt"])
1885            .require_read_only_tools();
1886
1887        let config = layer.visibility_config();
1888
1889        assert!(config.tools.allow.unwrap().contains("public_tool"));
1890        assert!(config.resources.deny.contains("vault://admin"));
1891        assert!(config.prompts.hide.contains("admin_prompt"));
1892        assert!(config.require_read_only_tools);
1893    }
1894
1895    #[tokio::test]
1896    async fn test_disabled_tool_call_returns_not_found() {
1897        let layer = VisibilityLayer::new(MockHandler).with_disabled_tools(["public_tool"]);
1898        let ctx = RequestContext::default();
1899
1900        let err = layer
1901            .call_tool("public_tool", serde_json::json!({}), &ctx)
1902            .await
1903            .expect_err("hidden tool calls should be rejected");
1904
1905        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
1906    }
1907
1908    #[tokio::test]
1909    async fn test_session_enable_allows_hidden_tagged_tool_call() {
1910        let layer = VisibilityLayer::new(MockHandler).disable_tags(["admin"]);
1911        let ctx = RequestContext::default().with_session_id("session1");
1912
1913        let err = layer
1914            .call_tool("admin_tool", serde_json::json!({}), &ctx)
1915            .await
1916            .expect_err("globally hidden tagged tool should be rejected");
1917        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
1918
1919        layer.enable_for_session("session1", &["admin".to_string()]);
1920
1921        let result = layer
1922            .call_tool("admin_tool", serde_json::json!({}), &ctx)
1923            .await
1924            .expect("session-enabled tagged tool should pass through");
1925        assert_eq!(result.first_text(), Some("Called admin_tool"));
1926    }
1927
1928    #[tokio::test]
1929    async fn test_session_disable_blocks_visible_tagged_tool_call() {
1930        let layer = VisibilityLayer::new(MockHandler);
1931        let ctx = RequestContext::default().with_session_id("session1");
1932
1933        let result = layer
1934            .call_tool("public_tool", serde_json::json!({}), &ctx)
1935            .await
1936            .expect("public tool should initially pass through");
1937        assert_eq!(result.first_text(), Some("Called public_tool"));
1938
1939        layer.disable_for_session("session1", &["public".to_string()]);
1940
1941        let err = layer
1942            .call_tool("public_tool", serde_json::json!({}), &ctx)
1943            .await
1944            .expect_err("session-disabled tagged tool should be rejected");
1945        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
1946    }
1947
1948    #[tokio::test]
1949    async fn test_dispatch_uses_registered_tool_without_relisting() {
1950        let handler = CountingHandler::default();
1951        let state = Arc::clone(&handler.state);
1952        let layer = VisibilityLayer::new(handler);
1953        let ctx = RequestContext::default();
1954
1955        assert_eq!(tool_names(&layer), vec!["counted_tool"]);
1956        assert_eq!(state.tool_lists.load(Ordering::SeqCst), 1);
1957
1958        layer
1959            .call_tool("counted_tool", serde_json::json!({}), &ctx)
1960            .await
1961            .expect("registered tool should dispatch");
1962        layer
1963            .call_tool("counted_tool", serde_json::json!({}), &ctx)
1964            .await
1965            .expect("registered tool should dispatch again");
1966
1967        assert_eq!(
1968            state.tool_lists.load(Ordering::SeqCst),
1969            1,
1970            "dispatch should use the registry populated by tools/list"
1971        );
1972    }
1973
1974    #[tokio::test]
1975    async fn test_dispatch_lazily_builds_registry_once_per_component_family() {
1976        let handler = CountingHandler::default();
1977        let state = Arc::clone(&handler.state);
1978        let layer = VisibilityLayer::new(handler);
1979        let ctx = RequestContext::default();
1980
1981        layer
1982            .call_tool("counted_tool", serde_json::json!({}), &ctx)
1983            .await
1984            .expect("registered tool should dispatch");
1985        layer
1986            .call_tool("counted_tool", serde_json::json!({}), &ctx)
1987            .await
1988            .expect("registered tool should dispatch again");
1989        assert_eq!(state.tool_lists.load(Ordering::SeqCst), 1);
1990
1991        layer
1992            .read_resource("counted://resource", &ctx)
1993            .await
1994            .expect("registered resource should dispatch");
1995        layer
1996            .read_resource("counted://resource", &ctx)
1997            .await
1998            .expect("registered resource should dispatch again");
1999        assert_eq!(state.resource_lists.load(Ordering::SeqCst), 1);
2000
2001        layer
2002            .get_prompt("counted_prompt", None, &ctx)
2003            .await
2004            .expect("registered prompt should dispatch");
2005        layer
2006            .get_prompt("counted_prompt", None, &ctx)
2007            .await
2008            .expect("registered prompt should dispatch again");
2009        assert_eq!(state.prompt_lists.load(Ordering::SeqCst), 1);
2010    }
2011
2012    #[tokio::test]
2013    async fn test_refresh_component_registry_updates_dispatch_metadata() {
2014        let read_only = Arc::new(AtomicBool::new(false));
2015        let layer = VisibilityLayer::new(MutableToolHandler {
2016            read_only: Arc::clone(&read_only),
2017        })
2018        .require_read_only_tools();
2019        let ctx = RequestContext::default();
2020
2021        let err = layer
2022            .call_tool("mutable_tool", serde_json::json!({}), &ctx)
2023            .await
2024            .expect_err("initial destructive metadata should be rejected");
2025        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
2026
2027        read_only.store(true, Ordering::SeqCst);
2028        let err = layer
2029            .call_tool("mutable_tool", serde_json::json!({}), &ctx)
2030            .await
2031            .expect_err("cached destructive metadata should remain fail-closed");
2032        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
2033
2034        layer.refresh_component_registry();
2035
2036        let result = layer
2037            .call_tool("mutable_tool", serde_json::json!({}), &ctx)
2038            .await
2039            .expect("refreshed read-only metadata should permit dispatch");
2040        assert_eq!(result.first_text(), Some("Called mutable_tool"));
2041    }
2042
2043    #[tokio::test]
2044    async fn test_registry_preserves_first_duplicate_tool_metadata() {
2045        let layer = VisibilityLayer::new(DuplicateToolMetadataHandler).require_read_only_tools();
2046        let ctx = RequestContext::default();
2047
2048        let result = layer
2049            .call_tool("duplicate_tool", serde_json::json!({}), &ctx)
2050            .await
2051            .expect("first listed read-only metadata should govern dispatch");
2052
2053        assert_eq!(result.first_text(), Some("Called duplicate_tool"));
2054    }
2055
2056    #[tokio::test]
2057    async fn test_exact_tool_policy_blocks_unlisted_dynamic_call() {
2058        let layer = VisibilityLayer::new(DynamicHandler).with_disabled_tools(["dynamic_tool"]);
2059        let ctx = RequestContext::default();
2060
2061        let err = layer
2062            .call_tool("dynamic_tool", serde_json::json!({}), &ctx)
2063            .await
2064            .expect_err("denylisted dynamic tool calls should be rejected");
2065
2066        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
2067    }
2068
2069    #[tokio::test]
2070    async fn test_exact_tool_allowlist_can_permit_unlisted_dynamic_call() {
2071        let layer = VisibilityLayer::new(DynamicHandler).with_allowed_tools(["dynamic_tool"]);
2072        let ctx = RequestContext::default();
2073
2074        let result = layer
2075            .call_tool("dynamic_tool", serde_json::json!({}), &ctx)
2076            .await
2077            .expect("allowlisted dynamic tool should pass through");
2078
2079        assert_eq!(result.first_text(), Some("Dynamic dynamic_tool"));
2080    }
2081
2082    #[tokio::test]
2083    async fn test_read_only_policy_blocks_unlisted_dynamic_tool() {
2084        let layer = VisibilityLayer::new(DynamicHandler)
2085            .with_allowed_tools(["dynamic_tool"])
2086            .require_read_only_tools();
2087        let ctx = RequestContext::default();
2088
2089        let err = layer
2090            .call_tool("dynamic_tool", serde_json::json!({}), &ctx)
2091            .await
2092            .expect_err("read-only policy should fail closed without annotations");
2093
2094        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ToolNotFound);
2095    }
2096
2097    #[test]
2098    fn test_require_read_only_tools_hides_mutating_tools() {
2099        let layer = VisibilityLayer::new(MockHandler).require_read_only_tools();
2100
2101        assert_eq!(tool_names(&layer), vec!["public_tool"]);
2102    }
2103
2104    #[tokio::test]
2105    async fn test_disabled_resource_read_returns_not_found() {
2106        let layer = VisibilityLayer::new(MockHandler).with_disabled_resources(["vault://public"]);
2107        let ctx = RequestContext::default();
2108
2109        let err = layer
2110            .read_resource("vault://public", &ctx)
2111            .await
2112            .expect_err("hidden resource reads should be rejected");
2113
2114        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ResourceNotFound);
2115    }
2116
2117    #[tokio::test]
2118    async fn test_resource_allowlist_by_name_allows_uri_read() {
2119        let layer = VisibilityLayer::new(MockHandler).with_allowed_resources(["public_resource"]);
2120        let ctx = RequestContext::default();
2121
2122        let result = layer
2123            .read_resource("vault://public", &ctx)
2124            .await
2125            .expect("allowlisted resource name should permit URI read");
2126
2127        assert_eq!(result.first_text(), Some("Read vault://public"));
2128    }
2129
2130    #[tokio::test]
2131    async fn test_exact_resource_policy_blocks_unlisted_dynamic_read() {
2132        let layer =
2133            VisibilityLayer::new(DynamicHandler).with_disabled_resources(["vault://dynamic"]);
2134        let ctx = RequestContext::default();
2135
2136        let err = layer
2137            .read_resource("vault://dynamic", &ctx)
2138            .await
2139            .expect_err("denylisted dynamic resources should be rejected");
2140
2141        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::ResourceNotFound);
2142    }
2143
2144    #[tokio::test]
2145    async fn test_disabled_prompt_get_returns_not_found() {
2146        let layer = VisibilityLayer::new(MockHandler).with_disabled_prompts(["public_prompt"]);
2147        let ctx = RequestContext::default();
2148
2149        let err = layer
2150            .get_prompt("public_prompt", None, &ctx)
2151            .await
2152            .expect_err("hidden prompts should be rejected");
2153
2154        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::PromptNotFound);
2155    }
2156
2157    #[tokio::test]
2158    async fn test_exact_prompt_policy_blocks_unlisted_dynamic_get() {
2159        let layer = VisibilityLayer::new(DynamicHandler).with_disabled_prompts(["dynamic_prompt"]);
2160        let ctx = RequestContext::default();
2161
2162        let err = layer
2163            .get_prompt("dynamic_prompt", None, &ctx)
2164            .await
2165            .expect_err("denylisted dynamic prompts should be rejected");
2166
2167        assert_eq!(err.kind, turbomcp_core::error::ErrorKind::PromptNotFound);
2168    }
2169
2170    #[test]
2171    fn test_visibility_config_applies_component_rules() {
2172        let config = VisibilityConfig::new()
2173            .with_allowed_tools(["public_tool"])
2174            .with_disabled_resources(["vault://admin"])
2175            .with_allowed_prompts(["public_prompt"])
2176            .with_allowed_resource_templates(["vault://notes/{id}"]);
2177
2178        let layer = VisibilityLayer::new(MockHandler).with_visibility_config(config);
2179
2180        assert_eq!(tool_names(&layer), vec!["public_tool"]);
2181
2182        let resources = layer.list_resources();
2183        assert_eq!(resources.len(), 1);
2184        assert_eq!(resources[0].name, "public_resource");
2185
2186        let prompts = layer.list_prompts();
2187        assert_eq!(prompts.len(), 1);
2188        assert_eq!(prompts[0].name, "public_prompt");
2189
2190        let templates = layer.list_resource_templates();
2191        assert_eq!(templates.len(), 1);
2192        assert_eq!(templates[0].name, "note_template");
2193    }
2194
2195    #[test]
2196    fn test_session_enable_override() {
2197        let layer = VisibilityLayer::new(MockHandler).disable_tags(["admin"]);
2198
2199        // Initially hidden
2200        assert_eq!(layer.list_tools().len(), 1);
2201
2202        // Enable for session
2203        layer.enable_for_session("session1", &["admin".to_string()]);
2204
2205        // Still hidden in list_tools (doesn't take session context)
2206        // but call_tool would work with session context
2207        assert_eq!(layer.list_tools().len(), 1);
2208
2209        // Cleanup
2210        layer.clear_session("session1");
2211    }
2212
2213    #[test]
2214    fn test_session_guard_cleanup() {
2215        let layer = VisibilityLayer::new(MockHandler).disable_tags(["admin"]);
2216
2217        {
2218            let _guard = layer.session_guard("guard-session");
2219
2220            // Enable admin for this session
2221            layer.enable_for_session("guard-session", &["admin".to_string()]);
2222            layer.disable_for_session("guard-session", &["public".to_string()]);
2223
2224            // Session state exists
2225            assert!(layer.active_sessions_count() > 0);
2226        }
2227
2228        // After guard drops, session state should be cleaned up
2229        assert_eq!(layer.active_sessions_count(), 0);
2230    }
2231
2232    #[test]
2233    fn test_active_sessions_count() {
2234        let layer = VisibilityLayer::new(MockHandler);
2235
2236        assert_eq!(layer.active_sessions_count(), 0);
2237
2238        layer.enable_for_session("session1", &["tag1".to_string()]);
2239        assert_eq!(layer.active_sessions_count(), 1);
2240
2241        layer.disable_for_session("session2", &["tag2".to_string()]);
2242        assert_eq!(layer.active_sessions_count(), 2);
2243
2244        // Same session, different tag - should not increase count
2245        layer.enable_for_session("session1", &["tag2".to_string()]);
2246        assert_eq!(layer.active_sessions_count(), 2);
2247
2248        layer.clear_session("session1");
2249        assert_eq!(layer.active_sessions_count(), 1);
2250
2251        layer.clear_session("session2");
2252        assert_eq!(layer.active_sessions_count(), 0);
2253    }
2254}