Skip to main content

mcp_proxy/
filter.rs

1//! Capability filtering middleware for the proxy.
2//!
3//! This module provides two complementary filtering middlewares that control
4//! which MCP capabilities (tools, resources, prompts) are visible and callable
5//! through the proxy.
6//!
7//! # Capability filtering ([`CapabilityFilterService`])
8//!
9//! Wraps a `Service<RouterRequest>` and filters tools, resources, and prompts
10//! based on per-backend allow/deny lists from config. Filtering happens in two
11//! places:
12//!
13//! - **List responses** -- tools, resources, and prompts are removed from
14//!   `ListTools`, `ListResources`, `ListResourceTemplates`, and `ListPrompts`
15//!   responses before they reach the client.
16//! - **Call/read/get requests** -- `CallTool`, `ReadResource`, and `GetPrompt`
17//!   requests for filtered capabilities are rejected immediately with an
18//!   `invalid_params` JSON-RPC error, without ever reaching the backend.
19//!
20//! ## Pattern support
21//!
22//! Filter patterns support three matching modes:
23//!
24//! - **Exact match** -- `"read_file"` matches only `read_file`.
25//! - **Glob patterns** -- `"*_file"` matches `read_file`, `write_file`, etc.
26//!   Standard glob wildcards (`*`, `?`) are supported.
27//! - **Regex patterns** -- prefix a pattern with `re:` to use a regular
28//!   expression: `"re:^list_.*$"` matches `list_files`, `list_users`, etc.
29//!
30//! ## Annotation-based filtering
31//!
32//! In addition to name-based allow/deny lists, the capability filter supports
33//! filtering based on MCP tool annotations:
34//!
35//! - **`hide_destructive`** -- hides any tool whose `destructive_hint`
36//!   annotation is `true`. Non-annotated tools are kept.
37//! - **`read_only_only`** -- only exposes tools whose `read_only_hint`
38//!   annotation is `true`. Tools without annotations are hidden (they are
39//!   not known to be read-only).
40//!
41//! Name-based and annotation-based filters compose: a tool must pass both
42//! the name filter and the annotation filter to be visible.
43//!
44//! ## Configuration
45//!
46//! Filters are configured per-backend in TOML. Use `expose_tools` (allowlist)
47//! or `hide_tools` (denylist) -- not both:
48//!
49//! ```toml
50//! [[backends]]
51//! name = "files"
52//! transport = "stdio"
53//! command = "file-server"
54//! # Allowlist: only these tools are visible
55//! expose_tools = ["read_file", "list_*"]
56//!
57//! [[backends]]
58//! name = "db"
59//! transport = "stdio"
60//! command = "db-server"
61//! # Denylist: everything except these tools is visible
62//! hide_tools = ["drop_table", "re:^delete_"]
63//! # Annotation filter: hide destructive tools
64//! hide_destructive = true
65//!
66//! [[backends]]
67//! name = "safe"
68//! transport = "stdio"
69//! command = "safe-server"
70//! # Only expose read-only tools
71//! read_only_only = true
72//! ```
73//!
74//! The same pattern applies to resources (`expose_resources` / `hide_resources`)
75//! and prompts (`expose_prompts` / `hide_prompts`).
76//!
77//! ## Middleware stack position
78//!
79//! Capability filtering runs after request validation and before search-mode
80//! filtering in the middleware stack. The ordering in `proxy.rs`:
81//!
82//! 1. Request coalescing
83//! 2. Request validation ([`crate::validation`])
84//! 3. **Capability filtering** (this module)
85//! 4. Search-mode filtering (this module)
86//! 5. Tool aliasing ([`crate::alias`])
87//! 6. Composite tools ([`crate::composite`])
88//!
89//! # Search-mode filtering ([`SearchModeFilterService`])
90//!
91//! When the proxy is configured with `tool_exposure = "search"`, the
92//! [`SearchModeFilterService`] hides all tools from `ListTools` responses
93//! except those under the `proxy/` namespace prefix. This is useful when
94//! aggregating many backends whose combined tool count would overwhelm an
95//! LLM's context window.
96//!
97//! Backend tools remain callable -- they are just hidden from discovery.
98//! Clients use `proxy/search_tools` to find tools and `proxy/call_tool`
99//! to invoke them. Only `ListTools` responses are filtered; all other
100//! request types (including `CallTool`) pass through unchanged.
101
102use std::convert::Infallible;
103use std::future::Future;
104use std::pin::Pin;
105use std::sync::Arc;
106use std::task::{Context, Poll};
107
108use tower::{Layer, Service};
109
110use tower_mcp::protocol::{McpRequest, McpResponse};
111use tower_mcp::{RouterRequest, RouterResponse};
112use tower_mcp_types::JsonRpcError;
113
114use crate::config::BackendFilter;
115
116/// Tower layer that produces a [`CapabilityFilterService`].
117///
118/// # Example
119///
120/// ```rust
121/// use tower::ServiceBuilder;
122/// # let proxy = tower_mcp::McpRouter::new();
123/// use mcp_proxy::filter::CapabilityFilterLayer;
124/// let filters = Vec::new(); // Supply BackendFilter rules for your backends.
125///
126/// let service = ServiceBuilder::new()
127///     .layer(CapabilityFilterLayer::new(filters))
128///     .service(proxy);
129/// ```
130#[derive(Clone)]
131pub struct CapabilityFilterLayer {
132    filters: Vec<BackendFilter>,
133}
134
135impl CapabilityFilterLayer {
136    /// Create a new capability filter layer with the given filter rules.
137    pub fn new(filters: Vec<BackendFilter>) -> Self {
138        Self { filters }
139    }
140}
141
142impl<S> Layer<S> for CapabilityFilterLayer {
143    type Service = CapabilityFilterService<S>;
144
145    fn layer(&self, inner: S) -> Self::Service {
146        CapabilityFilterService::new(inner, self.filters.clone())
147    }
148}
149
150/// Middleware that filters capabilities from proxy responses.
151#[derive(Clone)]
152pub struct CapabilityFilterService<S> {
153    inner: S,
154    filters: Arc<Vec<BackendFilter>>,
155}
156
157impl<S> CapabilityFilterService<S> {
158    /// Create a new capability filter service with the given filter rules.
159    pub fn new(inner: S, filters: Vec<BackendFilter>) -> Self {
160        Self {
161            inner,
162            filters: Arc::new(filters),
163        }
164    }
165}
166
167impl<S> Service<RouterRequest> for CapabilityFilterService<S>
168where
169    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
170        + Clone
171        + Send
172        + 'static,
173    S::Future: Send,
174{
175    type Response = RouterResponse;
176    type Error = Infallible;
177    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
178
179    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
180        self.inner.poll_ready(cx)
181    }
182
183    fn call(&mut self, req: RouterRequest) -> Self::Future {
184        let filters = Arc::clone(&self.filters);
185        let request_id = req.id.clone();
186
187        // Check if this is a call/read/get for a filtered capability
188        match &req.inner {
189            McpRequest::CallTool(params) => {
190                if let Some(reason) = check_tool_denied(&filters, &params.name) {
191                    return Box::pin(async move {
192                        Ok(RouterResponse {
193                            id: request_id,
194                            inner: Err(JsonRpcError::invalid_params(reason)),
195                        })
196                    });
197                }
198            }
199            McpRequest::ReadResource(params) => {
200                if let Some(reason) = check_resource_denied(&filters, &params.uri) {
201                    return Box::pin(async move {
202                        Ok(RouterResponse {
203                            id: request_id,
204                            inner: Err(JsonRpcError::invalid_params(reason)),
205                        })
206                    });
207                }
208            }
209            McpRequest::GetPrompt(params) => {
210                if let Some(reason) = check_prompt_denied(&filters, &params.name) {
211                    return Box::pin(async move {
212                        Ok(RouterResponse {
213                            id: request_id,
214                            inner: Err(JsonRpcError::invalid_params(reason)),
215                        })
216                    });
217                }
218            }
219            _ => {}
220        }
221
222        let fut = self.inner.call(req);
223
224        Box::pin(async move {
225            let mut resp = fut.await?;
226
227            // Filter list responses
228            if let Ok(ref mut mcp_resp) = resp.inner {
229                match mcp_resp {
230                    McpResponse::ListTools(result) => {
231                        result.tools.retain(|tool| {
232                            for f in filters.iter() {
233                                if let Some(local_name) = tool.name.strip_prefix(&f.namespace) {
234                                    if !f.tool_filter.allows(local_name) {
235                                        return false;
236                                    }
237                                    // Annotation-based filtering
238                                    if let Some(ref annotations) = tool.annotations {
239                                        if f.hide_destructive && annotations.destructive_hint {
240                                            return false;
241                                        }
242                                        if f.read_only_only && !annotations.read_only_hint {
243                                            return false;
244                                        }
245                                    } else if f.read_only_only {
246                                        // No annotations = not known to be read-only
247                                        return false;
248                                    }
249                                    return true;
250                                }
251                            }
252                            true
253                        });
254                    }
255                    McpResponse::ListResources(result) => {
256                        result.resources.retain(|resource| {
257                            for f in filters.iter() {
258                                if let Some(local_uri) = resource.uri.strip_prefix(&f.namespace) {
259                                    return f.resource_filter.allows(local_uri);
260                                }
261                            }
262                            true
263                        });
264                    }
265                    McpResponse::ListResourceTemplates(result) => {
266                        result.resource_templates.retain(|template| {
267                            for f in filters.iter() {
268                                if let Some(local_uri) =
269                                    template.uri_template.strip_prefix(&f.namespace)
270                                {
271                                    return f.resource_filter.allows(local_uri);
272                                }
273                            }
274                            true
275                        });
276                    }
277                    McpResponse::ListPrompts(result) => {
278                        result.prompts.retain(|prompt| {
279                            for f in filters.iter() {
280                                if let Some(local_name) = prompt.name.strip_prefix(&f.namespace) {
281                                    return f.prompt_filter.allows(local_name);
282                                }
283                            }
284                            true
285                        });
286                    }
287                    _ => {}
288                }
289            }
290
291            Ok(resp)
292        })
293    }
294}
295
296/// Check if a namespaced tool name is denied by any filter.
297/// Returns Some(reason) if denied.
298fn check_tool_denied(filters: &[BackendFilter], namespaced_name: &str) -> Option<String> {
299    for f in filters {
300        if let Some(local_name) = namespaced_name.strip_prefix(&f.namespace) {
301            if !f.tool_filter.allows(local_name) {
302                return Some(format!("Tool not available: {}", namespaced_name));
303            }
304            return None;
305        }
306    }
307    None
308}
309
310/// Check if a namespaced resource URI is denied by any filter.
311fn check_resource_denied(filters: &[BackendFilter], namespaced_uri: &str) -> Option<String> {
312    for f in filters {
313        if let Some(local_uri) = namespaced_uri.strip_prefix(&f.namespace) {
314            if !f.resource_filter.allows(local_uri) {
315                return Some(format!("Resource not available: {}", namespaced_uri));
316            }
317            return None;
318        }
319    }
320    None
321}
322
323/// Check if a namespaced prompt name is denied by any filter.
324fn check_prompt_denied(filters: &[BackendFilter], namespaced_name: &str) -> Option<String> {
325    for f in filters {
326        if let Some(local_name) = namespaced_name.strip_prefix(&f.namespace) {
327            if !f.prompt_filter.allows(local_name) {
328                return Some(format!("Prompt not available: {}", namespaced_name));
329            }
330            return None;
331        }
332    }
333    None
334}
335
336/// Tower layer that produces a [`SearchModeFilterService`].
337///
338/// When search mode is enabled, `ListTools` responses are filtered to only
339/// include tools under the given namespace prefix (typically `"proxy/"`).
340/// All other requests pass through unchanged -- `CallTool` requests for
341/// backend tools still work, allowing `proxy/call_tool` to forward them.
342#[derive(Clone)]
343pub struct SearchModeFilterLayer {
344    prefix: String,
345}
346
347impl SearchModeFilterLayer {
348    /// Create a new search mode filter that only lists tools matching `prefix`.
349    pub fn new(prefix: impl Into<String>) -> Self {
350        Self {
351            prefix: prefix.into(),
352        }
353    }
354}
355
356impl<S> Layer<S> for SearchModeFilterLayer {
357    type Service = SearchModeFilterService<S>;
358
359    fn layer(&self, inner: S) -> Self::Service {
360        SearchModeFilterService {
361            inner,
362            prefix: self.prefix.clone(),
363        }
364    }
365}
366
367/// Middleware that filters `ListTools` responses to only show tools under
368/// a specific namespace prefix.
369///
370/// Used by search mode to hide individual backend tools from tool listings
371/// while keeping them callable through `proxy/call_tool`.
372#[derive(Clone)]
373pub struct SearchModeFilterService<S> {
374    inner: S,
375    prefix: String,
376}
377
378impl<S> SearchModeFilterService<S> {
379    /// Create a new search mode filter service.
380    pub fn new(inner: S, prefix: impl Into<String>) -> Self {
381        Self {
382            inner,
383            prefix: prefix.into(),
384        }
385    }
386}
387
388impl<S> Service<RouterRequest> for SearchModeFilterService<S>
389where
390    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
391        + Clone
392        + Send
393        + 'static,
394    S::Future: Send,
395{
396    type Response = RouterResponse;
397    type Error = Infallible;
398    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
399
400    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
401        self.inner.poll_ready(cx)
402    }
403
404    fn call(&mut self, req: RouterRequest) -> Self::Future {
405        let prefix = self.prefix.clone();
406        let fut = self.inner.call(req);
407
408        Box::pin(async move {
409            let mut resp = fut.await?;
410
411            if let Ok(McpResponse::ListTools(ref mut result)) = resp.inner {
412                result.tools.retain(|tool| tool.name.starts_with(&prefix));
413            }
414
415            Ok(resp)
416        })
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use tower_mcp::protocol::{McpRequest, McpResponse};
423
424    use super::CapabilityFilterService;
425    use crate::config::{BackendFilter, NameFilter};
426    use crate::test_util::{MockService, call_service};
427
428    fn allow_filter(namespace: &str, tools: &[&str]) -> BackendFilter {
429        BackendFilter {
430            namespace: namespace.to_string(),
431            tool_filter: NameFilter::allow_list(tools.iter().map(|s| s.to_string())).unwrap(),
432            resource_filter: NameFilter::PassAll,
433            prompt_filter: NameFilter::PassAll,
434            hide_destructive: false,
435            read_only_only: false,
436        }
437    }
438
439    fn deny_filter(namespace: &str, tools: &[&str]) -> BackendFilter {
440        BackendFilter {
441            namespace: namespace.to_string(),
442            tool_filter: NameFilter::deny_list(tools.iter().map(|s| s.to_string())).unwrap(),
443            resource_filter: NameFilter::PassAll,
444            prompt_filter: NameFilter::PassAll,
445            hide_destructive: false,
446            read_only_only: false,
447        }
448    }
449
450    #[tokio::test]
451    async fn test_filter_allow_list_tools() {
452        let mock = MockService::with_tools(&["fs/read", "fs/write", "fs/delete"]);
453        let filters = vec![allow_filter("fs/", &["read", "write"])];
454        let mut svc = CapabilityFilterService::new(mock, filters);
455
456        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
457        match resp.inner.unwrap() {
458            McpResponse::ListTools(result) => {
459                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
460                assert!(names.contains(&"fs/read"));
461                assert!(names.contains(&"fs/write"));
462                assert!(!names.contains(&"fs/delete"), "delete should be filtered");
463            }
464            other => panic!("expected ListTools, got: {:?}", other),
465        }
466    }
467
468    #[tokio::test]
469    async fn test_filter_deny_list_tools() {
470        let mock = MockService::with_tools(&["fs/read", "fs/write", "fs/delete"]);
471        let filters = vec![deny_filter("fs/", &["delete"])];
472        let mut svc = CapabilityFilterService::new(mock, filters);
473
474        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
475        match resp.inner.unwrap() {
476            McpResponse::ListTools(result) => {
477                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
478                assert!(names.contains(&"fs/read"));
479                assert!(names.contains(&"fs/write"));
480                assert!(!names.contains(&"fs/delete"));
481            }
482            other => panic!("expected ListTools, got: {:?}", other),
483        }
484    }
485
486    #[tokio::test]
487    async fn test_filter_denies_call_to_hidden_tool() {
488        let mock = MockService::with_tools(&["fs/read", "fs/delete"]);
489        let filters = vec![allow_filter("fs/", &["read"])];
490        let mut svc = CapabilityFilterService::new(mock, filters);
491
492        let resp = call_service(
493            &mut svc,
494            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
495                name: "fs/delete".to_string(),
496                arguments: serde_json::json!({}),
497                input_responses: None,
498                request_state: None,
499                meta: None,
500                task: None,
501            }),
502        )
503        .await;
504
505        let err = resp.inner.unwrap_err();
506        assert!(
507            err.message.contains("not available"),
508            "should deny: {}",
509            err.message
510        );
511    }
512
513    #[tokio::test]
514    async fn test_filter_allows_call_to_permitted_tool() {
515        let mock = MockService::with_tools(&["fs/read"]);
516        let filters = vec![allow_filter("fs/", &["read"])];
517        let mut svc = CapabilityFilterService::new(mock, filters);
518
519        let resp = call_service(
520            &mut svc,
521            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
522                name: "fs/read".to_string(),
523                arguments: serde_json::json!({}),
524                input_responses: None,
525                request_state: None,
526                meta: None,
527                task: None,
528            }),
529        )
530        .await;
531
532        assert!(resp.inner.is_ok(), "allowed tool should succeed");
533    }
534
535    #[tokio::test]
536    async fn test_filter_pass_all_allows_everything() {
537        let mock = MockService::with_tools(&["fs/read", "fs/write", "fs/delete"]);
538        let filters = vec![BackendFilter {
539            namespace: "fs/".to_string(),
540            tool_filter: NameFilter::PassAll,
541            resource_filter: NameFilter::PassAll,
542            prompt_filter: NameFilter::PassAll,
543            hide_destructive: false,
544            read_only_only: false,
545        }];
546        let mut svc = CapabilityFilterService::new(mock, filters);
547
548        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
549        match resp.inner.unwrap() {
550            McpResponse::ListTools(result) => {
551                assert_eq!(result.tools.len(), 3);
552            }
553            other => panic!("expected ListTools, got: {:?}", other),
554        }
555    }
556
557    #[tokio::test]
558    async fn test_filter_unmatched_namespace_passes_through() {
559        let mock = MockService::with_tools(&["db/query"]);
560        let filters = vec![allow_filter("fs/", &["read"])];
561        let mut svc = CapabilityFilterService::new(mock, filters);
562
563        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
564        match resp.inner.unwrap() {
565            McpResponse::ListTools(result) => {
566                assert_eq!(result.tools.len(), 1, "unmatched namespace should pass");
567                assert_eq!(result.tools[0].name, "db/query");
568            }
569            other => panic!("expected ListTools, got: {:?}", other),
570        }
571    }
572
573    // --- Annotation-based filtering ---
574
575    /// Create a mock service with tools that have annotations.
576    fn mock_with_annotated_tools() -> MockService {
577        use tower_mcp::protocol::ToolDefinition;
578        use tower_mcp_types::protocol::ToolAnnotations;
579
580        let tools = vec![
581            ToolDefinition {
582                name: "fs/read_file".to_string(),
583                title: None,
584                description: Some("Read a file".to_string()),
585                input_schema: serde_json::json!({"type": "object"}),
586                output_schema: None,
587                icons: None,
588                annotations: Some(ToolAnnotations {
589                    title: None,
590                    read_only_hint: true,
591                    destructive_hint: false,
592                    idempotent_hint: true,
593                    open_world_hint: false,
594                }),
595                execution: None,
596                meta: None,
597            },
598            ToolDefinition {
599                name: "fs/delete_file".to_string(),
600                title: None,
601                description: Some("Delete a file".to_string()),
602                input_schema: serde_json::json!({"type": "object"}),
603                output_schema: None,
604                icons: None,
605                annotations: Some(ToolAnnotations {
606                    title: None,
607                    read_only_hint: false,
608                    destructive_hint: true,
609                    idempotent_hint: false,
610                    open_world_hint: false,
611                }),
612                execution: None,
613                meta: None,
614            },
615            ToolDefinition {
616                name: "fs/write_file".to_string(),
617                title: None,
618                description: Some("Write a file".to_string()),
619                input_schema: serde_json::json!({"type": "object"}),
620                output_schema: None,
621                icons: None,
622                annotations: Some(ToolAnnotations {
623                    title: None,
624                    read_only_hint: false,
625                    destructive_hint: false,
626                    idempotent_hint: true,
627                    open_world_hint: false,
628                }),
629                execution: None,
630                meta: None,
631            },
632        ];
633        MockService { tools }
634    }
635
636    #[tokio::test]
637    async fn test_filter_hide_destructive() {
638        let mock = mock_with_annotated_tools();
639        let filters = vec![BackendFilter {
640            namespace: "fs/".to_string(),
641            tool_filter: NameFilter::PassAll,
642            resource_filter: NameFilter::PassAll,
643            prompt_filter: NameFilter::PassAll,
644            hide_destructive: true,
645            read_only_only: false,
646        }];
647        let mut svc = CapabilityFilterService::new(mock, filters);
648
649        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
650        match resp.inner.unwrap() {
651            McpResponse::ListTools(result) => {
652                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
653                assert!(names.contains(&"fs/read_file"));
654                assert!(names.contains(&"fs/write_file"));
655                assert!(
656                    !names.contains(&"fs/delete_file"),
657                    "destructive tool should be hidden"
658                );
659            }
660            other => panic!("expected ListTools, got: {:?}", other),
661        }
662    }
663
664    #[tokio::test]
665    async fn test_filter_read_only_only() {
666        let mock = mock_with_annotated_tools();
667        let filters = vec![BackendFilter {
668            namespace: "fs/".to_string(),
669            tool_filter: NameFilter::PassAll,
670            resource_filter: NameFilter::PassAll,
671            prompt_filter: NameFilter::PassAll,
672            hide_destructive: false,
673            read_only_only: true,
674        }];
675        let mut svc = CapabilityFilterService::new(mock, filters);
676
677        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
678        match resp.inner.unwrap() {
679            McpResponse::ListTools(result) => {
680                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
681                assert!(names.contains(&"fs/read_file"), "read-only tool visible");
682                assert!(!names.contains(&"fs/delete_file"), "non-read-only hidden");
683                assert!(!names.contains(&"fs/write_file"), "non-read-only hidden");
684            }
685            other => panic!("expected ListTools, got: {:?}", other),
686        }
687    }
688
689    // --- Search mode filtering ---
690
691    #[tokio::test]
692    async fn test_search_mode_only_shows_prefix_tools() {
693        let mock = MockService::with_tools(&[
694            "proxy/search_tools",
695            "proxy/call_tool",
696            "proxy/tool_categories",
697            "fs/read",
698            "fs/write",
699            "db/query",
700        ]);
701        let mut svc = super::SearchModeFilterService::new(mock, "proxy/");
702
703        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
704        match resp.inner.unwrap() {
705            McpResponse::ListTools(result) => {
706                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
707                assert_eq!(names.len(), 3, "only proxy/ tools should be listed");
708                assert!(names.contains(&"proxy/search_tools"));
709                assert!(names.contains(&"proxy/call_tool"));
710                assert!(names.contains(&"proxy/tool_categories"));
711                assert!(!names.contains(&"fs/read"));
712                assert!(!names.contains(&"db/query"));
713            }
714            other => panic!("expected ListTools, got: {:?}", other),
715        }
716    }
717
718    #[tokio::test]
719    async fn test_search_mode_allows_call_tool_for_backend() {
720        let mock = MockService::with_tools(&["proxy/call_tool", "fs/read"]);
721        let mut svc = super::SearchModeFilterService::new(mock, "proxy/");
722
723        // CallTool requests should pass through regardless of namespace
724        let resp = call_service(
725            &mut svc,
726            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
727                name: "fs/read".to_string(),
728                arguments: serde_json::json!({}),
729                input_responses: None,
730                request_state: None,
731                meta: None,
732                task: None,
733            }),
734        )
735        .await;
736
737        assert!(
738            resp.inner.is_ok(),
739            "search mode should not block CallTool requests"
740        );
741    }
742
743    #[tokio::test]
744    async fn test_search_mode_no_proxy_tools_returns_empty() {
745        let mock = MockService::with_tools(&["fs/read", "db/query"]);
746        let mut svc = super::SearchModeFilterService::new(mock, "proxy/");
747
748        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
749        match resp.inner.unwrap() {
750            McpResponse::ListTools(result) => {
751                assert!(result.tools.is_empty(), "no proxy/ tools means empty list");
752            }
753            other => panic!("expected ListTools, got: {:?}", other),
754        }
755    }
756}