1use async_trait::async_trait;
7use std::collections::HashMap;
8
9use crate::core::error::{McpError, McpResult};
10use crate::protocol::types::{ResourceContent, ResourceInfo};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct ResourceTemplate {
15 pub uri_template: String,
17 pub name: String,
19 pub description: Option<String>,
21 pub mime_type: Option<String>,
23}
24
25#[async_trait]
27pub trait ResourceHandler: Send + Sync {
28 async fn read(
37 &self,
38 uri: &str,
39 params: &HashMap<String, String>,
40 ) -> McpResult<Vec<ResourceContent>>;
41
42 async fn list(&self) -> McpResult<Vec<ResourceInfo>>;
47
48 async fn subscribe(&self, uri: &str) -> McpResult<()> {
56 Err(McpError::protocol(format!(
58 "Subscription not supported for resource: {}",
59 uri
60 )))
61 }
62
63 async fn unsubscribe(&self, uri: &str) -> McpResult<()> {
71 Err(McpError::protocol(format!(
73 "Subscription not supported for resource: {}",
74 uri
75 )))
76 }
77}
78
79pub struct Resource {
81 pub info: ResourceInfo,
83 pub handler: Box<dyn ResourceHandler>,
85 pub template: Option<ResourceTemplate>,
87 pub enabled: bool,
89}
90
91impl Resource {
92 pub fn new<H>(info: ResourceInfo, handler: H) -> Self
98 where
99 H: ResourceHandler + 'static,
100 {
101 Self {
102 info,
103 handler: Box::new(handler),
104 template: None,
105 enabled: true,
106 }
107 }
108
109 pub fn with_template<H>(template: ResourceTemplate, handler: H) -> Self
115 where
116 H: ResourceHandler + 'static,
117 {
118 let info = ResourceInfo {
119 uri: template.uri_template.clone(),
120 name: template.name.clone(),
121 description: template.description.clone(),
122 mime_type: template.mime_type.clone(),
123 };
124
125 Self {
126 info,
127 handler: Box::new(handler),
128 template: Some(template),
129 enabled: true,
130 }
131 }
132
133 pub fn enable(&mut self) {
135 self.enabled = true;
136 }
137
138 pub fn disable(&mut self) {
140 self.enabled = false;
141 }
142
143 pub fn is_enabled(&self) -> bool {
145 self.enabled
146 }
147
148 pub async fn read(
157 &self,
158 uri: &str,
159 params: &HashMap<String, String>,
160 ) -> McpResult<Vec<ResourceContent>> {
161 if !self.enabled {
162 return Err(McpError::validation(format!(
163 "Resource '{}' is disabled",
164 self.info.name
165 )));
166 }
167
168 self.handler.read(uri, params).await
169 }
170
171 pub async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
173 if !self.enabled {
174 return Ok(vec![]);
175 }
176
177 self.handler.list().await
178 }
179
180 pub async fn subscribe(&self, uri: &str) -> McpResult<()> {
182 if !self.enabled {
183 return Err(McpError::validation(format!(
184 "Resource '{}' is disabled",
185 self.info.name
186 )));
187 }
188
189 self.handler.subscribe(uri).await
190 }
191
192 pub async fn unsubscribe(&self, uri: &str) -> McpResult<()> {
194 if !self.enabled {
195 return Err(McpError::validation(format!(
196 "Resource '{}' is disabled",
197 self.info.name
198 )));
199 }
200
201 self.handler.unsubscribe(uri).await
202 }
203
204 pub fn matches_uri(&self, uri: &str) -> bool {
206 if let Some(template) = &self.template {
207 uri.starts_with(&template.uri_template.replace("{id}", "").replace("{*}", ""))
210 } else {
211 self.info.uri == uri
212 }
213 }
214}
215
216impl std::fmt::Debug for Resource {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 f.debug_struct("Resource")
219 .field("info", &self.info)
220 .field("template", &self.template)
221 .field("enabled", &self.enabled)
222 .finish()
223 }
224}
225
226pub struct TextResource {
230 content: String,
231 mime_type: String,
232}
233
234impl TextResource {
235 pub fn new(content: String, mime_type: Option<String>) -> Self {
237 Self {
238 content,
239 mime_type: mime_type.unwrap_or_else(|| "text/plain".to_string()),
240 }
241 }
242}
243
244#[async_trait]
245impl ResourceHandler for TextResource {
246 async fn read(
247 &self,
248 uri: &str,
249 _params: &HashMap<String, String>,
250 ) -> McpResult<Vec<ResourceContent>> {
251 Ok(vec![ResourceContent {
252 uri: uri.to_string(),
253 mime_type: Some(self.mime_type.clone()),
254 text: Some(self.content.clone()),
255 blob: None,
256 }])
257 }
258
259 async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
260 Ok(vec![])
262 }
263}
264
265pub struct FileSystemResource {
267 base_path: std::path::PathBuf,
268 allowed_extensions: Option<Vec<String>>,
269}
270
271impl FileSystemResource {
272 pub fn new<P: AsRef<std::path::Path>>(base_path: P) -> Self {
274 Self {
275 base_path: base_path.as_ref().to_path_buf(),
276 allowed_extensions: None,
277 }
278 }
279
280 pub fn with_extensions(mut self, extensions: Vec<String>) -> Self {
282 self.allowed_extensions = Some(extensions);
283 self
284 }
285
286 fn is_allowed_file(&self, path: &std::path::Path) -> bool {
287 if let Some(ref allowed) = self.allowed_extensions {
288 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
289 return allowed.contains(&ext.to_lowercase());
290 }
291 return false;
292 }
293 true
294 }
295
296 fn get_mime_type(&self, path: &std::path::Path) -> String {
297 match path.extension().and_then(|e| e.to_str()) {
298 Some("txt") => "text/plain".to_string(),
299 Some("json") => "application/json".to_string(),
300 Some("html") => "text/html".to_string(),
301 Some("css") => "text/css".to_string(),
302 Some("js") => "application/javascript".to_string(),
303 Some("md") => "text/markdown".to_string(),
304 Some("xml") => "application/xml".to_string(),
305 Some("yaml") | Some("yml") => "application/yaml".to_string(),
306 _ => "application/octet-stream".to_string(),
307 }
308 }
309}
310
311#[async_trait]
312impl ResourceHandler for FileSystemResource {
313 async fn read(
314 &self,
315 uri: &str,
316 _params: &HashMap<String, String>,
317 ) -> McpResult<Vec<ResourceContent>> {
318 let file_path = if uri.starts_with("file://") {
320 uri.strip_prefix("file://").unwrap_or(uri)
321 } else {
322 uri
323 };
324
325 let full_path = self.base_path.join(file_path);
326
327 let canonical_base = self.base_path.canonicalize().map_err(|e| McpError::io(e))?;
329 let canonical_target = full_path
330 .canonicalize()
331 .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
332
333 if !canonical_target.starts_with(&canonical_base) {
334 return Err(McpError::validation("Path outside of allowed directory"));
335 }
336
337 if !self.is_allowed_file(&canonical_target) {
338 return Err(McpError::validation("File type not allowed"));
339 }
340
341 let content = tokio::fs::read_to_string(&canonical_target)
342 .await
343 .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
344
345 let mime_type = self.get_mime_type(&canonical_target);
346
347 Ok(vec![ResourceContent {
348 uri: uri.to_string(),
349 mime_type: Some(mime_type),
350 text: Some(content),
351 blob: None,
352 }])
353 }
354
355 async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
356 let mut resources = Vec::new();
357 let mut stack = vec![self.base_path.clone()];
358
359 while let Some(dir_path) = stack.pop() {
360 let mut dir = tokio::fs::read_dir(&dir_path)
361 .await
362 .map_err(|e| McpError::io(e))?;
363
364 while let Some(entry) = dir.next_entry().await.map_err(|e| McpError::io(e))? {
365 let path = entry.path();
366
367 if path.is_dir() {
368 stack.push(path);
369 } else if self.is_allowed_file(&path) {
370 let relative_path = path
371 .strip_prefix(&self.base_path)
372 .map_err(|_| McpError::internal("Path computation error"))?;
373
374 let uri = format!("file://{}", relative_path.display());
375 let name = path
376 .file_name()
377 .and_then(|n| n.to_str())
378 .unwrap_or("unnamed")
379 .to_string();
380
381 resources.push(ResourceInfo {
382 uri,
383 name,
384 description: None,
385 mime_type: Some(self.get_mime_type(&path)),
386 });
387 }
388 }
389 }
390
391 Ok(resources)
392 }
393}
394
395pub struct ResourceBuilder {
397 uri: String,
398 name: String,
399 description: Option<String>,
400 mime_type: Option<String>,
401}
402
403impl ResourceBuilder {
404 pub fn new<S: Into<String>>(uri: S, name: S) -> Self {
406 Self {
407 uri: uri.into(),
408 name: name.into(),
409 description: None,
410 mime_type: None,
411 }
412 }
413
414 pub fn description<S: Into<String>>(mut self, description: S) -> Self {
416 self.description = Some(description.into());
417 self
418 }
419
420 pub fn mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
422 self.mime_type = Some(mime_type.into());
423 self
424 }
425
426 pub fn build<H>(self, handler: H) -> Resource
428 where
429 H: ResourceHandler + 'static,
430 {
431 let info = ResourceInfo {
432 uri: self.uri,
433 name: self.name,
434 description: self.description,
435 mime_type: self.mime_type,
436 };
437
438 Resource::new(info, handler)
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[tokio::test]
447 async fn test_text_resource() {
448 let resource =
449 TextResource::new("Hello, World!".to_string(), Some("text/plain".to_string()));
450 let params = HashMap::new();
451
452 let content = resource.read("test://resource", ¶ms).await.unwrap();
453 assert_eq!(content.len(), 1);
454 assert_eq!(content[0].text, Some("Hello, World!".to_string()));
455 assert_eq!(content[0].mime_type, Some("text/plain".to_string()));
456 }
457
458 #[test]
459 fn test_resource_creation() {
460 let info = ResourceInfo {
461 uri: "test://resource".to_string(),
462 name: "Test Resource".to_string(),
463 description: Some("A test resource".to_string()),
464 mime_type: Some("text/plain".to_string()),
465 };
466
467 let resource = Resource::new(info.clone(), TextResource::new("test".to_string(), None));
468 assert_eq!(resource.info, info);
469 assert!(resource.is_enabled());
470 }
471
472 #[test]
473 fn test_resource_template() {
474 let template = ResourceTemplate {
475 uri_template: "test://resource/{id}".to_string(),
476 name: "Test Template".to_string(),
477 description: Some("A test template".to_string()),
478 mime_type: Some("text/plain".to_string()),
479 };
480
481 let resource = Resource::with_template(
482 template.clone(),
483 TextResource::new("test".to_string(), None),
484 );
485 assert_eq!(resource.template, Some(template));
486 }
487
488 #[test]
489 fn test_resource_uri_matching() {
490 let template = ResourceTemplate {
491 uri_template: "test://resource/{id}".to_string(),
492 name: "Test Template".to_string(),
493 description: None,
494 mime_type: None,
495 };
496
497 let resource =
498 Resource::with_template(template, TextResource::new("test".to_string(), None));
499
500 assert!(resource.matches_uri("test://resource/123"));
502 assert!(!resource.matches_uri("other://resource/123"));
503 }
504
505 #[test]
506 fn test_resource_builder() {
507 let resource = ResourceBuilder::new("test://resource", "Test Resource")
508 .description("A test resource")
509 .mime_type("text/plain")
510 .build(TextResource::new("test".to_string(), None));
511
512 assert_eq!(resource.info.uri, "test://resource");
513 assert_eq!(resource.info.name, "Test Resource");
514 assert_eq!(
515 resource.info.description,
516 Some("A test resource".to_string())
517 );
518 assert_eq!(resource.info.mime_type, Some("text/plain".to_string()));
519 }
520}