turul_mcp_builders/
resource.rs

1//! Resource Builder for Runtime Resource Construction
2//!
3//! This module provides a builder pattern for creating resources at runtime
4//! without requiring procedural macros. This enables dynamic resource creation
5//! for configuration-driven systems.
6
7use serde_json::Value;
8use std::collections::HashMap;
9use std::future::Future;
10use std::pin::Pin;
11
12// Import traits from local traits module
13use crate::traits::{
14    HasResourceAnnotations, HasResourceDescription, HasResourceMeta, HasResourceMetadata,
15    HasResourceMimeType, HasResourceSize, HasResourceUri,
16};
17// Import protocol types
18use turul_mcp_protocol::meta::Annotations;
19use turul_mcp_protocol::resources::ResourceContent;
20
21/// Type alias for dynamic resource read function
22pub type DynamicResourceFn = Box<
23    dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<ResourceContent, String>> + Send>>
24        + Send
25        + Sync,
26>;
27
28/// Builder for creating resources at runtime
29pub struct ResourceBuilder {
30    uri: String,
31    name: String,
32    title: Option<String>,
33    description: Option<String>,
34    mime_type: Option<String>,
35    size: Option<u64>,
36    content: Option<ResourceContent>,
37    annotations: Option<Annotations>,
38    meta: Option<HashMap<String, Value>>,
39    read_fn: Option<DynamicResourceFn>,
40}
41
42impl ResourceBuilder {
43    /// Create a new resource builder with the given URI and name
44    pub fn new(uri: impl Into<String>) -> Self {
45        let uri = uri.into();
46        // Extract a reasonable default name from the URI
47        let name = uri.split('/').next_back().unwrap_or(&uri).to_string();
48
49        Self {
50            uri,
51            name,
52            title: None,
53            description: None,
54            mime_type: None,
55            size: None,
56            content: None,
57            annotations: None,
58            meta: None,
59            read_fn: None,
60        }
61    }
62
63    /// Set the resource name (programmatic identifier)
64    pub fn name(mut self, name: impl Into<String>) -> Self {
65        self.name = name.into();
66        self
67    }
68
69    /// Set the resource title (display name)
70    pub fn title(mut self, title: impl Into<String>) -> Self {
71        self.title = Some(title.into());
72        self
73    }
74
75    /// Set the resource description
76    pub fn description(mut self, description: impl Into<String>) -> Self {
77        self.description = Some(description.into());
78        self
79    }
80
81    /// Set the MIME type
82    pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
83        self.mime_type = Some(mime_type.into());
84        self
85    }
86
87    /// Set the resource size in bytes
88    pub fn size(mut self, size: u64) -> Self {
89        self.size = Some(size);
90        self
91    }
92
93    /// Set static text content for this resource
94    pub fn text_content(mut self, text: impl Into<String>) -> Self {
95        let text = text.into();
96        self.size = Some(text.len() as u64);
97        if self.mime_type.is_none() {
98            self.mime_type = Some("text/plain".to_string());
99        }
100        self.content = Some(ResourceContent::text(&self.uri, text));
101        self
102    }
103
104    /// Set static JSON content for this resource
105    pub fn json_content(mut self, json_value: Value) -> Self {
106        let text = serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| "{}".to_string());
107        self.size = Some(text.len() as u64);
108        self.mime_type = Some("application/json".to_string());
109        self.content = Some(ResourceContent::text(&self.uri, text));
110        self
111    }
112
113    /// Set static blob content for this resource (base64-encoded)
114    pub fn blob_content(mut self, blob: impl Into<String>, mime_type: impl Into<String>) -> Self {
115        let blob = blob.into();
116        let mime_type = mime_type.into();
117
118        // Estimate size from base64 (approximately 3/4 of encoded length)
119        self.size = Some((blob.len() * 3 / 4) as u64);
120        self.mime_type = Some(mime_type.clone());
121        self.content = Some(ResourceContent::blob(&self.uri, blob, mime_type));
122        self
123    }
124
125    /// Set annotations
126    pub fn annotations(mut self, annotations: Annotations) -> Self {
127        self.annotations = Some(annotations);
128        self
129    }
130
131    /// Add annotation title (only field currently supported in Annotations)
132    pub fn annotation_title(mut self, title: impl Into<String>) -> Self {
133        let mut annotations = self.annotations.unwrap_or_default();
134        annotations.title = Some(title.into());
135        self.annotations = Some(annotations);
136        self
137    }
138
139    /// Set meta information
140    pub fn meta(mut self, meta: HashMap<String, Value>) -> Self {
141        self.meta = Some(meta);
142        self
143    }
144
145    /// Set the read function for dynamic content
146    pub fn read<F, Fut>(mut self, f: F) -> Self
147    where
148        F: Fn(String) -> Fut + Send + Sync + 'static,
149        Fut: Future<Output = Result<ResourceContent, String>> + Send + 'static,
150    {
151        self.read_fn = Some(Box::new(move |uri| Box::pin(f(uri))));
152        self
153    }
154
155    /// Convenience method to set a text read function
156    pub fn read_text<F, Fut>(mut self, f: F) -> Self
157    where
158        F: Fn(String) -> Fut + Send + Sync + 'static + Clone,
159        Fut: Future<Output = Result<String, String>> + Send + 'static,
160    {
161        self.read_fn = Some(Box::new(move |uri| {
162            let f = f.clone();
163            let uri_clone = uri.clone();
164            Box::pin(async move {
165                let text = f(uri.clone()).await?;
166                Ok(ResourceContent::text(uri_clone, text))
167            })
168        }));
169        self
170    }
171
172    /// Build the dynamic resource
173    pub fn build(self) -> Result<DynamicResource, String> {
174        Ok(DynamicResource {
175            uri: self.uri,
176            name: self.name,
177            title: self.title,
178            description: self.description,
179            mime_type: self.mime_type,
180            size: self.size,
181            content: self.content,
182            annotations: self.annotations,
183            meta: self.meta,
184            read_fn: self.read_fn,
185        })
186    }
187}
188
189/// Dynamic resource created by ResourceBuilder
190pub struct DynamicResource {
191    uri: String,
192    name: String,
193    title: Option<String>,
194    description: Option<String>,
195    mime_type: Option<String>,
196    size: Option<u64>,
197    content: Option<ResourceContent>,
198    annotations: Option<Annotations>,
199    meta: Option<HashMap<String, Value>>,
200    read_fn: Option<DynamicResourceFn>,
201}
202
203impl DynamicResource {
204    /// Read the resource content
205    pub async fn read(&self) -> Result<ResourceContent, String> {
206        if let Some(ref content) = self.content {
207            // Static content
208            Ok(content.clone())
209        } else if let Some(ref read_fn) = self.read_fn {
210            // Dynamic content
211            read_fn(self.uri.clone()).await
212        } else {
213            Err("No content or read function provided".to_string())
214        }
215    }
216}
217
218// Implement all fine-grained traits for DynamicResource
219/// Implements HasResourceMetadata for DynamicResource providing name and title access
220impl HasResourceMetadata for DynamicResource {
221    fn name(&self) -> &str {
222        &self.name
223    }
224
225    fn title(&self) -> Option<&str> {
226        self.title.as_deref()
227    }
228}
229
230/// Implements HasResourceDescription for DynamicResource providing description text
231impl HasResourceDescription for DynamicResource {
232    fn description(&self) -> Option<&str> {
233        self.description.as_deref()
234    }
235}
236
237/// Implements HasResourceUri for DynamicResource providing URI access
238impl HasResourceUri for DynamicResource {
239    fn uri(&self) -> &str {
240        &self.uri
241    }
242}
243
244/// Implements HasResourceMimeType for DynamicResource providing MIME type information
245impl HasResourceMimeType for DynamicResource {
246    fn mime_type(&self) -> Option<&str> {
247        self.mime_type.as_deref()
248    }
249}
250
251/// Implements HasResourceSize for DynamicResource providing content size information
252impl HasResourceSize for DynamicResource {
253    fn size(&self) -> Option<u64> {
254        self.size
255    }
256}
257
258/// Implements HasResourceAnnotations for DynamicResource providing metadata annotations
259impl HasResourceAnnotations for DynamicResource {
260    fn annotations(&self) -> Option<&Annotations> {
261        self.annotations.as_ref()
262    }
263}
264
265/// Implements HasResourceMeta for DynamicResource providing additional metadata fields
266impl HasResourceMeta for DynamicResource {
267    fn resource_meta(&self) -> Option<&HashMap<String, Value>> {
268        self.meta.as_ref()
269    }
270}
271
272// ResourceDefinition is automatically implemented via blanket impl!
273
274// Note: McpResource implementation will be provided by the turul-mcp-server crate
275// since it depends on types from that crate (SessionContext, etc.)
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use serde_json::json;
281
282    #[test]
283    fn test_resource_builder_basic() {
284        let resource = ResourceBuilder::new("file:///test.txt")
285            .name("test_resource")
286            .description("A test resource")
287            .text_content("Hello, World!")
288            .build()
289            .expect("Failed to build resource");
290
291        assert_eq!(resource.name(), "test_resource");
292        assert_eq!(resource.uri(), "file:///test.txt");
293        assert_eq!(resource.description(), Some("A test resource"));
294        assert_eq!(resource.mime_type(), Some("text/plain"));
295        assert_eq!(resource.size(), Some(13)); // Length of "Hello, World!"
296    }
297
298    #[tokio::test]
299    async fn test_resource_builder_static_content() {
300        let resource = ResourceBuilder::new("file:///config.json")
301            .description("Application configuration")
302            .json_content(json!({"version": "1.0", "debug": true}))
303            .build()
304            .expect("Failed to build resource");
305
306        let content = resource.read().await.expect("Failed to read content");
307
308        match content {
309            ResourceContent::Text(text_content) => {
310                assert!(text_content.text.contains("version"));
311                assert!(text_content.text.contains("1.0"));
312                assert_eq!(text_content.uri, "file:///config.json");
313            }
314            _ => panic!("Expected text content"),
315        }
316
317        // Verify the resource itself has the correct MIME type
318        assert_eq!(resource.mime_type(), Some("application/json"));
319    }
320
321    #[tokio::test]
322    async fn test_resource_builder_dynamic_content() {
323        let resource = ResourceBuilder::new("file:///dynamic.txt")
324            .description("Dynamic content resource")
325            .read_text(|_uri| async move { Ok("This is dynamic content!".to_string()) })
326            .build()
327            .expect("Failed to build resource");
328
329        let content = resource.read().await.expect("Failed to read content");
330
331        match content {
332            ResourceContent::Text(text_content) => {
333                assert_eq!(text_content.text, "This is dynamic content!");
334            }
335            _ => panic!("Expected text content"),
336        }
337    }
338
339    #[test]
340    fn test_resource_builder_annotations() {
341        let resource = ResourceBuilder::new("file:///important.txt")
342            .description("Important resource")
343            .annotation_title("Important File")
344            .build()
345            .expect("Failed to build resource");
346
347        let annotations = resource.annotations().expect("Expected annotations");
348        assert_eq!(annotations.title, Some("Important File".to_string()));
349    }
350
351    #[test]
352    fn test_resource_builder_blob_content() {
353        let resource = ResourceBuilder::new("data://example.png")
354            .description("Example image")
355            .blob_content("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "image/png")
356            .build()
357            .expect("Failed to build resource");
358
359        assert_eq!(resource.mime_type(), Some("image/png"));
360        assert!(resource.size().unwrap() > 0);
361    }
362}