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(McpError::io)?;
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).await.map_err(McpError::io)?;
361
362 while let Some(entry) = dir.next_entry().await.map_err(McpError::io)? {
363 let path = entry.path();
364
365 if path.is_dir() {
366 stack.push(path);
367 } else if self.is_allowed_file(&path) {
368 let relative_path = path
369 .strip_prefix(&self.base_path)
370 .map_err(|_| McpError::internal("Path computation error"))?;
371
372 let uri = format!("file://{}", relative_path.display());
373 let name = path
374 .file_name()
375 .and_then(|n| n.to_str())
376 .unwrap_or("unnamed")
377 .to_string();
378
379 resources.push(ResourceInfo {
380 uri,
381 name,
382 description: None,
383 mime_type: Some(self.get_mime_type(&path)),
384 });
385 }
386 }
387 }
388
389 Ok(resources)
390 }
391}
392
393pub struct ResourceBuilder {
395 uri: String,
396 name: String,
397 description: Option<String>,
398 mime_type: Option<String>,
399}
400
401impl ResourceBuilder {
402 pub fn new<S: Into<String>>(uri: S, name: S) -> Self {
404 Self {
405 uri: uri.into(),
406 name: name.into(),
407 description: None,
408 mime_type: None,
409 }
410 }
411
412 pub fn description<S: Into<String>>(mut self, description: S) -> Self {
414 self.description = Some(description.into());
415 self
416 }
417
418 pub fn mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
420 self.mime_type = Some(mime_type.into());
421 self
422 }
423
424 pub fn build<H>(self, handler: H) -> Resource
426 where
427 H: ResourceHandler + 'static,
428 {
429 let info = ResourceInfo {
430 uri: self.uri,
431 name: self.name,
432 description: self.description,
433 mime_type: self.mime_type,
434 };
435
436 Resource::new(info, handler)
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[tokio::test]
445 async fn test_text_resource() {
446 let resource =
447 TextResource::new("Hello, World!".to_string(), Some("text/plain".to_string()));
448 let params = HashMap::new();
449
450 let content = resource.read("test://resource", ¶ms).await.unwrap();
451 assert_eq!(content.len(), 1);
452 assert_eq!(content[0].text, Some("Hello, World!".to_string()));
453 assert_eq!(content[0].mime_type, Some("text/plain".to_string()));
454 }
455
456 #[test]
457 fn test_resource_creation() {
458 let info = ResourceInfo {
459 uri: "test://resource".to_string(),
460 name: "Test Resource".to_string(),
461 description: Some("A test resource".to_string()),
462 mime_type: Some("text/plain".to_string()),
463 };
464
465 let resource = Resource::new(info.clone(), TextResource::new("test".to_string(), None));
466 assert_eq!(resource.info, info);
467 assert!(resource.is_enabled());
468 }
469
470 #[test]
471 fn test_resource_template() {
472 let template = ResourceTemplate {
473 uri_template: "test://resource/{id}".to_string(),
474 name: "Test Template".to_string(),
475 description: Some("A test template".to_string()),
476 mime_type: Some("text/plain".to_string()),
477 };
478
479 let resource = Resource::with_template(
480 template.clone(),
481 TextResource::new("test".to_string(), None),
482 );
483 assert_eq!(resource.template, Some(template));
484 }
485
486 #[test]
487 fn test_resource_uri_matching() {
488 let template = ResourceTemplate {
489 uri_template: "test://resource/{id}".to_string(),
490 name: "Test Template".to_string(),
491 description: None,
492 mime_type: None,
493 };
494
495 let resource =
496 Resource::with_template(template, TextResource::new("test".to_string(), None));
497
498 assert!(resource.matches_uri("test://resource/123"));
500 assert!(!resource.matches_uri("other://resource/123"));
501 }
502
503 #[test]
504 fn test_resource_builder() {
505 let resource = ResourceBuilder::new("test://resource", "Test Resource")
506 .description("A test resource")
507 .mime_type("text/plain")
508 .build(TextResource::new("test".to_string(), None));
509
510 assert_eq!(resource.info.uri, "test://resource");
511 assert_eq!(resource.info.name, "Test Resource");
512 assert_eq!(
513 resource.info.description,
514 Some("A test resource".to_string())
515 );
516 assert_eq!(resource.info.mime_type, Some("text/plain".to_string()));
517 }
518}