Skip to main content

org_mcp_server/resources/
mod.rs

1mod org_agenda;
2mod org_file;
3mod org_file_list;
4mod org_heading;
5mod org_id;
6mod org_outline;
7mod utils;
8
9#[cfg(test)]
10mod resource_tests;
11
12use org_core::org_mode::AgendaViewType;
13use rmcp::model::{
14    AnnotateAble, Implementation, InitializeRequestParams, InitializeResult,
15    ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams, RawResource,
16    RawResourceTemplate, ReadResourceRequestParams, ReadResourceResult,
17};
18use rmcp::service::RequestContext;
19use rmcp::{
20    ErrorData as McpError,
21    model::{ServerCapabilities, ServerInfo},
22    tool_handler,
23};
24
25use rmcp::{RoleServer, ServerHandler};
26use serde_json::json;
27
28use crate::core::OrgModeRouter;
29
30pub enum OrgResource {
31    OrgFiles,
32    Org { path: String },
33    OrgOutline { path: String },
34    OrgHeading { path: String, heading: String },
35    OrgId { id: String },
36    OrgAgenda { agenda_view_type: AgendaViewType },
37}
38
39#[tool_handler]
40impl ServerHandler for OrgModeRouter {
41    fn get_info(&self) -> ServerInfo {
42        const INSTRUCTIONS: &str = concat!(
43            "This server provides org-mode tools and resources.\n\n",
44            "Tools:\n",
45            "- org-file-list\n",
46            "- org-search\n",
47            "- org-agenda\n",
48            "Resources:\n",
49            "- org:// (List all org-mode files in the configured directory tree)\n",
50            "- org://{file} (Access the raw content of an allowed Org file)\n",
51            "- org-outline://{file} (Get the hierarchical structure of an Org file)\n",
52            "- org-heading://{file}#{heading} (Access the content of a specific headline by its path)\n",
53            "- org-id://{uuid} (Access Org node content by its unique ID property)\n",
54            "- org-agenda:// (List all agenda items/tasks)\n",
55            "- org-agenda://today (Today's scheduled tasks)\n",
56            "- org-agenda://week (This week's scheduled tasks)\n",
57        );
58
59        ServerInfo {
60            instructions: Some(INSTRUCTIONS.into()),
61            capabilities: ServerCapabilities::builder()
62                .enable_tools()
63                .enable_resources()
64                .enable_completions()
65                .build(),
66            server_info: Implementation::from_build_env(),
67            ..Default::default()
68        }
69    }
70
71    async fn list_resources(
72        &self,
73        _request: Option<PaginatedRequestParams>,
74        _: RequestContext<RoleServer>,
75    ) -> Result<ListResourcesResult, McpError> {
76        Ok(ListResourcesResult {
77            resources: vec![
78                RawResource {
79                    uri: "org://".to_string(),
80                    name: "org".to_string(),
81                    title: None,
82                    icons: None,
83                    meta: None,
84                    description: Some(
85                        "List all org-mode files in the configured directory tree".to_string(),
86                    ),
87                    size: None,
88                    mime_type: Some("application/json".to_string()),
89                }
90                .no_annotation(),
91                RawResource {
92                    uri: "org-agenda://".to_string(),
93                    name: "org-agenda".to_string(),
94                    title: None,
95                    icons: None,
96                    meta: None,
97                    description: Some("List all agenda items and tasks".to_string()),
98                    size: None,
99                    mime_type: Some("application/json".to_string()),
100                }
101                .no_annotation(),
102                RawResource {
103                    uri: "org-agenda://today".to_string(),
104                    name: "org-agenda-today".to_string(),
105                    title: None,
106                    icons: None,
107                    meta: None,
108                    description: Some("Today's scheduled agenda items".to_string()),
109                    size: None,
110                    mime_type: Some("application/json".to_string()),
111                }
112                .no_annotation(),
113                RawResource {
114                    uri: "org-agenda://week".to_string(),
115                    name: "org-agenda-week".to_string(),
116                    title: None,
117                    icons: None,
118                    meta: None,
119                    description: Some("This week's scheduled agenda items".to_string()),
120                    size: None,
121                    mime_type: Some("application/json".to_string()),
122                }
123                .no_annotation(),
124            ],
125            next_cursor: None,
126            meta: None,
127        })
128    }
129
130    async fn list_resource_templates(
131        &self,
132        _: Option<PaginatedRequestParams>,
133        _: RequestContext<RoleServer>,
134    ) -> Result<ListResourceTemplatesResult, McpError> {
135        Ok(ListResourceTemplatesResult {
136            next_cursor: None,
137            meta: None,
138            resource_templates: vec![
139                RawResourceTemplate {
140                    uri_template: "org://{file}".to_string(),
141                    icons: None,
142                    name: "org-file".to_string(),
143                    title: None,
144                    description: Some(
145                        "Access the raw content of an org-mode file by its path".to_string(),
146                    ),
147                    mime_type: Some("text/org".to_string()),
148                }
149                .no_annotation(),
150                RawResourceTemplate {
151                    uri_template: "org-outline://{file}".to_string(),
152                    icons: None,
153                    name: "org-outline-file".to_string(),
154                    title: None,
155                    description: Some(
156                        "Get the hierarchical outline structure of an org-mode file as JSON"
157                            .to_string(),
158                    ),
159                    mime_type: Some("application/json".to_string()),
160                }
161                .no_annotation(),
162                RawResourceTemplate {
163                    uri_template: "org-heading://{file}#{heading}".to_string(),
164                    icons: None,
165                    name: "org-heading-file".to_string(),
166                    title: None,
167                    description: Some(
168                        "Access the content of a specific heading within an org-mode file"
169                            .to_string(),
170                    ),
171                    mime_type: Some("application/json".to_string()),
172                }
173                .no_annotation(),
174                RawResourceTemplate {
175                    uri_template: "org-id://{id}".to_string(),
176                    icons: None,
177                    name: "org-element-by-id".to_string(),
178                    title: None,
179                    description: Some(
180                        "Access the content of any org-mode element by its unique ID property"
181                            .to_string(),
182                    ),
183                    mime_type: Some("plain/text".to_string()),
184                }
185                .no_annotation(),
186                RawResourceTemplate {
187                    uri_template: "org-agenda://day/{date}".to_string(),
188                    icons: None,
189                    name: "org-agenda-day".to_string(),
190                    title: None,
191                    description: Some(
192                        "Access the agenda items for a specific day (YYYY-MM-DD)".to_string(),
193                    ),
194                    mime_type: Some("application/json".to_string()),
195                }
196                .no_annotation(),
197                RawResourceTemplate {
198                    uri_template: "org-agenda://week/{num}".to_string(),
199                    icons: None,
200                    name: "org-agenda-week".to_string(),
201                    title: None,
202                    description: Some(
203                        "Access the agenda items for the specified week number".to_string(),
204                    ),
205                    mime_type: Some("application/json".to_string()),
206                }
207                .no_annotation(),
208                RawResourceTemplate {
209                    uri_template: "org-agenda://month/{num}".to_string(),
210                    icons: None,
211                    name: "org-agenda-month".to_string(),
212                    title: None,
213                    description: Some(
214                        "Access the agenda items for the specified month number".to_string(),
215                    ),
216                    mime_type: Some("application/json".to_string()),
217                }
218                .no_annotation(),
219                RawResourceTemplate {
220                    uri_template: "org-agenda://query/from/{from}/to/{to}".to_string(),
221                    icons: None,
222                    name: "org-agenda-query".to_string(),
223                    title: None,
224                    description: Some(
225                        "Access the agenda items for the specified date range".to_string(),
226                    ),
227                    mime_type: Some("application/json".to_string()),
228                }
229                .no_annotation(),
230            ],
231        })
232    }
233
234    async fn read_resource(
235        &self,
236        ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams,
237        _context: RequestContext<RoleServer>,
238    ) -> Result<ReadResourceResult, McpError> {
239        match OrgModeRouter::parse_resource(uri.clone()) {
240            Some(OrgResource::OrgFiles) => self.list_files(uri).await,
241            Some(OrgResource::Org { path }) => self.read_file(uri, path).await,
242            Some(OrgResource::OrgOutline { path }) => self.outline(uri, path).await,
243            Some(OrgResource::OrgHeading { path, heading }) => {
244                self.heading(uri, path, heading).await
245            }
246            Some(OrgResource::OrgId { id }) => self.id(uri, id).await,
247            Some(OrgResource::OrgAgenda { agenda_view_type }) => {
248                self.read_agenda(uri, agenda_view_type).await
249            }
250
251            None => Err(McpError::resource_not_found(
252                format!("Invalid resource URI format: {}", uri),
253                Some(json!({"uri": uri})),
254            )),
255        }
256    }
257
258    async fn initialize(
259        &self,
260        _request: InitializeRequestParams,
261        _context: RequestContext<RoleServer>,
262    ) -> Result<InitializeResult, McpError> {
263        Ok(self.get_info())
264    }
265}
266
267impl OrgModeRouter {
268    fn parse_resource(uri: String) -> Option<OrgResource> {
269        let uri = Self::decode_uri_path(&uri);
270
271        if uri == "org://" {
272            Some(OrgResource::OrgFiles)
273        } else if let Some(path) = uri.strip_prefix("org://")
274            && !path.is_empty()
275        {
276            Some(OrgResource::Org {
277                path: path.to_string(),
278            })
279        } else if let Some(id) = uri.strip_prefix("org-id://")
280            && !id.is_empty()
281        {
282            Some(OrgResource::OrgId { id: id.to_string() })
283        } else if let Some(path) = uri.strip_prefix("org-outline://")
284            && !path.is_empty()
285        {
286            Some(OrgResource::OrgOutline {
287                path: path.to_string(),
288            })
289        } else if let Some(remainder) = uri.strip_prefix("org-heading://")
290            && !remainder.is_empty()
291            && let Some((path, heading)) = remainder.split_once('#')
292            && !path.is_empty()
293            && !heading.is_empty()
294        {
295            Some(OrgResource::OrgHeading {
296                path: path.to_string(),
297                heading: heading.to_string(),
298            })
299        } else if let Some(remainder) = uri.strip_prefix("org-agenda://") {
300            let agenda_view_type = if remainder.is_empty() {
301                AgendaViewType::default()
302            } else {
303                remainder.try_into().unwrap_or_default()
304            };
305            Some(OrgResource::OrgAgenda { agenda_view_type })
306        } else {
307            None
308        }
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use crate::{core::OrgModeRouter, resources::OrgResource};
315
316    #[test]
317    fn test_org_files_list_resource() {
318        let result = OrgModeRouter::parse_resource("org://".to_string());
319        assert!(matches!(result, Some(OrgResource::OrgFiles)));
320    }
321
322    #[test]
323    fn test_org_resource_parsing() {
324        let cases = vec![
325            ("org://simple.org", "simple.org"),
326            ("org://path/to/file.org", "path/to/file.org"),
327            (
328                "org://deep/nested/path/document.org",
329                "deep/nested/path/document.org",
330            ),
331            (
332                "org://file_with_underscores.org",
333                "file_with_underscores.org",
334            ),
335            ("org://file-with-dashes.org", "file-with-dashes.org"),
336        ];
337
338        for (uri, expected_path) in cases {
339            let result = OrgModeRouter::parse_resource(uri.to_string());
340            match result {
341                Some(OrgResource::Org { path }) => {
342                    assert_eq!(path, expected_path, "Failed for URI: {}", uri);
343                }
344                _ => {
345                    unreachable!("Expected Org resource for URI: {}", uri);
346                }
347            }
348        }
349    }
350
351    #[test]
352    fn test_org_outline_resource_parsing() {
353        let cases = vec![
354            ("org-outline://simple.org", "simple.org"),
355            ("org-outline://path/to/file.org", "path/to/file.org"),
356            (
357                "org-outline://deep/nested/path/document.org",
358                "deep/nested/path/document.org",
359            ),
360        ];
361
362        for (uri, expected_path) in cases {
363            let result = OrgModeRouter::parse_resource(uri.to_string());
364            match result {
365                Some(OrgResource::OrgOutline { path }) => {
366                    assert_eq!(path, expected_path, "Failed for URI: {}", uri);
367                }
368                _ => {
369                    unreachable!("Expected OrgOutline resource for URI: {}", uri);
370                }
371            }
372        }
373    }
374
375    #[test]
376    fn test_org_heading_resource_parsing() {
377        let cases = vec![
378            (
379                "org-heading://file.org#Introduction",
380                "file.org",
381                "Introduction",
382            ),
383            (
384                "org-heading://path/to/file.org#My Heading",
385                "path/to/file.org",
386                "My Heading",
387            ),
388            (
389                "org-heading://notes/tasks.org#Project Planning",
390                "notes/tasks.org",
391                "Project Planning",
392            ),
393            (
394                "org-heading://complex/path#Heading with Multiple Words",
395                "complex/path",
396                "Heading with Multiple Words",
397            ),
398            (
399                "org-heading://file.org#Section 1.2.3",
400                "file.org",
401                "Section 1.2.3",
402            ),
403        ];
404
405        for (uri, expected_path, expected_heading) in cases {
406            let result = OrgModeRouter::parse_resource(uri.to_string());
407            match result {
408                Some(OrgResource::OrgHeading { path, heading }) => {
409                    assert_eq!(path, expected_path, "Path failed for URI: {}", uri);
410                    assert_eq!(heading, expected_heading, "Heading failed for URI: {}", uri);
411                }
412                _ => {
413                    unreachable!("Expected OrgHeading resource for URI: {}", uri);
414                }
415            }
416        }
417    }
418
419    #[test]
420    fn test_uri_decoding() {
421        let cases = vec![
422            ("path%2Fto%2Ffile.org", "path/to/file.org"),
423            ("file%20with%20spaces.org", "file with spaces.org"),
424            ("deep%2Fnested%2Fpath.org", "deep/nested/path.org"),
425            (
426                "path%2Fto%2Ffile.org%23Special%20Heading",
427                "path/to/file.org#Special Heading",
428            ),
429        ];
430
431        for (encoded_path, expected_decoded) in cases {
432            let decoded = OrgModeRouter::decode_uri_path(encoded_path);
433            assert_eq!(
434                decoded, expected_decoded,
435                "URI decoding failed for: {}",
436                encoded_path
437            );
438        }
439    }
440
441    #[test]
442    fn test_uri_decoding_in_parsing() {
443        let result = OrgModeRouter::parse_resource("org://path%2Fto%2Ffile.org".to_string());
444        match result {
445            Some(OrgResource::Org { path }) => {
446                assert_eq!(path, "path/to/file.org");
447            }
448            _ => {
449                unreachable!("Failed to parse URL-encoded org URI");
450            }
451        }
452
453        let result = OrgModeRouter::parse_resource(
454            "org-heading://notes%2Ftasks.org%23Project%20Planning".to_string(),
455        );
456        match result {
457            Some(OrgResource::OrgHeading { path, heading }) => {
458                assert_eq!(path, "notes/tasks.org");
459                assert_eq!(heading, "Project Planning");
460            }
461            _ => {
462                unreachable!("Failed to parse URL-encoded org-heading URI");
463            }
464        }
465    }
466
467    #[test]
468    fn test_invalid_uris() {
469        let invalid_cases = vec![
470            ("", "empty string"),
471            ("invalid://path", "invalid scheme"),
472            ("org-outline://", "empty outline path"),
473            ("org-heading://", "empty heading URI"),
474            ("org-heading://path", "missing heading separator"),
475            ("org-heading://path#", "empty heading"),
476            ("org-heading://#heading", "empty path"),
477            ("random-string", "no scheme"),
478            ("org", "incomplete scheme"),
479            ("org:/", "incomplete scheme"),
480        ];
481
482        for (uri, description) in invalid_cases {
483            let result = OrgModeRouter::parse_resource(uri.to_string());
484            assert!(
485                result.is_none(),
486                "Expected None for {} (URI: '{}')",
487                description,
488                uri
489            );
490        }
491    }
492
493    #[test]
494    fn test_boundary_cases() {
495        assert!(matches!(
496            OrgModeRouter::parse_resource("org://a".to_string()),
497            Some(OrgResource::Org { path }) if path == "a"
498        ));
499
500        assert!(matches!(
501            OrgModeRouter::parse_resource("org-heading://a#b".to_string()),
502            Some(OrgResource::OrgHeading { path, heading }) if path == "a" && heading == "b"
503        ));
504
505        let result =
506            OrgModeRouter::parse_resource("org-heading://file.org#Heading: With Colon".to_string());
507        match result {
508            Some(OrgResource::OrgHeading { path, heading }) => {
509                assert_eq!(path, "file.org");
510                assert_eq!(heading, "Heading: With Colon");
511            }
512            _ => {
513                unreachable!("Failed to parse heading with special characters");
514            }
515        }
516
517        let result =
518            OrgModeRouter::parse_resource("org-heading://file.org#Section#Subsection".to_string());
519        match result {
520            Some(OrgResource::OrgHeading { path, heading }) => {
521                assert_eq!(path, "file.org");
522                assert_eq!(heading, "Section#Subsection");
523            }
524            _ => {
525                unreachable!("Failed to parse heading with multiple # characters");
526            }
527        }
528    }
529
530    #[test]
531    fn test_case_sensitivity() {
532        let invalid_cases = vec![
533            "ORG://path/to/file",
534            "Org://path/to/file",
535            "org-OUTLINE://path/to/file",
536            "ORG-HEADING://path#heading",
537        ];
538
539        for uri in invalid_cases {
540            let result = OrgModeRouter::parse_resource(uri.to_string());
541            assert!(
542                result.is_none(),
543                "Expected case-sensitive scheme rejection for: {}",
544                uri
545            );
546        }
547    }
548}