Skip to main content

fastmcp_server/
proxy.rs

1//! Proxy/composition support for MCP servers.
2//!
3//! This module provides lightweight proxy handlers that forward tool/resource/prompt
4//! calls to another MCP server via a backend client.
5
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8
9use fastmcp_client::Client;
10use fastmcp_core::{McpContext, McpError, McpResult};
11use fastmcp_protocol::{
12    Content, Prompt, PromptMessage, Resource, ResourceContent, ResourceTemplate, Tool,
13};
14
15use crate::handler::{PromptHandler, ResourceHandler, ToolHandler, UriParams};
16
17/// Progress callback signature used by proxy backends.
18pub type ProgressCallback<'a> = &'a mut dyn FnMut(f64, Option<f64>, Option<String>);
19
20/// Backend interface used by proxy handlers.
21pub trait ProxyBackend: Send {
22    /// Lists available tools.
23    fn list_tools(&mut self) -> McpResult<Vec<Tool>>;
24    /// Lists available resources.
25    fn list_resources(&mut self) -> McpResult<Vec<Resource>>;
26    /// Lists available resource templates.
27    fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>>;
28    /// Lists available prompts.
29    fn list_prompts(&mut self) -> McpResult<Vec<Prompt>>;
30    /// Calls a tool.
31    fn call_tool(&mut self, name: &str, arguments: serde_json::Value) -> McpResult<Vec<Content>>;
32    /// Calls a tool with progress callback support.
33    fn call_tool_with_progress(
34        &mut self,
35        name: &str,
36        arguments: serde_json::Value,
37        on_progress: ProgressCallback<'_>,
38    ) -> McpResult<Vec<Content>>;
39    /// Reads a resource by URI.
40    fn read_resource(&mut self, uri: &str) -> McpResult<Vec<ResourceContent>>;
41    /// Fetches a prompt by name.
42    fn get_prompt(
43        &mut self,
44        name: &str,
45        arguments: HashMap<String, String>,
46    ) -> McpResult<Vec<PromptMessage>>;
47}
48
49impl ProxyBackend for Client {
50    fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
51        if self.server_capabilities().tools.is_none() {
52            return Ok(Vec::new());
53        }
54        Client::list_tools(self)
55    }
56
57    fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
58        if self.server_capabilities().resources.is_none() {
59            return Ok(Vec::new());
60        }
61        Client::list_resources(self)
62    }
63
64    fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
65        if self.server_capabilities().resources.is_none() {
66            return Ok(Vec::new());
67        }
68        Client::list_resource_templates(self)
69    }
70
71    fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
72        if self.server_capabilities().prompts.is_none() {
73            return Ok(Vec::new());
74        }
75        Client::list_prompts(self)
76    }
77
78    fn call_tool(&mut self, name: &str, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
79        Client::call_tool(self, name, arguments)
80    }
81
82    fn call_tool_with_progress(
83        &mut self,
84        name: &str,
85        arguments: serde_json::Value,
86        on_progress: ProgressCallback<'_>,
87    ) -> McpResult<Vec<Content>> {
88        let mut wrapper = |progress, total, message: Option<&str>| {
89            on_progress(progress, total, message.map(ToString::to_string));
90        };
91        Client::call_tool_with_progress(self, name, arguments, &mut wrapper)
92    }
93
94    fn read_resource(&mut self, uri: &str) -> McpResult<Vec<ResourceContent>> {
95        Client::read_resource(self, uri)
96    }
97
98    fn get_prompt(
99        &mut self,
100        name: &str,
101        arguments: HashMap<String, String>,
102    ) -> McpResult<Vec<PromptMessage>> {
103        Client::get_prompt(self, name, arguments)
104    }
105}
106
107/// Catalog of remote definitions used to register proxy handlers.
108#[derive(Debug, Clone, Default)]
109pub struct ProxyCatalog {
110    /// Remote tool definitions.
111    pub tools: Vec<Tool>,
112    /// Remote resource definitions.
113    pub resources: Vec<Resource>,
114    /// Remote resource templates.
115    pub resource_templates: Vec<ResourceTemplate>,
116    /// Remote prompt definitions.
117    pub prompts: Vec<Prompt>,
118}
119
120impl ProxyCatalog {
121    /// Builds a catalog by querying a proxy backend.
122    pub fn from_backend<B: ProxyBackend + ?Sized>(backend: &mut B) -> McpResult<Self> {
123        Ok(Self {
124            tools: backend.list_tools()?,
125            resources: backend.list_resources()?,
126            resource_templates: backend.list_resource_templates()?,
127            prompts: backend.list_prompts()?,
128        })
129    }
130
131    /// Builds a catalog by querying a client.
132    pub fn from_client(client: &mut Client) -> McpResult<Self> {
133        Self::from_backend(client)
134    }
135}
136
137/// Shared proxy client wrapper for handler reuse.
138#[derive(Clone)]
139pub struct ProxyClient {
140    inner: Arc<Mutex<dyn ProxyBackend>>,
141}
142
143impl ProxyClient {
144    /// Creates a proxy client from an MCP client.
145    #[must_use]
146    pub fn from_client(client: Client) -> Self {
147        Self::from_backend(client)
148    }
149
150    /// Creates a proxy client from a backend implementation.
151    #[must_use]
152    pub fn from_backend<B: ProxyBackend + 'static>(backend: B) -> Self {
153        Self {
154            inner: Arc::new(Mutex::new(backend)),
155        }
156    }
157
158    /// Fetches a catalog by querying the backend.
159    pub fn catalog(&self) -> McpResult<ProxyCatalog> {
160        self.with_backend(|backend| ProxyCatalog::from_backend(backend))
161    }
162
163    fn with_backend<F, R>(&self, f: F) -> McpResult<R>
164    where
165        F: FnOnce(&mut dyn ProxyBackend) -> McpResult<R>,
166    {
167        let mut guard = self
168            .inner
169            .lock()
170            .map_err(|_| McpError::internal_error("Proxy backend lock poisoned"))?;
171        f(&mut *guard)
172    }
173
174    fn call_tool(
175        &self,
176        ctx: &McpContext,
177        name: &str,
178        arguments: serde_json::Value,
179    ) -> McpResult<Vec<Content>> {
180        ctx.checkpoint()?;
181        self.with_backend(|backend| {
182            if ctx.has_progress_reporter() {
183                let mut callback = |progress, total, message: Option<String>| {
184                    if let Some(total) = total {
185                        ctx.report_progress_with_total(progress, total, message.as_deref());
186                    } else {
187                        ctx.report_progress(progress, message.as_deref());
188                    }
189                };
190                backend.call_tool_with_progress(name, arguments, &mut callback)
191            } else {
192                backend.call_tool(name, arguments)
193            }
194        })
195    }
196
197    fn read_resource(&self, ctx: &McpContext, uri: &str) -> McpResult<Vec<ResourceContent>> {
198        ctx.checkpoint()?;
199        self.with_backend(|backend| backend.read_resource(uri))
200    }
201
202    fn get_prompt(
203        &self,
204        ctx: &McpContext,
205        name: &str,
206        arguments: HashMap<String, String>,
207    ) -> McpResult<Vec<PromptMessage>> {
208        ctx.checkpoint()?;
209        self.with_backend(|backend| backend.get_prompt(name, arguments))
210    }
211}
212
213pub(crate) struct ProxyToolHandler {
214    /// The tool definition as exposed to clients (may have prefixed name).
215    tool: Tool,
216    /// The original tool name on the remote server (for forwarding).
217    external_name: String,
218    client: ProxyClient,
219}
220
221impl ProxyToolHandler {
222    pub(crate) fn new(tool: Tool, client: ProxyClient) -> Self {
223        let external_name = tool.name.clone();
224        Self {
225            tool,
226            external_name,
227            client,
228        }
229    }
230
231    /// Creates a proxy handler with a prefixed name.
232    ///
233    /// The tool will be exposed with `prefix/original_name` but calls will be
234    /// forwarded using the original name.
235    pub(crate) fn with_prefix(mut tool: Tool, prefix: &str, client: ProxyClient) -> Self {
236        let external_name = tool.name.clone();
237        tool.name = format!("{}/{}", prefix, tool.name);
238        Self {
239            tool,
240            external_name,
241            client,
242        }
243    }
244}
245
246impl ToolHandler for ProxyToolHandler {
247    fn definition(&self) -> Tool {
248        self.tool.clone()
249    }
250
251    fn call(&self, ctx: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
252        // Forward using the original external name
253        self.client.call_tool(ctx, &self.external_name, arguments)
254    }
255}
256
257pub(crate) struct ProxyResourceHandler {
258    /// The resource definition as exposed to clients (may have prefixed URI).
259    resource: Resource,
260    /// The original URI on the remote server (for forwarding).
261    external_uri: String,
262    template: Option<ResourceTemplate>,
263    client: ProxyClient,
264}
265
266impl ProxyResourceHandler {
267    pub(crate) fn new(resource: Resource, client: ProxyClient) -> Self {
268        let external_uri = resource.uri.clone();
269        Self {
270            resource,
271            external_uri,
272            template: None,
273            client,
274        }
275    }
276
277    /// Creates a proxy handler with a prefixed URI.
278    pub(crate) fn with_prefix(mut resource: Resource, prefix: &str, client: ProxyClient) -> Self {
279        let external_uri = resource.uri.clone();
280        resource.uri = format!("{}/{}", prefix, resource.uri);
281        Self {
282            resource,
283            external_uri,
284            template: None,
285            client,
286        }
287    }
288
289    pub(crate) fn from_template(template: ResourceTemplate, client: ProxyClient) -> Self {
290        let external_uri = template.uri_template.clone();
291        Self {
292            resource: resource_from_template(&template),
293            external_uri,
294            template: Some(template),
295            client,
296        }
297    }
298
299    /// Creates a proxy handler from a template with a prefixed URI.
300    pub(crate) fn from_template_with_prefix(
301        mut template: ResourceTemplate,
302        prefix: &str,
303        client: ProxyClient,
304    ) -> Self {
305        let external_uri = template.uri_template.clone();
306        template.uri_template = format!("{}/{}", prefix, template.uri_template);
307        Self {
308            resource: resource_from_template(&template),
309            external_uri,
310            template: Some(template),
311            client,
312        }
313    }
314}
315
316impl ResourceHandler for ProxyResourceHandler {
317    fn definition(&self) -> Resource {
318        self.resource.clone()
319    }
320
321    fn template(&self) -> Option<ResourceTemplate> {
322        self.template.clone()
323    }
324
325    fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
326        // Forward using the original external URI
327        self.client.read_resource(ctx, &self.external_uri)
328    }
329
330    fn read_with_uri(
331        &self,
332        ctx: &McpContext,
333        uri: &str,
334        _params: &UriParams,
335    ) -> McpResult<Vec<ResourceContent>> {
336        // For templated resources with a prefix, we need to strip the prefix
337        // to forward the correct URI to the external server.
338        //
339        // If the incoming URI matches our prefixed pattern (e.g., "ext/file://..."),
340        // strip the prefix to get the original URI (e.g., "file://...").
341        let external_uri = if uri.starts_with(&format!(
342            "{}/",
343            self.resource.uri.split('/').next().unwrap_or("")
344        )) {
345            // Strip the prefix (everything before and including the first '/')
346            uri.splitn(2, '/').nth(1).unwrap_or(uri)
347        } else {
348            // No prefix match, use as-is
349            uri
350        };
351        self.client.read_resource(ctx, external_uri)
352    }
353}
354
355pub(crate) struct ProxyPromptHandler {
356    /// The prompt definition as exposed to clients (may have prefixed name).
357    prompt: Prompt,
358    /// The original prompt name on the remote server (for forwarding).
359    external_name: String,
360    client: ProxyClient,
361}
362
363impl ProxyPromptHandler {
364    pub(crate) fn new(prompt: Prompt, client: ProxyClient) -> Self {
365        let external_name = prompt.name.clone();
366        Self {
367            prompt,
368            external_name,
369            client,
370        }
371    }
372
373    /// Creates a proxy handler with a prefixed name.
374    pub(crate) fn with_prefix(mut prompt: Prompt, prefix: &str, client: ProxyClient) -> Self {
375        let external_name = prompt.name.clone();
376        prompt.name = format!("{}/{}", prefix, prompt.name);
377        Self {
378            prompt,
379            external_name,
380            client,
381        }
382    }
383}
384
385impl PromptHandler for ProxyPromptHandler {
386    fn definition(&self) -> Prompt {
387        self.prompt.clone()
388    }
389
390    fn get(
391        &self,
392        ctx: &McpContext,
393        arguments: HashMap<String, String>,
394    ) -> McpResult<Vec<PromptMessage>> {
395        // Forward using the original external name
396        self.client.get_prompt(ctx, &self.external_name, arguments)
397    }
398}
399
400fn resource_from_template(template: &ResourceTemplate) -> Resource {
401    Resource {
402        uri: template.uri_template.clone(),
403        name: template.name.clone(),
404        description: template.description.clone(),
405        mime_type: template.mime_type.clone(),
406        icon: template.icon.clone(),
407        version: template.version.clone(),
408        tags: template.tags.clone(),
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use std::collections::HashMap;
415    use std::sync::{Arc, Mutex};
416
417    use asupersync::Cx;
418    use fastmcp_core::McpContext;
419    use fastmcp_protocol::{Content, Prompt, PromptMessage, Resource, ResourceContent, Tool};
420
421    use super::{ProxyBackend, ProxyCatalog, ProxyClient, ProxyPromptHandler, ProxyToolHandler};
422    use crate::handler::{PromptHandler, ToolHandler};
423
424    #[derive(Default)]
425    struct TestState {
426        last_tool: Option<(String, serde_json::Value)>,
427        last_prompt: Option<(String, HashMap<String, String>)>,
428    }
429
430    #[derive(Clone, Default)]
431    struct TestBackend {
432        tools: Vec<Tool>,
433        resources: Vec<Resource>,
434        prompts: Vec<Prompt>,
435        state: Arc<Mutex<TestState>>,
436    }
437
438    impl ProxyBackend for TestBackend {
439        fn list_tools(&mut self) -> fastmcp_core::McpResult<Vec<Tool>> {
440            Ok(self.tools.clone())
441        }
442
443        fn list_resources(&mut self) -> fastmcp_core::McpResult<Vec<Resource>> {
444            Ok(self.resources.clone())
445        }
446
447        fn list_resource_templates(
448            &mut self,
449        ) -> fastmcp_core::McpResult<Vec<fastmcp_protocol::ResourceTemplate>> {
450            Ok(Vec::new())
451        }
452
453        fn list_prompts(&mut self) -> fastmcp_core::McpResult<Vec<Prompt>> {
454            Ok(self.prompts.clone())
455        }
456
457        fn call_tool(
458            &mut self,
459            name: &str,
460            arguments: serde_json::Value,
461        ) -> fastmcp_core::McpResult<Vec<Content>> {
462            let mut guard = self.state.lock().expect("state lock poisoned");
463            guard.last_tool.replace((name.to_string(), arguments));
464            Ok(vec![Content::Text {
465                text: "ok".to_string(),
466            }])
467        }
468
469        fn call_tool_with_progress(
470            &mut self,
471            name: &str,
472            arguments: serde_json::Value,
473            on_progress: super::ProgressCallback<'_>,
474        ) -> fastmcp_core::McpResult<Vec<Content>> {
475            on_progress(0.5, Some(1.0), Some("half".to_string()));
476            self.call_tool(name, arguments)
477        }
478
479        fn read_resource(&mut self, _uri: &str) -> fastmcp_core::McpResult<Vec<ResourceContent>> {
480            Ok(vec![ResourceContent {
481                uri: "test://resource".to_string(),
482                text: Some("resource".to_string()),
483                mime_type: None,
484                blob: None,
485            }])
486        }
487
488        fn get_prompt(
489            &mut self,
490            name: &str,
491            arguments: HashMap<String, String>,
492        ) -> fastmcp_core::McpResult<Vec<PromptMessage>> {
493            let mut guard = self.state.lock().expect("state lock poisoned");
494            guard.last_prompt.replace((name.to_string(), arguments));
495            Ok(vec![PromptMessage {
496                role: fastmcp_protocol::Role::Assistant,
497                content: Content::Text {
498                    text: "ok".to_string(),
499                },
500            }])
501        }
502    }
503
504    #[test]
505    fn proxy_catalog_collects_definitions() {
506        let backend = TestBackend {
507            tools: vec![Tool {
508                name: "tool".to_string(),
509                description: None,
510                input_schema: serde_json::json!({}),
511                output_schema: None,
512                icon: None,
513                version: None,
514                tags: vec![],
515                annotations: None,
516            }],
517            resources: vec![Resource {
518                uri: "test://resource".to_string(),
519                name: "resource".to_string(),
520                description: None,
521                mime_type: None,
522                icon: None,
523                version: None,
524                tags: vec![],
525            }],
526            prompts: vec![Prompt {
527                name: "prompt".to_string(),
528                description: None,
529                arguments: Vec::new(),
530                icon: None,
531                version: None,
532                tags: vec![],
533            }],
534            ..TestBackend::default()
535        };
536        let mut backend = backend;
537        let catalog = ProxyCatalog::from_backend(&mut backend).expect("catalog");
538        assert_eq!(catalog.tools.len(), 1);
539        assert_eq!(catalog.resources.len(), 1);
540        assert_eq!(catalog.prompts.len(), 1);
541    }
542
543    #[test]
544    fn proxy_tool_handler_forwards_calls() {
545        let state = Arc::new(Mutex::new(TestState::default()));
546        let backend = TestBackend {
547            tools: vec![Tool {
548                name: "tool".to_string(),
549                description: None,
550                input_schema: serde_json::json!({}),
551                output_schema: None,
552                icon: None,
553                version: None,
554                tags: vec![],
555                annotations: None,
556            }],
557            state: Arc::clone(&state),
558            ..TestBackend::default()
559        };
560        let proxy = ProxyClient::from_backend(backend);
561        let handler = ProxyToolHandler::new(
562            Tool {
563                name: "tool".to_string(),
564                description: None,
565                input_schema: serde_json::json!({}),
566                output_schema: None,
567                icon: None,
568                version: None,
569                tags: vec![],
570                annotations: None,
571            },
572            proxy,
573        );
574
575        let ctx = McpContext::new(Cx::for_testing(), 1);
576        let args = serde_json::json!({"value": 1});
577        let result = handler.call(&ctx, args.clone()).expect("call ok");
578        assert_eq!(result.len(), 1);
579
580        let guard = state.lock().expect("state lock poisoned");
581        let (name, recorded_args) = guard
582            .last_tool
583            .as_ref()
584            .expect("tool call recorded")
585            .clone();
586        assert_eq!(name, "tool");
587        assert_eq!(recorded_args, args);
588    }
589
590    #[test]
591    fn proxy_prompt_handler_forwards_calls() {
592        let state = Arc::new(Mutex::new(TestState::default()));
593        let backend = TestBackend {
594            prompts: vec![Prompt {
595                name: "prompt".to_string(),
596                description: None,
597                arguments: Vec::new(),
598                icon: None,
599                version: None,
600                tags: vec![],
601            }],
602            state: Arc::clone(&state),
603            ..TestBackend::default()
604        };
605        let proxy = ProxyClient::from_backend(backend);
606        let handler = ProxyPromptHandler::new(
607            Prompt {
608                name: "prompt".to_string(),
609                description: None,
610                arguments: Vec::new(),
611                icon: None,
612                version: None,
613                tags: vec![],
614            },
615            proxy,
616        );
617
618        let ctx = McpContext::new(Cx::for_testing(), 1);
619        let mut args = HashMap::new();
620        args.insert("key".to_string(), "value".to_string());
621        let result = handler.get(&ctx, args.clone()).expect("get ok");
622        assert_eq!(result.len(), 1);
623
624        let guard = state.lock().expect("state lock poisoned");
625        let (name, recorded_args) = guard
626            .last_prompt
627            .as_ref()
628            .expect("prompt call recorded")
629            .clone();
630        assert_eq!(name, "prompt");
631        assert_eq!(recorded_args, args);
632    }
633
634    // =========================================================================
635    // Prefixed Proxy Handler Tests (for as_proxy)
636    // =========================================================================
637
638    #[test]
639    fn prefixed_tool_handler_uses_correct_names() {
640        let state = Arc::new(Mutex::new(TestState::default()));
641        let backend = TestBackend {
642            tools: vec![Tool {
643                name: "query".to_string(),
644                description: Some("Execute a query".to_string()),
645                input_schema: serde_json::json!({}),
646                output_schema: None,
647                icon: None,
648                version: None,
649                tags: vec![],
650                annotations: None,
651            }],
652            state: Arc::clone(&state),
653            ..TestBackend::default()
654        };
655        let proxy = ProxyClient::from_backend(backend);
656
657        // Create handler with prefix "db"
658        let handler = ProxyToolHandler::with_prefix(
659            Tool {
660                name: "query".to_string(),
661                description: Some("Execute a query".to_string()),
662                input_schema: serde_json::json!({}),
663                output_schema: None,
664                icon: None,
665                version: None,
666                tags: vec![],
667                annotations: None,
668            },
669            "db",
670            proxy,
671        );
672
673        // Definition should have prefixed name
674        let def = handler.definition();
675        assert_eq!(def.name, "db/query");
676        assert_eq!(def.description, Some("Execute a query".to_string()));
677
678        // Call should forward with original name
679        let ctx = McpContext::new(Cx::for_testing(), 1);
680        let args = serde_json::json!({"sql": "SELECT 1"});
681        handler.call(&ctx, args.clone()).expect("call ok");
682
683        let guard = state.lock().expect("state lock poisoned");
684        let (forwarded_name, _) = guard.last_tool.as_ref().expect("tool called").clone();
685        assert_eq!(forwarded_name, "query"); // Original name, not prefixed
686    }
687
688    #[test]
689    fn prefixed_prompt_handler_uses_correct_names() {
690        let state = Arc::new(Mutex::new(TestState::default()));
691        let backend = TestBackend {
692            prompts: vec![Prompt {
693                name: "greeting".to_string(),
694                description: Some("A greeting prompt".to_string()),
695                arguments: Vec::new(),
696                icon: None,
697                version: None,
698                tags: vec![],
699            }],
700            state: Arc::clone(&state),
701            ..TestBackend::default()
702        };
703        let proxy = ProxyClient::from_backend(backend);
704
705        // Create handler with prefix "templates"
706        let handler = ProxyPromptHandler::with_prefix(
707            Prompt {
708                name: "greeting".to_string(),
709                description: Some("A greeting prompt".to_string()),
710                arguments: Vec::new(),
711                icon: None,
712                version: None,
713                tags: vec![],
714            },
715            "templates",
716            proxy,
717        );
718
719        // Definition should have prefixed name
720        let def = handler.definition();
721        assert_eq!(def.name, "templates/greeting");
722        assert_eq!(def.description, Some("A greeting prompt".to_string()));
723
724        // Call should forward with original name
725        let ctx = McpContext::new(Cx::for_testing(), 1);
726        let args = HashMap::new();
727        handler.get(&ctx, args).expect("get ok");
728
729        let guard = state.lock().expect("state lock poisoned");
730        let (forwarded_name, _) = guard.last_prompt.as_ref().expect("prompt called").clone();
731        assert_eq!(forwarded_name, "greeting"); // Original name, not prefixed
732    }
733
734    #[test]
735    fn prefixed_resource_handler_uses_correct_uri() {
736        use super::ProxyResourceHandler;
737        use crate::handler::ResourceHandler;
738
739        let backend = TestBackend {
740            resources: vec![Resource {
741                uri: "file://data".to_string(),
742                name: "Data File".to_string(),
743                description: None,
744                mime_type: None,
745                icon: None,
746                version: None,
747                tags: vec![],
748            }],
749            ..TestBackend::default()
750        };
751        let proxy = ProxyClient::from_backend(backend);
752
753        // Create handler with prefix "storage"
754        let handler = ProxyResourceHandler::with_prefix(
755            Resource {
756                uri: "file://data".to_string(),
757                name: "Data File".to_string(),
758                description: None,
759                mime_type: None,
760                icon: None,
761                version: None,
762                tags: vec![],
763            },
764            "storage",
765            proxy,
766        );
767
768        // Definition should have prefixed URI
769        let def = handler.definition();
770        assert_eq!(def.uri, "storage/file://data");
771        assert_eq!(def.name, "Data File");
772    }
773
774    // =========================================================================
775    // ProxyCatalog Edge Cases
776    // =========================================================================
777
778    #[test]
779    fn proxy_catalog_empty_backend() {
780        let mut backend = TestBackend::default();
781        let catalog = ProxyCatalog::from_backend(&mut backend).expect("catalog");
782        assert!(catalog.tools.is_empty());
783        assert!(catalog.resources.is_empty());
784        assert!(catalog.resource_templates.is_empty());
785        assert!(catalog.prompts.is_empty());
786    }
787
788    #[test]
789    fn proxy_catalog_default_is_empty() {
790        let catalog = ProxyCatalog::default();
791        assert!(catalog.tools.is_empty());
792        assert!(catalog.resources.is_empty());
793        assert!(catalog.resource_templates.is_empty());
794        assert!(catalog.prompts.is_empty());
795    }
796
797    #[test]
798    fn proxy_catalog_multiple_items() {
799        let mut backend = TestBackend {
800            tools: vec![
801                Tool {
802                    name: "t1".to_string(),
803                    description: None,
804                    input_schema: serde_json::json!({}),
805                    output_schema: None,
806                    icon: None,
807                    version: None,
808                    tags: vec![],
809                    annotations: None,
810                },
811                Tool {
812                    name: "t2".to_string(),
813                    description: None,
814                    input_schema: serde_json::json!({}),
815                    output_schema: None,
816                    icon: None,
817                    version: None,
818                    tags: vec![],
819                    annotations: None,
820                },
821            ],
822            prompts: vec![
823                Prompt {
824                    name: "p1".to_string(),
825                    description: None,
826                    arguments: Vec::new(),
827                    icon: None,
828                    version: None,
829                    tags: vec![],
830                },
831                Prompt {
832                    name: "p2".to_string(),
833                    description: None,
834                    arguments: Vec::new(),
835                    icon: None,
836                    version: None,
837                    tags: vec![],
838                },
839            ],
840            ..TestBackend::default()
841        };
842        let catalog = ProxyCatalog::from_backend(&mut backend).expect("catalog");
843        assert_eq!(catalog.tools.len(), 2);
844        assert_eq!(catalog.prompts.len(), 2);
845    }
846
847    // =========================================================================
848    // ProxyClient Tests
849    // =========================================================================
850
851    #[test]
852    fn proxy_client_clone_shares_backend() {
853        let state = Arc::new(Mutex::new(TestState::default()));
854        let backend = TestBackend {
855            tools: vec![Tool {
856                name: "shared".to_string(),
857                description: None,
858                input_schema: serde_json::json!({}),
859                output_schema: None,
860                icon: None,
861                version: None,
862                tags: vec![],
863                annotations: None,
864            }],
865            state: Arc::clone(&state),
866            ..TestBackend::default()
867        };
868        let proxy1 = ProxyClient::from_backend(backend);
869        let proxy2 = proxy1.clone();
870
871        // Both clones should reach the same backend
872        let catalog1 = proxy1.catalog().expect("catalog1");
873        let catalog2 = proxy2.catalog().expect("catalog2");
874        assert_eq!(catalog1.tools.len(), catalog2.tools.len());
875    }
876
877    #[test]
878    fn proxy_client_catalog_fetches_all() {
879        let backend = TestBackend {
880            tools: vec![Tool {
881                name: "t".to_string(),
882                description: None,
883                input_schema: serde_json::json!({}),
884                output_schema: None,
885                icon: None,
886                version: None,
887                tags: vec![],
888                annotations: None,
889            }],
890            resources: vec![Resource {
891                uri: "test://r".to_string(),
892                name: "r".to_string(),
893                description: None,
894                mime_type: None,
895                icon: None,
896                version: None,
897                tags: vec![],
898            }],
899            prompts: vec![Prompt {
900                name: "p".to_string(),
901                description: None,
902                arguments: Vec::new(),
903                icon: None,
904                version: None,
905                tags: vec![],
906            }],
907            ..TestBackend::default()
908        };
909        let proxy = ProxyClient::from_backend(backend);
910        let catalog = proxy.catalog().expect("catalog");
911        assert_eq!(catalog.tools.len(), 1);
912        assert_eq!(catalog.resources.len(), 1);
913        assert_eq!(catalog.prompts.len(), 1);
914    }
915
916    // =========================================================================
917    // ProxyResourceHandler Tests
918    // =========================================================================
919
920    #[test]
921    fn proxy_resource_handler_read_forwards_to_backend() {
922        use super::ProxyResourceHandler;
923        use crate::handler::ResourceHandler;
924
925        let backend = TestBackend::default();
926        let proxy = ProxyClient::from_backend(backend);
927        let handler = ProxyResourceHandler::new(
928            Resource {
929                uri: "test://resource".to_string(),
930                name: "Test".to_string(),
931                description: None,
932                mime_type: None,
933                icon: None,
934                version: None,
935                tags: vec![],
936            },
937            proxy,
938        );
939
940        let ctx = McpContext::new(Cx::for_testing(), 1);
941        let result = handler.read(&ctx).expect("read ok");
942        assert_eq!(result.len(), 1);
943        assert_eq!(result[0].text, Some("resource".to_string()));
944    }
945
946    #[test]
947    fn proxy_resource_handler_no_template_by_default() {
948        use super::ProxyResourceHandler;
949        use crate::handler::ResourceHandler;
950
951        let backend = TestBackend::default();
952        let proxy = ProxyClient::from_backend(backend);
953        let handler = ProxyResourceHandler::new(
954            Resource {
955                uri: "test://x".to_string(),
956                name: "x".to_string(),
957                description: None,
958                mime_type: None,
959                icon: None,
960                version: None,
961                tags: vec![],
962            },
963            proxy,
964        );
965        assert!(handler.template().is_none());
966    }
967
968    #[test]
969    fn proxy_resource_handler_from_template() {
970        use super::ProxyResourceHandler;
971        use crate::handler::ResourceHandler;
972        use fastmcp_protocol::ResourceTemplate;
973
974        let backend = TestBackend::default();
975        let proxy = ProxyClient::from_backend(backend);
976        let template = ResourceTemplate {
977            uri_template: "file://{path}".to_string(),
978            name: "File".to_string(),
979            description: Some("A file resource".to_string()),
980            mime_type: Some("text/plain".to_string()),
981            icon: None,
982            version: None,
983            tags: vec![],
984        };
985        let handler = ProxyResourceHandler::from_template(template.clone(), proxy);
986
987        // Definition should mirror the template
988        let def = handler.definition();
989        assert_eq!(def.uri, "file://{path}");
990        assert_eq!(def.name, "File");
991        assert_eq!(def.description, Some("A file resource".to_string()));
992        assert_eq!(def.mime_type, Some("text/plain".to_string()));
993
994        // Template should be available
995        let tmpl = handler.template().expect("has template");
996        assert_eq!(tmpl.uri_template, "file://{path}");
997    }
998
999    #[test]
1000    fn proxy_resource_handler_from_template_with_prefix() {
1001        use super::ProxyResourceHandler;
1002        use crate::handler::ResourceHandler;
1003        use fastmcp_protocol::ResourceTemplate;
1004
1005        let backend = TestBackend::default();
1006        let proxy = ProxyClient::from_backend(backend);
1007        let template = ResourceTemplate {
1008            uri_template: "file://{path}".to_string(),
1009            name: "File".to_string(),
1010            description: None,
1011            mime_type: None,
1012            icon: None,
1013            version: None,
1014            tags: vec![],
1015        };
1016        let handler = ProxyResourceHandler::from_template_with_prefix(template, "storage", proxy);
1017
1018        // Definition should have prefixed URI template
1019        let def = handler.definition();
1020        assert_eq!(def.uri, "storage/file://{path}");
1021
1022        // Template should also be prefixed
1023        let tmpl = handler.template().expect("has template");
1024        assert_eq!(tmpl.uri_template, "storage/file://{path}");
1025    }
1026
1027    // =========================================================================
1028    // Error Propagation Tests
1029    // =========================================================================
1030
1031    /// A backend that always returns errors.
1032    struct FailingBackend;
1033
1034    impl ProxyBackend for FailingBackend {
1035        fn list_tools(&mut self) -> fastmcp_core::McpResult<Vec<Tool>> {
1036            Err(fastmcp_core::McpError::internal_error("tool list failed"))
1037        }
1038
1039        fn list_resources(&mut self) -> fastmcp_core::McpResult<Vec<Resource>> {
1040            Err(fastmcp_core::McpError::internal_error(
1041                "resource list failed",
1042            ))
1043        }
1044
1045        fn list_resource_templates(
1046            &mut self,
1047        ) -> fastmcp_core::McpResult<Vec<fastmcp_protocol::ResourceTemplate>> {
1048            Err(fastmcp_core::McpError::internal_error(
1049                "template list failed",
1050            ))
1051        }
1052
1053        fn list_prompts(&mut self) -> fastmcp_core::McpResult<Vec<Prompt>> {
1054            Err(fastmcp_core::McpError::internal_error("prompt list failed"))
1055        }
1056
1057        fn call_tool(
1058            &mut self,
1059            _name: &str,
1060            _arguments: serde_json::Value,
1061        ) -> fastmcp_core::McpResult<Vec<Content>> {
1062            Err(fastmcp_core::McpError::internal_error("tool call failed"))
1063        }
1064
1065        fn call_tool_with_progress(
1066            &mut self,
1067            _name: &str,
1068            _arguments: serde_json::Value,
1069            _on_progress: super::ProgressCallback<'_>,
1070        ) -> fastmcp_core::McpResult<Vec<Content>> {
1071            Err(fastmcp_core::McpError::internal_error("tool call failed"))
1072        }
1073
1074        fn read_resource(&mut self, _uri: &str) -> fastmcp_core::McpResult<Vec<ResourceContent>> {
1075            Err(fastmcp_core::McpError::internal_error(
1076                "resource read failed",
1077            ))
1078        }
1079
1080        fn get_prompt(
1081            &mut self,
1082            _name: &str,
1083            _arguments: HashMap<String, String>,
1084        ) -> fastmcp_core::McpResult<Vec<PromptMessage>> {
1085            Err(fastmcp_core::McpError::internal_error("prompt get failed"))
1086        }
1087    }
1088
1089    #[test]
1090    fn proxy_catalog_propagates_tool_list_error() {
1091        let mut backend = FailingBackend;
1092        let result = ProxyCatalog::from_backend(&mut backend);
1093        assert!(result.is_err());
1094        let err = result.unwrap_err();
1095        assert!(err.message.contains("tool list failed"));
1096    }
1097
1098    #[test]
1099    fn proxy_tool_handler_propagates_call_error() {
1100        let proxy = ProxyClient::from_backend(FailingBackend);
1101        let handler = ProxyToolHandler::new(
1102            Tool {
1103                name: "fail".to_string(),
1104                description: None,
1105                input_schema: serde_json::json!({}),
1106                output_schema: None,
1107                icon: None,
1108                version: None,
1109                tags: vec![],
1110                annotations: None,
1111            },
1112            proxy,
1113        );
1114
1115        let ctx = McpContext::new(Cx::for_testing(), 1);
1116        let result = handler.call(&ctx, serde_json::json!({}));
1117        assert!(result.is_err());
1118        assert!(result.unwrap_err().message.contains("tool call failed"));
1119    }
1120
1121    #[test]
1122    fn proxy_resource_handler_propagates_read_error() {
1123        use super::ProxyResourceHandler;
1124        use crate::handler::ResourceHandler;
1125
1126        let proxy = ProxyClient::from_backend(FailingBackend);
1127        let handler = ProxyResourceHandler::new(
1128            Resource {
1129                uri: "test://fail".to_string(),
1130                name: "Fail".to_string(),
1131                description: None,
1132                mime_type: None,
1133                icon: None,
1134                version: None,
1135                tags: vec![],
1136            },
1137            proxy,
1138        );
1139
1140        let ctx = McpContext::new(Cx::for_testing(), 1);
1141        let result = handler.read(&ctx);
1142        assert!(result.is_err());
1143        assert!(result.unwrap_err().message.contains("resource read failed"));
1144    }
1145
1146    #[test]
1147    fn proxy_prompt_handler_propagates_get_error() {
1148        let proxy = ProxyClient::from_backend(FailingBackend);
1149        let handler = ProxyPromptHandler::new(
1150            Prompt {
1151                name: "fail".to_string(),
1152                description: None,
1153                arguments: Vec::new(),
1154                icon: None,
1155                version: None,
1156                tags: vec![],
1157            },
1158            proxy,
1159        );
1160
1161        let ctx = McpContext::new(Cx::for_testing(), 1);
1162        let result = handler.get(&ctx, HashMap::new());
1163        assert!(result.is_err());
1164        assert!(result.unwrap_err().message.contains("prompt get failed"));
1165    }
1166
1167    // =========================================================================
1168    // resource_from_template Helper
1169    // =========================================================================
1170
1171    #[test]
1172    fn resource_from_template_copies_all_fields() {
1173        use fastmcp_protocol::ResourceTemplate;
1174
1175        let template = ResourceTemplate {
1176            uri_template: "db://{table}/{id}".to_string(),
1177            name: "Database Record".to_string(),
1178            description: Some("A database record".to_string()),
1179            mime_type: Some("application/json".to_string()),
1180            icon: None,
1181            version: Some("1.0.0".to_string()),
1182            tags: vec!["db".to_string()],
1183        };
1184        let resource = super::resource_from_template(&template);
1185        assert_eq!(resource.uri, "db://{table}/{id}");
1186        assert_eq!(resource.name, "Database Record");
1187        assert_eq!(resource.description, Some("A database record".to_string()));
1188        assert_eq!(resource.mime_type, Some("application/json".to_string()));
1189        assert_eq!(resource.version, Some("1.0.0".to_string()));
1190        assert_eq!(resource.tags, vec!["db".to_string()]);
1191    }
1192}