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}