mcp_spec/
resource.rs

1/// Resources that servers provide to clients
2use anyhow::{anyhow, Result};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use url::Url;
6
7use crate::content::Annotations;
8
9const EPSILON: f32 = 1e-6; // Tolerance for floating point comparison
10
11/// Represents a resource in the extension with metadata
12#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
13#[serde(rename_all = "camelCase")]
14pub struct Resource {
15    /// URI representing the resource location (e.g., "file:///path/to/file" or "str:///content")
16    pub uri: String,
17    /// Name of the resource
18    pub name: String,
19    /// Optional description of the resource
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub description: Option<String>,
22    /// MIME type of the resource content ("text" or "blob")
23    #[serde(default = "default_mime_type")]
24    pub mime_type: String,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub annotations: Option<Annotations>,
27}
28
29#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
30#[serde(rename_all = "camelCase", untagged)]
31pub enum ResourceContents {
32    TextResourceContents {
33        uri: String,
34        #[serde(skip_serializing_if = "Option::is_none")]
35        mime_type: Option<String>,
36        text: String,
37    },
38    BlobResourceContents {
39        uri: String,
40        #[serde(skip_serializing_if = "Option::is_none")]
41        mime_type: Option<String>,
42        blob: String,
43    },
44}
45
46fn default_mime_type() -> String {
47    "text".to_string()
48}
49
50impl Resource {
51    /// Creates a new Resource from a URI with explicit mime type
52    pub fn new<S: AsRef<str>>(
53        uri: S,
54        mime_type: Option<String>,
55        name: Option<String>,
56    ) -> Result<Self> {
57        let uri = uri.as_ref();
58        let url = Url::parse(uri).map_err(|e| anyhow!("Invalid URI: {}", e))?;
59
60        // Extract name from the path component of the URI
61        // Use provided name if available, otherwise extract from URI
62        let name = match name {
63            Some(n) => n,
64            None => url
65                .path_segments()
66                .and_then(|segments| segments.last())
67                .unwrap_or("unnamed")
68                .to_string(),
69        };
70
71        // Use provided mime_type or default
72        let mime_type = match mime_type {
73            Some(t) if t == "text" || t == "blob" => t,
74            _ => default_mime_type(),
75        };
76
77        Ok(Self {
78            uri: uri.to_string(),
79            name,
80            description: None,
81            mime_type,
82            annotations: Some(Annotations::for_resource(0.0, Utc::now())),
83        })
84    }
85
86    /// Creates a new Resource with explicit URI, name, and priority
87    pub fn with_uri<S: Into<String>>(
88        uri: S,
89        name: S,
90        priority: f32,
91        mime_type: Option<String>,
92    ) -> Result<Self> {
93        let uri_string = uri.into();
94        Url::parse(&uri_string).map_err(|e| anyhow!("Invalid URI: {}", e))?;
95
96        // Use provided mime_type or default
97        let mime_type = match mime_type {
98            Some(t) if t == "text" || t == "blob" => t,
99            _ => default_mime_type(),
100        };
101
102        Ok(Self {
103            uri: uri_string,
104            name: name.into(),
105            description: None,
106            mime_type,
107            annotations: Some(Annotations::for_resource(priority, Utc::now())),
108        })
109    }
110
111    /// Updates the resource's timestamp to the current time
112    pub fn update_timestamp(&mut self) {
113        self.annotations.as_mut().unwrap().timestamp = Some(Utc::now());
114    }
115
116    /// Sets the priority of the resource and returns self for method chaining
117    pub fn with_priority(mut self, priority: f32) -> Self {
118        self.annotations.as_mut().unwrap().priority = Some(priority);
119        self
120    }
121
122    /// Mark the resource as active, i.e. set its priority to 1.0
123    pub fn mark_active(self) -> Self {
124        self.with_priority(1.0)
125    }
126
127    // Check if the resource is active
128    pub fn is_active(&self) -> bool {
129        if let Some(priority) = self.priority() {
130            (priority - 1.0).abs() < EPSILON
131        } else {
132            false
133        }
134    }
135
136    /// Returns the priority of the resource, if set
137    pub fn priority(&self) -> Option<f32> {
138        self.annotations.as_ref().and_then(|a| a.priority)
139    }
140
141    /// Returns the timestamp of the resource, if set
142    pub fn timestamp(&self) -> Option<DateTime<Utc>> {
143        self.annotations.as_ref().and_then(|a| a.timestamp)
144    }
145
146    /// Returns the scheme of the URI
147    pub fn scheme(&self) -> Result<String> {
148        let url = Url::parse(&self.uri)?;
149        Ok(url.scheme().to_string())
150    }
151
152    /// Sets the description of the resource
153    pub fn with_description<S: Into<String>>(mut self, description: S) -> Self {
154        self.description = Some(description.into());
155        self
156    }
157
158    /// Sets the MIME type of the resource
159    pub fn with_mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
160        let mime_type = mime_type.into();
161        match mime_type.as_str() {
162            "text" | "blob" => self.mime_type = mime_type,
163            _ => self.mime_type = default_mime_type(),
164        }
165        self
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::io::Write;
173    use tempfile::NamedTempFile;
174
175    #[test]
176    fn test_new_resource_with_file_uri() -> Result<()> {
177        let mut temp_file = NamedTempFile::new()?;
178        writeln!(temp_file, "test content")?;
179
180        let uri = Url::from_file_path(temp_file.path())
181            .map_err(|_| anyhow!("Invalid file path"))?
182            .to_string();
183
184        let resource = Resource::new(&uri, Some("text".to_string()), None)?;
185        assert!(resource.uri.starts_with("file:///"));
186        assert_eq!(resource.priority(), Some(0.0));
187        assert_eq!(resource.mime_type, "text");
188        assert_eq!(resource.scheme()?, "file");
189
190        Ok(())
191    }
192
193    #[test]
194    fn test_resource_with_str_uri() -> Result<()> {
195        let test_content = "Hello, world!";
196        let uri = format!("str:///{}", test_content);
197        let resource = Resource::with_uri(
198            uri.clone(),
199            "test.txt".to_string(),
200            0.5,
201            Some("text".to_string()),
202        )?;
203
204        assert_eq!(resource.uri, uri);
205        assert_eq!(resource.name, "test.txt");
206        assert_eq!(resource.priority(), Some(0.5));
207        assert_eq!(resource.mime_type, "text");
208        assert_eq!(resource.scheme()?, "str");
209
210        Ok(())
211    }
212
213    #[test]
214    fn test_mime_type_validation() -> Result<()> {
215        // Test valid mime types
216        let resource = Resource::new("file:///test.txt", Some("text".to_string()), None)?;
217        assert_eq!(resource.mime_type, "text");
218
219        let resource = Resource::new("file:///test.bin", Some("blob".to_string()), None)?;
220        assert_eq!(resource.mime_type, "blob");
221
222        // Test invalid mime type defaults to "text"
223        let resource = Resource::new("file:///test.txt", Some("invalid".to_string()), None)?;
224        assert_eq!(resource.mime_type, "text");
225
226        // Test None defaults to "text"
227        let resource = Resource::new("file:///test.txt", None, None)?;
228        assert_eq!(resource.mime_type, "text");
229
230        Ok(())
231    }
232
233    #[test]
234    fn test_with_description() -> Result<()> {
235        let resource = Resource::with_uri("file:///test.txt", "test.txt", 0.0, None)?
236            .with_description("A test resource");
237
238        assert_eq!(resource.description, Some("A test resource".to_string()));
239        Ok(())
240    }
241
242    #[test]
243    fn test_with_mime_type() -> Result<()> {
244        let resource =
245            Resource::with_uri("file:///test.txt", "test.txt", 0.0, None)?.with_mime_type("blob");
246
247        assert_eq!(resource.mime_type, "blob");
248
249        // Test invalid mime type defaults to "text"
250        let resource = resource.with_mime_type("invalid");
251        assert_eq!(resource.mime_type, "text");
252        Ok(())
253    }
254
255    #[test]
256    fn test_invalid_uri() {
257        let result = Resource::new("not-a-uri", None, None);
258        assert!(result.is_err());
259    }
260}