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,ignore
121/// use tower::ServiceBuilder;
122/// use mcp_proxy::filter::CapabilityFilterLayer;
123///
124/// let service = ServiceBuilder::new()
125///     .layer(CapabilityFilterLayer::new(filters))
126///     .service(proxy);
127/// ```
128#[derive(Clone)]
129pub struct CapabilityFilterLayer {
130    filters: Vec<BackendFilter>,
131}
132
133impl CapabilityFilterLayer {
134    /// Create a new capability filter layer with the given filter rules.
135    pub fn new(filters: Vec<BackendFilter>) -> Self {
136        Self { filters }
137    }
138}
139
140impl<S> Layer<S> for CapabilityFilterLayer {
141    type Service = CapabilityFilterService<S>;
142
143    fn layer(&self, inner: S) -> Self::Service {
144        CapabilityFilterService::new(inner, self.filters.clone())
145    }
146}
147
148/// Middleware that filters capabilities from proxy responses.
149#[derive(Clone)]
150pub struct CapabilityFilterService<S> {
151    inner: S,
152    filters: Arc<Vec<BackendFilter>>,
153}
154
155impl<S> CapabilityFilterService<S> {
156    /// Create a new capability filter service with the given filter rules.
157    pub fn new(inner: S, filters: Vec<BackendFilter>) -> Self {
158        Self {
159            inner,
160            filters: Arc::new(filters),
161        }
162    }
163}
164
165impl<S> Service<RouterRequest> for CapabilityFilterService<S>
166where
167    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
168        + Clone
169        + Send
170        + 'static,
171    S::Future: Send,
172{
173    type Response = RouterResponse;
174    type Error = Infallible;
175    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
176
177    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
178        self.inner.poll_ready(cx)
179    }
180
181    fn call(&mut self, req: RouterRequest) -> Self::Future {
182        let filters = Arc::clone(&self.filters);
183        let request_id = req.id.clone();
184
185        // Check if this is a call/read/get for a filtered capability
186        match &req.inner {
187            McpRequest::CallTool(params) => {
188                if let Some(reason) = check_tool_denied(&filters, &params.name) {
189                    return Box::pin(async move {
190                        Ok(RouterResponse {
191                            id: request_id,
192                            inner: Err(JsonRpcError::invalid_params(reason)),
193                        })
194                    });
195                }
196            }
197            McpRequest::ReadResource(params) => {
198                if let Some(reason) = check_resource_denied(&filters, &params.uri) {
199                    return Box::pin(async move {
200                        Ok(RouterResponse {
201                            id: request_id,
202                            inner: Err(JsonRpcError::invalid_params(reason)),
203                        })
204                    });
205                }
206            }
207            McpRequest::GetPrompt(params) => {
208                if let Some(reason) = check_prompt_denied(&filters, &params.name) {
209                    return Box::pin(async move {
210                        Ok(RouterResponse {
211                            id: request_id,
212                            inner: Err(JsonRpcError::invalid_params(reason)),
213                        })
214                    });
215                }
216            }
217            _ => {}
218        }
219
220        let fut = self.inner.call(req);
221
222        Box::pin(async move {
223            let mut resp = fut.await?;
224
225            // Filter list responses
226            if let Ok(ref mut mcp_resp) = resp.inner {
227                match mcp_resp {
228                    McpResponse::ListTools(result) => {
229                        result.tools.retain(|tool| {
230                            for f in filters.iter() {
231                                if let Some(local_name) = tool.name.strip_prefix(&f.namespace) {
232                                    if !f.tool_filter.allows(local_name) {
233                                        return false;
234                                    }
235                                    // Annotation-based filtering
236                                    if let Some(ref annotations) = tool.annotations {
237                                        if f.hide_destructive && annotations.destructive_hint {
238                                            return false;
239                                        }
240                                        if f.read_only_only && !annotations.read_only_hint {
241                                            return false;
242                                        }
243                                    } else if f.read_only_only {
244                                        // No annotations = not known to be read-only
245                                        return false;
246                                    }
247                                    return true;
248                                }
249                            }
250                            true
251                        });
252                    }
253                    McpResponse::ListResources(result) => {
254                        result.resources.retain(|resource| {
255                            for f in filters.iter() {
256                                if let Some(local_uri) = resource.uri.strip_prefix(&f.namespace) {
257                                    return f.resource_filter.allows(local_uri);
258                                }
259                            }
260                            true
261                        });
262                    }
263                    McpResponse::ListResourceTemplates(result) => {
264                        result.resource_templates.retain(|template| {
265                            for f in filters.iter() {
266                                if let Some(local_uri) =
267                                    template.uri_template.strip_prefix(&f.namespace)
268                                {
269                                    return f.resource_filter.allows(local_uri);
270                                }
271                            }
272                            true
273                        });
274                    }
275                    McpResponse::ListPrompts(result) => {
276                        result.prompts.retain(|prompt| {
277                            for f in filters.iter() {
278                                if let Some(local_name) = prompt.name.strip_prefix(&f.namespace) {
279                                    return f.prompt_filter.allows(local_name);
280                                }
281                            }
282                            true
283                        });
284                    }
285                    _ => {}
286                }
287            }
288
289            Ok(resp)
290        })
291    }
292}
293
294/// Check if a namespaced tool name is denied by any filter.
295/// Returns Some(reason) if denied.
296fn check_tool_denied(filters: &[BackendFilter], namespaced_name: &str) -> Option<String> {
297    for f in filters {
298        if let Some(local_name) = namespaced_name.strip_prefix(&f.namespace) {
299            if !f.tool_filter.allows(local_name) {
300                return Some(format!("Tool not available: {}", namespaced_name));
301            }
302            return None;
303        }
304    }
305    None
306}
307
308/// Check if a namespaced resource URI is denied by any filter.
309fn check_resource_denied(filters: &[BackendFilter], namespaced_uri: &str) -> Option<String> {
310    for f in filters {
311        if let Some(local_uri) = namespaced_uri.strip_prefix(&f.namespace) {
312            if !f.resource_filter.allows(local_uri) {
313                return Some(format!("Resource not available: {}", namespaced_uri));
314            }
315            return None;
316        }
317    }
318    None
319}
320
321/// Check if a namespaced prompt name is denied by any filter.
322fn check_prompt_denied(filters: &[BackendFilter], namespaced_name: &str) -> Option<String> {
323    for f in filters {
324        if let Some(local_name) = namespaced_name.strip_prefix(&f.namespace) {
325            if !f.prompt_filter.allows(local_name) {
326                return Some(format!("Prompt not available: {}", namespaced_name));
327            }
328            return None;
329        }
330    }
331    None
332}
333
334/// Tower layer that produces a [`SearchModeFilterService`].
335///
336/// When search mode is enabled, `ListTools` responses are filtered to only
337/// include tools under the given namespace prefix (typically `"proxy/"`).
338/// All other requests pass through unchanged -- `CallTool` requests for
339/// backend tools still work, allowing `proxy/call_tool` to forward them.
340#[derive(Clone)]
341pub struct SearchModeFilterLayer {
342    prefix: String,
343}
344
345impl SearchModeFilterLayer {
346    /// Create a new search mode filter that only lists tools matching `prefix`.
347    pub fn new(prefix: impl Into<String>) -> Self {
348        Self {
349            prefix: prefix.into(),
350        }
351    }
352}
353
354impl<S> Layer<S> for SearchModeFilterLayer {
355    type Service = SearchModeFilterService<S>;
356
357    fn layer(&self, inner: S) -> Self::Service {
358        SearchModeFilterService {
359            inner,
360            prefix: self.prefix.clone(),
361        }
362    }
363}
364
365/// Middleware that filters `ListTools` responses to only show tools under
366/// a specific namespace prefix.
367///
368/// Used by search mode to hide individual backend tools from tool listings
369/// while keeping them callable through `proxy/call_tool`.
370#[derive(Clone)]
371pub struct SearchModeFilterService<S> {
372    inner: S,
373    prefix: String,
374}
375
376impl<S> SearchModeFilterService<S> {
377    /// Create a new search mode filter service.
378    pub fn new(inner: S, prefix: impl Into<String>) -> Self {
379        Self {
380            inner,
381            prefix: prefix.into(),
382        }
383    }
384}
385
386impl<S> Service<RouterRequest> for SearchModeFilterService<S>
387where
388    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
389        + Clone
390        + Send
391        + 'static,
392    S::Future: Send,
393{
394    type Response = RouterResponse;
395    type Error = Infallible;
396    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
397
398    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
399        self.inner.poll_ready(cx)
400    }
401
402    fn call(&mut self, req: RouterRequest) -> Self::Future {
403        let prefix = self.prefix.clone();
404        let fut = self.inner.call(req);
405
406        Box::pin(async move {
407            let mut resp = fut.await?;
408
409            if let Ok(McpResponse::ListTools(ref mut result)) = resp.inner {
410                result.tools.retain(|tool| tool.name.starts_with(&prefix));
411            }
412
413            Ok(resp)
414        })
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use tower_mcp::protocol::{McpRequest, McpResponse};
421
422    use super::CapabilityFilterService;
423    use crate::config::{BackendFilter, NameFilter};
424    use crate::test_util::{MockService, call_service};
425
426    fn allow_filter(namespace: &str, tools: &[&str]) -> BackendFilter {
427        BackendFilter {
428            namespace: namespace.to_string(),
429            tool_filter: NameFilter::allow_list(tools.iter().map(|s| s.to_string())).unwrap(),
430            resource_filter: NameFilter::PassAll,
431            prompt_filter: NameFilter::PassAll,
432            hide_destructive: false,
433            read_only_only: false,
434        }
435    }
436
437    fn deny_filter(namespace: &str, tools: &[&str]) -> BackendFilter {
438        BackendFilter {
439            namespace: namespace.to_string(),
440            tool_filter: NameFilter::deny_list(tools.iter().map(|s| s.to_string())).unwrap(),
441            resource_filter: NameFilter::PassAll,
442            prompt_filter: NameFilter::PassAll,
443            hide_destructive: false,
444            read_only_only: false,
445        }
446    }
447
448    #[tokio::test]
449    async fn test_filter_allow_list_tools() {
450        let mock = MockService::with_tools(&["fs/read", "fs/write", "fs/delete"]);
451        let filters = vec![allow_filter("fs/", &["read", "write"])];
452        let mut svc = CapabilityFilterService::new(mock, filters);
453
454        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
455        match resp.inner.unwrap() {
456            McpResponse::ListTools(result) => {
457                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
458                assert!(names.contains(&"fs/read"));
459                assert!(names.contains(&"fs/write"));
460                assert!(!names.contains(&"fs/delete"), "delete should be filtered");
461            }
462            other => panic!("expected ListTools, got: {:?}", other),
463        }
464    }
465
466    #[tokio::test]
467    async fn test_filter_deny_list_tools() {
468        let mock = MockService::with_tools(&["fs/read", "fs/write", "fs/delete"]);
469        let filters = vec![deny_filter("fs/", &["delete"])];
470        let mut svc = CapabilityFilterService::new(mock, filters);
471
472        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
473        match resp.inner.unwrap() {
474            McpResponse::ListTools(result) => {
475                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
476                assert!(names.contains(&"fs/read"));
477                assert!(names.contains(&"fs/write"));
478                assert!(!names.contains(&"fs/delete"));
479            }
480            other => panic!("expected ListTools, got: {:?}", other),
481        }
482    }
483
484    #[tokio::test]
485    async fn test_filter_denies_call_to_hidden_tool() {
486        let mock = MockService::with_tools(&["fs/read", "fs/delete"]);
487        let filters = vec![allow_filter("fs/", &["read"])];
488        let mut svc = CapabilityFilterService::new(mock, filters);
489
490        let resp = call_service(
491            &mut svc,
492            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
493                name: "fs/delete".to_string(),
494                arguments: serde_json::json!({}),
495                input_responses: None,
496                request_state: None,
497                meta: None,
498                task: None,
499            }),
500        )
501        .await;
502
503        let err = resp.inner.unwrap_err();
504        assert!(
505            err.message.contains("not available"),
506            "should deny: {}",
507            err.message
508        );
509    }
510
511    #[tokio::test]
512    async fn test_filter_allows_call_to_permitted_tool() {
513        let mock = MockService::with_tools(&["fs/read"]);
514        let filters = vec![allow_filter("fs/", &["read"])];
515        let mut svc = CapabilityFilterService::new(mock, filters);
516
517        let resp = call_service(
518            &mut svc,
519            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
520                name: "fs/read".to_string(),
521                arguments: serde_json::json!({}),
522                input_responses: None,
523                request_state: None,
524                meta: None,
525                task: None,
526            }),
527        )
528        .await;
529
530        assert!(resp.inner.is_ok(), "allowed tool should succeed");
531    }
532
533    #[tokio::test]
534    async fn test_filter_pass_all_allows_everything() {
535        let mock = MockService::with_tools(&["fs/read", "fs/write", "fs/delete"]);
536        let filters = vec![BackendFilter {
537            namespace: "fs/".to_string(),
538            tool_filter: NameFilter::PassAll,
539            resource_filter: NameFilter::PassAll,
540            prompt_filter: NameFilter::PassAll,
541            hide_destructive: false,
542            read_only_only: false,
543        }];
544        let mut svc = CapabilityFilterService::new(mock, filters);
545
546        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
547        match resp.inner.unwrap() {
548            McpResponse::ListTools(result) => {
549                assert_eq!(result.tools.len(), 3);
550            }
551            other => panic!("expected ListTools, got: {:?}", other),
552        }
553    }
554
555    #[tokio::test]
556    async fn test_filter_unmatched_namespace_passes_through() {
557        let mock = MockService::with_tools(&["db/query"]);
558        let filters = vec![allow_filter("fs/", &["read"])];
559        let mut svc = CapabilityFilterService::new(mock, filters);
560
561        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
562        match resp.inner.unwrap() {
563            McpResponse::ListTools(result) => {
564                assert_eq!(result.tools.len(), 1, "unmatched namespace should pass");
565                assert_eq!(result.tools[0].name, "db/query");
566            }
567            other => panic!("expected ListTools, got: {:?}", other),
568        }
569    }
570
571    // --- Annotation-based filtering ---
572
573    /// Create a mock service with tools that have annotations.
574    fn mock_with_annotated_tools() -> MockService {
575        use tower_mcp::protocol::ToolDefinition;
576        use tower_mcp_types::protocol::ToolAnnotations;
577
578        let tools = vec![
579            ToolDefinition {
580                name: "fs/read_file".to_string(),
581                title: None,
582                description: Some("Read a file".to_string()),
583                input_schema: serde_json::json!({"type": "object"}),
584                output_schema: None,
585                icons: None,
586                annotations: Some(ToolAnnotations {
587                    title: None,
588                    read_only_hint: true,
589                    destructive_hint: false,
590                    idempotent_hint: true,
591                    open_world_hint: false,
592                }),
593                execution: None,
594                meta: None,
595            },
596            ToolDefinition {
597                name: "fs/delete_file".to_string(),
598                title: None,
599                description: Some("Delete a file".to_string()),
600                input_schema: serde_json::json!({"type": "object"}),
601                output_schema: None,
602                icons: None,
603                annotations: Some(ToolAnnotations {
604                    title: None,
605                    read_only_hint: false,
606                    destructive_hint: true,
607                    idempotent_hint: false,
608                    open_world_hint: false,
609                }),
610                execution: None,
611                meta: None,
612            },
613            ToolDefinition {
614                name: "fs/write_file".to_string(),
615                title: None,
616                description: Some("Write a file".to_string()),
617                input_schema: serde_json::json!({"type": "object"}),
618                output_schema: None,
619                icons: None,
620                annotations: Some(ToolAnnotations {
621                    title: None,
622                    read_only_hint: false,
623                    destructive_hint: false,
624                    idempotent_hint: true,
625                    open_world_hint: false,
626                }),
627                execution: None,
628                meta: None,
629            },
630        ];
631        MockService { tools }
632    }
633
634    #[tokio::test]
635    async fn test_filter_hide_destructive() {
636        let mock = mock_with_annotated_tools();
637        let filters = vec![BackendFilter {
638            namespace: "fs/".to_string(),
639            tool_filter: NameFilter::PassAll,
640            resource_filter: NameFilter::PassAll,
641            prompt_filter: NameFilter::PassAll,
642            hide_destructive: true,
643            read_only_only: false,
644        }];
645        let mut svc = CapabilityFilterService::new(mock, filters);
646
647        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
648        match resp.inner.unwrap() {
649            McpResponse::ListTools(result) => {
650                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
651                assert!(names.contains(&"fs/read_file"));
652                assert!(names.contains(&"fs/write_file"));
653                assert!(
654                    !names.contains(&"fs/delete_file"),
655                    "destructive tool should be hidden"
656                );
657            }
658            other => panic!("expected ListTools, got: {:?}", other),
659        }
660    }
661
662    #[tokio::test]
663    async fn test_filter_read_only_only() {
664        let mock = mock_with_annotated_tools();
665        let filters = vec![BackendFilter {
666            namespace: "fs/".to_string(),
667            tool_filter: NameFilter::PassAll,
668            resource_filter: NameFilter::PassAll,
669            prompt_filter: NameFilter::PassAll,
670            hide_destructive: false,
671            read_only_only: true,
672        }];
673        let mut svc = CapabilityFilterService::new(mock, filters);
674
675        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
676        match resp.inner.unwrap() {
677            McpResponse::ListTools(result) => {
678                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
679                assert!(names.contains(&"fs/read_file"), "read-only tool visible");
680                assert!(!names.contains(&"fs/delete_file"), "non-read-only hidden");
681                assert!(!names.contains(&"fs/write_file"), "non-read-only hidden");
682            }
683            other => panic!("expected ListTools, got: {:?}", other),
684        }
685    }
686
687    // --- Search mode filtering ---
688
689    #[tokio::test]
690    async fn test_search_mode_only_shows_prefix_tools() {
691        let mock = MockService::with_tools(&[
692            "proxy/search_tools",
693            "proxy/call_tool",
694            "proxy/tool_categories",
695            "fs/read",
696            "fs/write",
697            "db/query",
698        ]);
699        let mut svc = super::SearchModeFilterService::new(mock, "proxy/");
700
701        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
702        match resp.inner.unwrap() {
703            McpResponse::ListTools(result) => {
704                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
705                assert_eq!(names.len(), 3, "only proxy/ tools should be listed");
706                assert!(names.contains(&"proxy/search_tools"));
707                assert!(names.contains(&"proxy/call_tool"));
708                assert!(names.contains(&"proxy/tool_categories"));
709                assert!(!names.contains(&"fs/read"));
710                assert!(!names.contains(&"db/query"));
711            }
712            other => panic!("expected ListTools, got: {:?}", other),
713        }
714    }
715
716    #[tokio::test]
717    async fn test_search_mode_allows_call_tool_for_backend() {
718        let mock = MockService::with_tools(&["proxy/call_tool", "fs/read"]);
719        let mut svc = super::SearchModeFilterService::new(mock, "proxy/");
720
721        // CallTool requests should pass through regardless of namespace
722        let resp = call_service(
723            &mut svc,
724            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
725                name: "fs/read".to_string(),
726                arguments: serde_json::json!({}),
727                input_responses: None,
728                request_state: None,
729                meta: None,
730                task: None,
731            }),
732        )
733        .await;
734
735        assert!(
736            resp.inner.is_ok(),
737            "search mode should not block CallTool requests"
738        );
739    }
740
741    #[tokio::test]
742    async fn test_search_mode_no_proxy_tools_returns_empty() {
743        let mock = MockService::with_tools(&["fs/read", "db/query"]);
744        let mut svc = super::SearchModeFilterService::new(mock, "proxy/");
745
746        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
747        match resp.inner.unwrap() {
748            McpResponse::ListTools(result) => {
749                assert!(result.tools.is_empty(), "no proxy/ tools means empty list");
750            }
751            other => panic!("expected ListTools, got: {:?}", other),
752        }
753    }
754}