use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Map, Value};
use turbomcp_core::{RequestContext, meta};
use turbomcp_protocol::neutral;
use crate::tags;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ComponentKind {
Tool,
Resource,
ResourceTemplate,
Prompt,
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct VisibleComponent<'a> {
pub kind: ComponentKind,
pub id: &'a str,
pub meta: &'a Map<String, Value>,
pub request: &'a RequestContext,
}
impl VisibleComponent<'_> {
pub fn tags(&self) -> impl Iterator<Item = &str> {
tags::of(self.meta)
}
pub fn declared_scopes(&self) -> impl Iterator<Item = &str> {
self.meta
.get(meta::keys::SCOPES)
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(Value::as_str)
}
}
pub trait VisibilityPolicy: Send + Sync + 'static {
fn is_visible(&self, component: &VisibleComponent<'_>) -> bool;
}
impl<F> VisibilityPolicy for F
where
F: Fn(&VisibleComponent<'_>) -> bool + Send + Sync + 'static,
{
fn is_visible(&self, component: &VisibleComponent<'_>) -> bool {
self(component)
}
}
#[derive(Clone, Debug, Default)]
pub struct Visibility {
hidden_tags: BTreeSet<String>,
declared_scopes: bool,
}
impl Visibility {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn hiding_tagged<I, T>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<String>,
{
self.hidden_tags.extend(tags.into_iter().map(Into::into));
self
}
#[must_use]
pub fn requiring_declared_scopes(mut self) -> Self {
self.declared_scopes = true;
self
}
}
impl VisibilityPolicy for Visibility {
fn is_visible(&self, component: &VisibleComponent<'_>) -> bool {
if !self.hidden_tags.is_empty() && component.tags().any(|t| self.hidden_tags.contains(t)) {
return false;
}
if self.declared_scopes {
let required: Vec<&str> = component.declared_scopes().collect();
if !required.is_empty() && !component.request.identity.has_scopes(&required) {
return false;
}
}
true
}
}
pub(crate) type Policy = Option<Arc<dyn VisibilityPolicy>>;
macro_rules! retain_visible {
($policy:expr, $ctx:expr, $kind:expr, $items:expr, $id:ident) => {
if let Some(policy) = $policy {
$items.retain(|item| {
policy.is_visible(&VisibleComponent {
kind: $kind,
id: &item.$id,
meta: &item.meta,
request: $ctx,
})
});
}
};
}
pub(crate) fn filter_tools(
policy: &Policy,
ctx: &RequestContext,
result: &mut neutral::ListToolsResult,
) {
retain_visible!(policy, ctx, ComponentKind::Tool, result.tools, name);
}
pub(crate) fn filter_resources(
policy: &Policy,
ctx: &RequestContext,
result: &mut neutral::ListResourcesResult,
) {
retain_visible!(policy, ctx, ComponentKind::Resource, result.resources, uri);
}
pub(crate) fn filter_resource_templates(
policy: &Policy,
ctx: &RequestContext,
result: &mut neutral::ListResourceTemplatesResult,
) {
retain_visible!(
policy,
ctx,
ComponentKind::ResourceTemplate,
result.resource_templates,
uri_template
);
}
pub(crate) fn filter_prompts(
policy: &Policy,
ctx: &RequestContext,
result: &mut neutral::ListPromptsResult,
) {
retain_visible!(policy, ctx, ComponentKind::Prompt, result.prompts, name);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use turbomcp_core::{Claims, Identity, ProtocolVersion};
fn caller(scopes: &str) -> RequestContext {
let mut claims = Claims::new();
claims.insert("scope".into(), json!(scopes));
RequestContext::new(ProtocolVersion::V2025_11_25).with_identity(Identity::Bearer {
sub: "alice".into(),
claims,
})
}
fn tool(meta: Value) -> neutral::Tool {
let mut t = neutral::Tool::new("wipe", json!({"type": "object"}));
if let Value::Object(m) = meta {
t.meta = m;
}
t
}
fn visible(policy: &impl VisibilityPolicy, tool: &neutral::Tool, ctx: &RequestContext) -> bool {
policy.is_visible(&VisibleComponent {
kind: ComponentKind::Tool,
id: &tool.name,
meta: &tool.meta,
request: ctx,
})
}
#[test]
fn a_bare_policy_hides_nothing() {
let ctx = caller("read");
assert!(visible(
&Visibility::new(),
&tool(json!({ "io.turbomcp/tags": ["internal"] })),
&ctx
));
}
#[test]
fn tagged_components_are_hidden() {
let policy = Visibility::new().hiding_tagged(["internal"]);
let ctx = caller("read");
assert!(!visible(
&policy,
&tool(json!({ "io.turbomcp/tags": ["internal", "beta"] })),
&ctx
));
assert!(visible(
&policy,
&tool(json!({ "io.turbomcp/tags": ["beta"] })),
&ctx
));
assert!(visible(&policy, &tool(json!({})), &ctx));
}
#[test]
fn declared_scopes_are_matched_against_the_caller() {
let policy = Visibility::new().requiring_declared_scopes();
let admin = tool(json!({ "io.turbomcp/scopes": ["admin"] }));
assert!(!visible(&policy, &admin, &caller("read")));
assert!(visible(&policy, &admin, &caller("read admin")));
assert!(visible(&policy, &tool(json!({})), &caller("")));
assert!(visible(
&policy,
&tool(json!({})),
&RequestContext::new(ProtocolVersion::V2025_11_25)
));
}
#[test]
fn every_enabled_check_must_pass() {
let policy = Visibility::new()
.hiding_tagged(["internal"])
.requiring_declared_scopes();
let ctx = caller("admin");
assert!(!visible(
&policy,
&tool(json!({ "io.turbomcp/tags": ["internal"], "io.turbomcp/scopes": ["admin"] })),
&ctx
));
}
#[test]
fn a_closure_is_a_policy() {
let policy = |c: &VisibleComponent<'_>| c.id != "wipe";
assert!(!visible(&policy, &tool(json!({})), &caller("read")));
}
#[test]
fn filtering_a_list_leaves_only_what_is_visible() {
let policy: Policy = Some(Arc::new(Visibility::new().hiding_tagged(["internal"])));
let ctx = caller("read");
let mut result = neutral::ListToolsResult::new(vec![
tool(json!({ "io.turbomcp/tags": ["internal"] })),
neutral::Tool::new("read", json!({"type": "object"})),
]);
filter_tools(&policy, &ctx, &mut result);
assert_eq!(
result
.tools
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>(),
["read"]
);
}
}