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