1pub mod prompts;
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use color_eyre::eyre::{Result, eyre};
11use kimun_core::{NoteVault, nfs::VaultPath};
12use rmcp::{
13 ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
14 handler::server::{
15 router::{prompt::PromptRouter, tool::ToolRouter},
16 wrapper::Parameters,
17 },
18 model::*,
19 prompt_handler, schemars,
20 service::RequestContext,
21 tool, tool_handler, tool_router,
22 transport::stdio,
23};
24use serde::Deserialize;
25
26#[derive(Debug, Deserialize, schemars::JsonSchema)]
31pub struct CreateNoteParams {
32 pub path: String,
33 pub content: String,
34}
35
36#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct AppendNoteParams {
38 pub path: String,
39 pub content: String,
40}
41
42#[derive(Debug, Deserialize, schemars::JsonSchema)]
43pub struct ShowNoteParams {
44 pub path: String,
45}
46
47#[derive(Debug, Deserialize, schemars::JsonSchema)]
48pub struct SearchNotesParams {
49 pub query: String,
50}
51
52#[derive(Debug, Deserialize, schemars::JsonSchema)]
53pub struct ListNotesParams {
54 pub path: Option<String>,
55}
56
57#[derive(Debug, Deserialize, schemars::JsonSchema)]
58pub struct JournalParams {
59 pub text: String,
60 pub date: Option<String>,
61}
62
63#[derive(Debug, Deserialize, schemars::JsonSchema)]
64pub struct BacklinksParams {
65 pub path: String,
66}
67
68#[derive(Debug, Deserialize, schemars::JsonSchema)]
69pub struct ChunksParams {
70 pub path: String,
71}
72
73#[derive(Debug, Deserialize, schemars::JsonSchema)]
74pub struct OutlinksParams {
75 pub path: String,
76}
77
78#[derive(Debug, Deserialize, schemars::JsonSchema)]
79pub struct RenameNoteParams {
80 pub path: String,
81 pub new_name: String,
83}
84
85#[derive(Debug, Deserialize, schemars::JsonSchema)]
86pub struct MoveNoteParams {
87 pub path: String,
88 pub new_path: String,
89}
90
91#[derive(Debug, Deserialize, schemars::JsonSchema)]
92pub struct QuickNoteParams {
93 pub content: String,
95}
96
97#[derive(Clone)]
102pub struct KimunHandler {
103 vault: Arc<NoteVault>,
104 tool_router: ToolRouter<KimunHandler>,
105 prompt_router: PromptRouter<KimunHandler>,
106}
107
108#[tool_router]
113impl KimunHandler {
114 pub fn new(vault: NoteVault) -> Self {
115 Self {
116 vault: Arc::new(vault),
117 tool_router: Self::tool_router(),
118 prompt_router: Self::prompt_router(),
119 }
120 }
121
122 fn resolve_path(path: &str) -> VaultPath {
123 VaultPath::note_path_from(path)
124 }
125
126 #[tool(
127 description = "Create a new note at the given vault path with the given markdown content. Fails if the note already exists."
128 )]
129 async fn create_note(
130 &self,
131 Parameters(p): Parameters<CreateNoteParams>,
132 ) -> Result<CallToolResult, McpError> {
133 let vault_path = Self::resolve_path(&p.path);
134 match self.vault.create_note(&vault_path, &p.content).await {
135 Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
136 "Note created: {}",
137 vault_path
138 ))])),
139 Err(kimun_core::error::VaultError::NoteExists { .. }) => {
140 Ok(CallToolResult::error(vec![Content::text(format!(
141 "Note already exists: {}",
142 vault_path
143 ))]))
144 }
145 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
146 }
147 }
148
149 #[tool(description = "Append text to an existing note. Creates the note if it does not exist.")]
150 async fn append_note(
151 &self,
152 Parameters(p): Parameters<AppendNoteParams>,
153 ) -> Result<CallToolResult, McpError> {
154 let vault_path = Self::resolve_path(&p.path);
155 let existing = self
156 .vault
157 .load_or_create_note(&vault_path, None)
158 .await
159 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
160 let combined = if existing.is_empty() {
161 p.content
162 } else {
163 format!("{}\n{}", existing, p.content)
164 };
165 self.vault
166 .save_note(&vault_path, &combined)
167 .await
168 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
169 Ok(CallToolResult::success(vec![Content::text(format!(
170 "Note saved: {}",
171 vault_path
172 ))]))
173 }
174
175 #[tool(description = "Return the full markdown content of a note.")]
176 async fn show_note(
177 &self,
178 Parameters(p): Parameters<ShowNoteParams>,
179 ) -> Result<CallToolResult, McpError> {
180 let vault_path = Self::resolve_path(&p.path);
181 match self.vault.get_note_text(&vault_path).await {
182 Ok(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
183 Err(kimun_core::error::VaultError::FSError(
184 kimun_core::error::FSError::VaultPathNotFound { .. },
185 )) => Ok(CallToolResult::error(vec![Content::text(format!(
186 "Note not found: {}",
187 vault_path
188 ))])),
189 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
190 }
191 }
192
193 #[tool(
194 description = "Search notes by query. Supports @filename, >heading, /path prefix, and -exclusion operators."
195 )]
196 async fn search_notes(
197 &self,
198 Parameters(p): Parameters<SearchNotesParams>,
199 ) -> Result<CallToolResult, McpError> {
200 let results = self
201 .vault
202 .search_notes(&p.query)
203 .await
204 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
205 if results.is_empty() {
206 return Ok(CallToolResult::success(vec![Content::text(
207 "No results found.",
208 )]));
209 }
210 let lines: Vec<String> = results
211 .iter()
212 .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
213 .collect();
214 Ok(CallToolResult::success(vec![Content::text(
215 lines.join("\n"),
216 )]))
217 }
218
219 #[tool(description = "List all notes in the vault, optionally filtered by path prefix.")]
220 async fn list_notes(
221 &self,
222 Parameters(p): Parameters<ListNotesParams>,
223 ) -> Result<CallToolResult, McpError> {
224 let all = self
225 .vault
226 .get_all_notes()
227 .await
228 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
229 let filtered: Vec<_> = match &p.path {
230 None => all,
231 Some(prefix) => {
232 let norm = prefix.trim_matches('/');
233 all.into_iter()
234 .filter(|(entry, _)| {
235 let mut p = entry.path.clone();
236 p.to_relative();
237 p.to_string().starts_with(norm)
238 })
239 .collect()
240 }
241 };
242 if filtered.is_empty() {
243 return Ok(CallToolResult::success(vec![Content::text(
244 "No notes found.",
245 )]));
246 }
247 let lines: Vec<String> = filtered
248 .iter()
249 .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
250 .collect();
251 Ok(CallToolResult::success(vec![Content::text(
252 lines.join("\n"),
253 )]))
254 }
255
256 #[tool(
257 description = "Append text to today's journal entry (or a specific date). Creates the entry if absent."
258 )]
259 async fn journal(
260 &self,
261 Parameters(p): Parameters<JournalParams>,
262 ) -> Result<CallToolResult, McpError> {
263 let date_str = match p.date.as_deref() {
265 None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
266 Some(d) => {
267 if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
268 return Ok(CallToolResult::error(vec![Content::text(format!(
269 "Invalid date '{}' — expected YYYY-MM-DD",
270 d
271 ))]));
272 }
273 d.to_string()
274 }
275 };
276
277 let (vault_path, existing) = if p.date.is_none() {
278 let (details, existing) = self
280 .vault
281 .journal_entry()
282 .await
283 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
284 (details.path, existing)
285 } else {
286 let journal_path = self
288 .vault
289 .journal_path()
290 .append(&VaultPath::note_path_from(&date_str))
291 .absolute();
292 let existing = self
293 .vault
294 .load_or_create_note(&journal_path, Some(format!("# {}\n\n", date_str)))
295 .await
296 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
297 (journal_path, existing)
298 };
299
300 let combined = format!("{}\n{}", existing, p.text);
301 self.vault
302 .save_note(&vault_path, &combined)
303 .await
304 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
305
306 Ok(CallToolResult::success(vec![Content::text(format!(
307 "Note saved: {}",
308 vault_path
309 ))]))
310 }
311
312 #[tool(description = "Return the list of notes that link to the given note (backlinks).")]
313 async fn get_backlinks(
314 &self,
315 Parameters(p): Parameters<BacklinksParams>,
316 ) -> Result<CallToolResult, McpError> {
317 let vault_path = Self::resolve_path(&p.path);
318 let backlinks = self
319 .vault
320 .get_backlinks(&vault_path)
321 .await
322 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
323 if backlinks.is_empty() {
324 return Ok(CallToolResult::success(vec![Content::text(
325 "No backlinks found.",
326 )]));
327 }
328 let lines: Vec<String> = backlinks
329 .iter()
330 .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
331 .collect();
332 Ok(CallToolResult::success(vec![Content::text(
333 lines.join("\n"),
334 )]))
335 }
336
337 #[tool(description = "Return the content chunks (sections) of a note as JSON.")]
338 async fn get_chunks(
339 &self,
340 Parameters(p): Parameters<ChunksParams>,
341 ) -> Result<CallToolResult, McpError> {
342 let vault_path = Self::resolve_path(&p.path);
343 let chunks_map = self
344 .vault
345 .get_note_chunks(&vault_path)
346 .await
347 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
348
349 let mut lines: Vec<String> = Vec::new();
350 for chunks in chunks_map.values() {
351 for chunk in chunks {
352 let breadcrumb = chunk.breadcrumb.join(" > ");
353 lines.push(format!("[{}] {}", breadcrumb, chunk.text.trim()));
354 }
355 }
356
357 if lines.is_empty() {
358 return Ok(CallToolResult::success(vec![Content::text(
359 "No chunks found.",
360 )]));
361 }
362 Ok(CallToolResult::success(vec![Content::text(
363 lines.join("\n\n"),
364 )]))
365 }
366
367 #[tool(description = "Return the list of notes that this note links to (outgoing wikilinks).")]
368 async fn get_outlinks(
369 &self,
370 Parameters(p): Parameters<OutlinksParams>,
371 ) -> Result<CallToolResult, McpError> {
372 use kimun_core::error::{FSError, VaultError};
373 use kimun_core::note::{LinkType, NoteDetails};
374
375 let vault_path = Self::resolve_path(&p.path);
376
377 let md_note = match self.vault.get_markdown_and_links(&vault_path).await {
378 Ok(n) => n,
379 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
380 return Ok(CallToolResult::error(vec![Content::text(format!(
381 "Note not found: {}",
382 vault_path
383 ))]));
384 }
385 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
386 };
387
388 let note_links: Vec<_> = md_note
389 .links
390 .into_iter()
391 .filter_map(|link| {
392 if let LinkType::Note(path) = link.ltype {
393 Some(path)
394 } else {
395 None
396 }
397 })
398 .collect();
399
400 if note_links.is_empty() {
401 return Ok(CallToolResult::success(vec![Content::text(
402 "No outlinks found.",
403 )]));
404 }
405
406 let mut lines: Vec<String> = Vec::new();
407 for path in note_links {
408 let title = match self.vault.get_note_text(&path).await {
409 Ok(text) => {
410 let t = NoteDetails::get_title_from_text(&text);
411 if t.is_empty() {
412 path.get_clean_name()
413 } else {
414 t
415 }
416 }
417 Err(_) => path.get_clean_name(),
418 };
419 lines.push(format!("{} — {}", path, title));
420 }
421
422 Ok(CallToolResult::success(vec![Content::text(
423 lines.join("\n"),
424 )]))
425 }
426
427 #[tool(
428 description = "Rename a note within its current directory (filename only). Use move_note to change the directory."
429 )]
430 async fn rename_note(
431 &self,
432 Parameters(p): Parameters<RenameNoteParams>,
433 ) -> Result<CallToolResult, McpError> {
434 if p.new_name.contains('/') {
435 return Ok(CallToolResult::error(vec![Content::text(
436 "new_name must not contain '/'. Use move_note to change a note's directory.",
437 )]));
438 }
439
440 let from = Self::resolve_path(&p.path);
441 let (parent, _) = from.get_parent_path();
442 let to = parent
443 .append(&VaultPath::note_path_from(&p.new_name))
444 .absolute();
445
446 match self.vault.rename_note(&from, &to).await {
447 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
448 "Note renamed: {} → {}",
449 from, to
450 ))])),
451 Err(
452 kimun_core::error::VaultError::NoteExists { .. }
453 | kimun_core::error::VaultError::FSError(
454 kimun_core::error::FSError::VaultPathNotFound { .. }
455 | kimun_core::error::FSError::InvalidPath { .. },
456 ),
457 ) => Ok(CallToolResult::error(vec![Content::text(format!(
458 "Note not found or destination already exists: {} → {}",
459 from, to
460 ))])),
461 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
462 }
463 }
464
465 #[tool(
466 description = "Move a note to a new vault path (different directory and/or name). Backlinks in other notes are updated automatically."
467 )]
468 async fn move_note(
469 &self,
470 Parameters(p): Parameters<MoveNoteParams>,
471 ) -> Result<CallToolResult, McpError> {
472 let from = Self::resolve_path(&p.path);
473 let to = Self::resolve_path(&p.new_path);
474
475 match self.vault.rename_note(&from, &to).await {
476 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
477 "Note moved: {} → {}",
478 from, to
479 ))])),
480 Err(
481 kimun_core::error::VaultError::NoteExists { .. }
482 | kimun_core::error::VaultError::FSError(
483 kimun_core::error::FSError::VaultPathNotFound { .. }
484 | kimun_core::error::FSError::InvalidPath { .. },
485 ),
486 ) => Ok(CallToolResult::error(vec![Content::text(format!(
487 "Note not found or destination already exists: {} → {}",
488 from, to
489 ))])),
490 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
491 }
492 }
493
494 #[tool(
495 description = "Quickly capture a thought into a timestamped note in the inbox directory. Returns the path of the created note."
496 )]
497 async fn quick_note(
498 &self,
499 Parameters(p): Parameters<QuickNoteParams>,
500 ) -> Result<CallToolResult, McpError> {
501 if p.content.trim().is_empty() {
502 return Ok(CallToolResult::error(vec![Content::text(
503 "Content cannot be empty.",
504 )]));
505 }
506 match self.vault.quick_note(&p.content).await {
507 Ok(details) => Ok(CallToolResult::success(vec![Content::text(format!(
508 "Note saved: {}",
509 details.path
510 ))])),
511 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
512 }
513 }
514}
515
516#[tool_handler]
521#[prompt_handler]
522impl ServerHandler for KimunHandler {
523 fn get_info(&self) -> ServerInfo {
524 ServerInfo::new(
525 ServerCapabilities::builder()
526 .enable_tools()
527 .enable_resources()
528 .enable_prompts()
529 .build(),
530 )
531 .with_instructions("Kimun notes MCP server — read and write vault notes via tools.")
532 }
533
534 async fn list_resources(
535 &self,
536 _request: Option<PaginatedRequestParams>,
537 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
538 ) -> Result<ListResourcesResult, McpError> {
539 let notes = self
540 .vault
541 .get_all_notes()
542 .await
543 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
544
545 let resources: Vec<Resource> = notes
546 .into_iter()
547 .map(|(entry, content)| {
548 let mut rel_path = entry.path.clone();
550 rel_path.to_relative();
551 let uri = format!("note://{}", rel_path.to_string_with_ext());
552
553 let name = if content.title.is_empty() {
555 entry.path.get_clean_name()
556 } else {
557 content.title.clone()
558 };
559
560 RawResource::new(uri, name)
561 .with_mime_type("text/markdown")
562 .no_annotation()
563 })
564 .collect();
565
566 Ok(ListResourcesResult {
567 resources,
568 next_cursor: None,
569 meta: None,
570 })
571 }
572
573 async fn read_resource(
574 &self,
575 request: ReadResourceRequestParams,
576 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
577 ) -> Result<ReadResourceResult, McpError> {
578 let uri = &request.uri;
579
580 let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
582 McpError::invalid_params(
583 format!("invalid URI scheme — expected note://, got: {}", uri),
584 None,
585 )
586 })?;
587
588 let vault_path = VaultPath::note_path_from(path_with_ext);
589
590 match self.vault.get_note_text(&vault_path).await {
592 Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
593 text,
594 uri.clone(),
595 )])),
596 Err(kimun_core::error::VaultError::FSError(
597 kimun_core::error::FSError::VaultPathNotFound { .. },
598 )) => Err(McpError::invalid_params(
599 format!("note not found: {}", uri),
600 None,
601 )),
602 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
603 }
604 }
605
606 async fn list_resource_templates(
607 &self,
608 _request: Option<PaginatedRequestParams>,
609 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
610 ) -> Result<ListResourceTemplatesResult, McpError> {
611 Ok(ListResourceTemplatesResult {
612 resource_templates: vec![],
613 next_cursor: None,
614 meta: None,
615 })
616 }
617}
618
619pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
624 use crate::cli::helpers::create_and_init_vault;
625 let (vault, _) = create_and_init_vault(config_path).await?;
626 let handler = KimunHandler::new(vault);
627 let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
628 service.waiting().await.map_err(|e| eyre!("{e}"))?;
629 Ok(())
630}
631
632#[cfg(test)]
637mod tests {
638 use super::*;
639 use kimun_core::NoteVault;
640 use tempfile::TempDir;
641
642 async fn make_handler() -> (KimunHandler, TempDir) {
643 let dir = TempDir::new().unwrap();
644 let vault = NoteVault::new(dir.path()).await.unwrap();
645 vault.validate_and_init().await.unwrap();
646 let handler = KimunHandler::new(vault);
647 (handler, dir)
648 }
649
650 fn is_success(result: &CallToolResult) -> bool {
651 result.is_error != Some(true)
652 }
653
654 fn result_text(result: &CallToolResult) -> String {
655 serde_json::to_string(&result.content).unwrap_or_default()
656 }
657
658 #[tokio::test]
659 async fn test_create_note_succeeds() {
660 let (handler, _dir) = make_handler().await;
661 let result = handler
662 .create_note(Parameters(CreateNoteParams {
663 path: "test/hello".to_string(),
664 content: "# Hello\n\nworld".to_string(),
665 }))
666 .await
667 .unwrap();
668 assert!(
669 is_success(&result),
670 "expected success, got: {:?}",
671 result_text(&result)
672 );
673 assert!(result_text(&result).contains("test/hello"));
674 }
675
676 #[tokio::test]
677 async fn test_create_note_fails_if_exists() {
678 let (handler, _dir) = make_handler().await;
679 handler
680 .create_note(Parameters(CreateNoteParams {
681 path: "test/hello".to_string(),
682 content: "first".to_string(),
683 }))
684 .await
685 .unwrap();
686 let result = handler
687 .create_note(Parameters(CreateNoteParams {
688 path: "test/hello".to_string(),
689 content: "second".to_string(),
690 }))
691 .await
692 .unwrap();
693 assert_eq!(result.is_error, Some(true));
694 }
695
696 #[tokio::test]
697 async fn test_show_note_returns_content() {
698 let (handler, _dir) = make_handler().await;
699 handler
700 .create_note(Parameters(CreateNoteParams {
701 path: "show/me".to_string(),
702 content: "# Show me\n\nsome content".to_string(),
703 }))
704 .await
705 .unwrap();
706 let result = handler
707 .show_note(Parameters(ShowNoteParams {
708 path: "show/me".to_string(),
709 }))
710 .await
711 .unwrap();
712 assert!(is_success(&result));
713 assert!(result_text(&result).contains("some content"));
714 }
715
716 #[tokio::test]
717 async fn test_show_note_not_found_returns_error_result() {
718 let (handler, _dir) = make_handler().await;
719 let result = handler
720 .show_note(Parameters(ShowNoteParams {
721 path: "missing/note".to_string(),
722 }))
723 .await
724 .unwrap();
725 assert_eq!(result.is_error, Some(true));
726 }
727
728 #[tokio::test]
729 async fn test_append_note_creates_if_absent() {
730 let (handler, _dir) = make_handler().await;
731 let result = handler
732 .append_note(Parameters(AppendNoteParams {
733 path: "new/note".to_string(),
734 content: "appended text".to_string(),
735 }))
736 .await
737 .unwrap();
738 assert!(is_success(&result));
739 let show = handler
740 .show_note(Parameters(ShowNoteParams {
741 path: "new/note".to_string(),
742 }))
743 .await
744 .unwrap();
745 assert!(result_text(&show).contains("appended text"));
746 }
747
748 #[tokio::test]
749 async fn test_append_note_appends_to_existing() {
750 let (handler, _dir) = make_handler().await;
751 handler
752 .create_note(Parameters(CreateNoteParams {
753 path: "exist/note".to_string(),
754 content: "original".to_string(),
755 }))
756 .await
757 .unwrap();
758 handler
759 .append_note(Parameters(AppendNoteParams {
760 path: "exist/note".to_string(),
761 content: "added".to_string(),
762 }))
763 .await
764 .unwrap();
765 let show = handler
766 .show_note(Parameters(ShowNoteParams {
767 path: "exist/note".to_string(),
768 }))
769 .await
770 .unwrap();
771 let text = result_text(&show);
772 assert!(text.contains("original"), "missing 'original' in: {}", text);
773 assert!(text.contains("added"), "missing 'added' in: {}", text);
774 let orig_pos = text.find("original").expect("original not found");
775 let added_pos = text.find("added").expect("added not found");
776 assert!(orig_pos < added_pos, "original should appear before added");
777 }
778
779 #[tokio::test]
780 async fn test_search_notes_finds_match() {
781 let (handler, _dir) = make_handler().await;
782 handler
783 .create_note(Parameters(CreateNoteParams {
784 path: "alpha/one".to_string(),
785 content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
786 }))
787 .await
788 .unwrap();
789 let result = handler
790 .search_notes(Parameters(SearchNotesParams {
791 query: "unique_keyword_xyz".to_string(),
792 }))
793 .await
794 .unwrap();
795 assert!(
796 is_success(&result),
797 "expected success: {}",
798 result_text(&result)
799 );
800 assert!(
801 result_text(&result).contains("alpha/one"),
802 "search result did not include 'alpha/one': {}",
803 result_text(&result)
804 );
805 }
806
807 #[tokio::test]
808 async fn test_search_notes_returns_empty_for_no_match() {
809 let (handler, _dir) = make_handler().await;
810 let result = handler
811 .search_notes(Parameters(SearchNotesParams {
812 query: "nonexistent_zzz_123".to_string(),
813 }))
814 .await
815 .unwrap();
816 assert!(is_success(&result));
817 }
818
819 #[tokio::test]
820 async fn test_list_notes_returns_all() {
821 let (handler, _dir) = make_handler().await;
822 handler
823 .create_note(Parameters(CreateNoteParams {
824 path: "folder/a".to_string(),
825 content: "note a".to_string(),
826 }))
827 .await
828 .unwrap();
829 handler
830 .create_note(Parameters(CreateNoteParams {
831 path: "folder/b".to_string(),
832 content: "note b".to_string(),
833 }))
834 .await
835 .unwrap();
836 let result = handler
837 .list_notes(Parameters(ListNotesParams { path: None }))
838 .await
839 .unwrap();
840 assert!(is_success(&result));
841 let text = result_text(&result);
842 assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
843 assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
844 }
845
846 #[tokio::test]
847 async fn test_journal_appends_to_today() {
848 let (handler, _dir) = make_handler().await;
849 let result = handler
850 .journal(Parameters(JournalParams {
851 text: "Today's thought".to_string(),
852 date: None,
853 }))
854 .await
855 .unwrap();
856 assert!(
857 is_success(&result),
858 "expected success: {}",
859 result_text(&result)
860 );
861 assert!(
862 result_text(&result).contains("saved"),
863 "expected 'saved' in result: {}",
864 result_text(&result)
865 );
866 }
867
868 #[tokio::test]
869 async fn test_journal_with_explicit_date() {
870 let (handler, _dir) = make_handler().await;
871 let result = handler
872 .journal(Parameters(JournalParams {
873 text: "Entry for specific date".to_string(),
874 date: Some("2026-01-15".to_string()),
875 }))
876 .await
877 .unwrap();
878 assert!(
879 is_success(&result),
880 "expected success: {}",
881 result_text(&result)
882 );
883 }
884
885 #[tokio::test]
886 async fn test_journal_invalid_date_returns_error() {
887 let (handler, _dir) = make_handler().await;
888 let result = handler
889 .journal(Parameters(JournalParams {
890 text: "bad date".to_string(),
891 date: Some("not-a-date".to_string()),
892 }))
893 .await
894 .unwrap();
895 assert_eq!(
896 result.is_error,
897 Some(true),
898 "expected error for invalid date"
899 );
900 }
901
902 #[tokio::test]
903 async fn test_get_backlinks_empty_for_no_links() {
904 let (handler, _dir) = make_handler().await;
905 handler
906 .create_note(Parameters(CreateNoteParams {
907 path: "standalone".to_string(),
908 content: "# Standalone\n\nNo links here.".to_string(),
909 }))
910 .await
911 .unwrap();
912 let result = handler
913 .get_backlinks(Parameters(BacklinksParams {
914 path: "standalone".to_string(),
915 }))
916 .await
917 .unwrap();
918 assert!(is_success(&result));
919 }
920
921 #[tokio::test]
922 async fn test_get_backlinks_finds_linking_note() {
923 let (handler, _dir) = make_handler().await;
924 handler
925 .create_note(Parameters(CreateNoteParams {
926 path: "target".to_string(),
927 content: "# Target".to_string(),
928 }))
929 .await
930 .unwrap();
931 handler
932 .create_note(Parameters(CreateNoteParams {
933 path: "source".to_string(),
934 content: "links to [[target]]".to_string(),
935 }))
936 .await
937 .unwrap();
938 let result = handler
939 .get_backlinks(Parameters(BacklinksParams {
940 path: "target".to_string(),
941 }))
942 .await
943 .unwrap();
944 assert!(is_success(&result));
945 assert!(
946 result_text(&result).contains("source"),
947 "expected 'source' in backlinks: {}",
948 result_text(&result)
949 );
950 }
951
952 #[tokio::test]
953 async fn test_get_chunks_returns_sections() {
954 let (handler, _dir) = make_handler().await;
955 handler
956 .create_note(Parameters(CreateNoteParams {
957 path: "chunked".to_string(),
958 content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
959 .to_string(),
960 }))
961 .await
962 .unwrap();
963 let result = handler
964 .get_chunks(Parameters(ChunksParams {
965 path: "chunked".to_string(),
966 }))
967 .await
968 .unwrap();
969 assert!(is_success(&result));
970 assert!(
971 result_text(&result).contains("Section"),
972 "expected section in chunks: {}",
973 result_text(&result)
974 );
975 }
976
977 #[tokio::test]
978 async fn test_get_chunks_missing_note_returns_gracefully() {
979 let (handler, _dir) = make_handler().await;
980 let result = handler
983 .get_chunks(Parameters(ChunksParams {
984 path: "missing/note".to_string(),
985 }))
986 .await;
987 let _ = result;
989 }
990
991 #[tokio::test]
1001 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1002 async fn test_list_resources_returns_notes() {
1003 let (handler, _dir) = make_handler().await;
1004 handler
1005 .create_note(Parameters(CreateNoteParams {
1006 path: "res/alpha".to_string(),
1007 content: "# Alpha Note".to_string(),
1008 }))
1009 .await
1010 .unwrap();
1011 unreachable!("test is ignored");
1016 }
1017
1018 #[tokio::test]
1019 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1020 async fn test_read_resource_returns_content() {
1021 let (handler, _dir) = make_handler().await;
1022 handler
1023 .create_note(Parameters(CreateNoteParams {
1024 path: "res/beta".to_string(),
1025 content: "# Beta\n\nbeta content".to_string(),
1026 }))
1027 .await
1028 .unwrap();
1029 unreachable!("test is ignored");
1032 }
1033
1034 #[tokio::test]
1035 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1036 async fn test_read_resource_not_found_returns_error() {
1037 let (handler, _dir) = make_handler().await;
1038 let _ = &handler;
1041 unreachable!("test is ignored");
1042 }
1043
1044 #[tokio::test]
1045 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1046 async fn test_read_resource_invalid_scheme_returns_error() {
1047 let (handler, _dir) = make_handler().await;
1048 let _ = &handler;
1051 unreachable!("test is ignored");
1052 }
1053
1054 #[tokio::test]
1055 async fn test_get_outlinks_returns_linked_notes() {
1056 let (handler, _dir) = make_handler().await;
1057 handler
1058 .create_note(Parameters(CreateNoteParams {
1059 path: "source".to_string(),
1060 content: "# Source\n\nSee [[target]] for more.".to_string(),
1061 }))
1062 .await
1063 .unwrap();
1064 handler
1065 .create_note(Parameters(CreateNoteParams {
1066 path: "target".to_string(),
1067 content: "# Target\n\nContent here.".to_string(),
1068 }))
1069 .await
1070 .unwrap();
1071 let result = handler
1072 .get_outlinks(Parameters(OutlinksParams {
1073 path: "source".to_string(),
1074 }))
1075 .await
1076 .unwrap();
1077 assert!(
1078 is_success(&result),
1079 "expected success: {}",
1080 result_text(&result)
1081 );
1082 assert!(
1083 result_text(&result).contains("target"),
1084 "expected 'target' in outlinks: {}",
1085 result_text(&result)
1086 );
1087 }
1088
1089 #[tokio::test]
1090 async fn test_get_outlinks_no_links_returns_empty_message() {
1091 let (handler, _dir) = make_handler().await;
1092 handler
1093 .create_note(Parameters(CreateNoteParams {
1094 path: "no-links".to_string(),
1095 content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1096 }))
1097 .await
1098 .unwrap();
1099 let result = handler
1100 .get_outlinks(Parameters(OutlinksParams {
1101 path: "no-links".to_string(),
1102 }))
1103 .await
1104 .unwrap();
1105 assert!(is_success(&result));
1106 assert!(
1107 result_text(&result).contains("No outlinks found"),
1108 "expected empty message: {}",
1109 result_text(&result)
1110 );
1111 }
1112
1113 #[tokio::test]
1114 async fn test_get_outlinks_note_not_found_returns_error() {
1115 let (handler, _dir) = make_handler().await;
1116 let result = handler
1117 .get_outlinks(Parameters(OutlinksParams {
1118 path: "missing/note".to_string(),
1119 }))
1120 .await
1121 .unwrap();
1122 assert_eq!(result.is_error, Some(true));
1123 }
1124
1125 #[tokio::test]
1126 async fn test_rename_note_succeeds() {
1127 let (handler, _dir) = make_handler().await;
1128 handler
1129 .create_note(Parameters(CreateNoteParams {
1130 path: "old-name".to_string(),
1131 content: "# Old\n\nunique_rename_content_xyz".to_string(),
1132 }))
1133 .await
1134 .unwrap();
1135 let result = handler
1136 .rename_note(Parameters(RenameNoteParams {
1137 path: "old-name".to_string(),
1138 new_name: "new-name".to_string(),
1139 }))
1140 .await
1141 .unwrap();
1142 assert!(
1143 is_success(&result),
1144 "expected success: {}",
1145 result_text(&result)
1146 );
1147 let show = handler
1148 .show_note(Parameters(ShowNoteParams {
1149 path: "new-name".to_string(),
1150 }))
1151 .await
1152 .unwrap();
1153 assert!(is_success(&show), "new path should be readable");
1154 assert!(result_text(&show).contains("unique_rename_content_xyz"));
1155 let old = handler
1156 .show_note(Parameters(ShowNoteParams {
1157 path: "old-name".to_string(),
1158 }))
1159 .await
1160 .unwrap();
1161 assert_eq!(old.is_error, Some(true), "old path should be gone");
1162 }
1163
1164 #[tokio::test]
1165 async fn test_rename_note_rejects_slash_in_name() {
1166 let (handler, _dir) = make_handler().await;
1167 handler
1168 .create_note(Parameters(CreateNoteParams {
1169 path: "some/note".to_string(),
1170 content: "content".to_string(),
1171 }))
1172 .await
1173 .unwrap();
1174 let result = handler
1175 .rename_note(Parameters(RenameNoteParams {
1176 path: "some/note".to_string(),
1177 new_name: "other/dir".to_string(),
1178 }))
1179 .await
1180 .unwrap();
1181 assert_eq!(result.is_error, Some(true));
1182 assert!(
1183 result_text(&result).contains("move_note"),
1184 "hint should mention move_note: {}",
1185 result_text(&result)
1186 );
1187 }
1188
1189 #[tokio::test]
1190 async fn test_rename_note_updates_backlinks() {
1191 let (handler, _dir) = make_handler().await;
1192 handler
1193 .create_note(Parameters(CreateNoteParams {
1194 path: "target".to_string(),
1195 content: "# Target".to_string(),
1196 }))
1197 .await
1198 .unwrap();
1199 handler
1200 .create_note(Parameters(CreateNoteParams {
1201 path: "linker".to_string(),
1202 content: "see [[target]] for details".to_string(),
1203 }))
1204 .await
1205 .unwrap();
1206 handler
1207 .rename_note(Parameters(RenameNoteParams {
1208 path: "target".to_string(),
1209 new_name: "renamed-target".to_string(),
1210 }))
1211 .await
1212 .unwrap();
1213 let show = handler
1214 .show_note(Parameters(ShowNoteParams {
1215 path: "linker".to_string(),
1216 }))
1217 .await
1218 .unwrap();
1219 assert!(
1220 result_text(&show).contains("renamed-target"),
1221 "backlink should be updated: {}",
1222 result_text(&show)
1223 );
1224 }
1225
1226 #[tokio::test]
1227 async fn test_move_note_succeeds() {
1228 let (handler, _dir) = make_handler().await;
1229 handler
1230 .create_note(Parameters(CreateNoteParams {
1231 path: "original".to_string(),
1232 content: "# Original\n\nunique_move_content_xyz".to_string(),
1233 }))
1234 .await
1235 .unwrap();
1236 let result = handler
1237 .move_note(Parameters(MoveNoteParams {
1238 path: "original".to_string(),
1239 new_path: "folder/moved".to_string(),
1240 }))
1241 .await
1242 .unwrap();
1243 assert!(
1244 is_success(&result),
1245 "expected success: {}",
1246 result_text(&result)
1247 );
1248 let show = handler
1249 .show_note(Parameters(ShowNoteParams {
1250 path: "folder/moved".to_string(),
1251 }))
1252 .await
1253 .unwrap();
1254 assert!(is_success(&show));
1255 assert!(result_text(&show).contains("unique_move_content_xyz"));
1256 let old = handler
1257 .show_note(Parameters(ShowNoteParams {
1258 path: "original".to_string(),
1259 }))
1260 .await
1261 .unwrap();
1262 assert_eq!(old.is_error, Some(true), "old path should be gone");
1263 }
1264
1265 #[tokio::test]
1266 async fn test_move_note_fails_if_destination_exists() {
1267 let (handler, _dir) = make_handler().await;
1268 handler
1269 .create_note(Parameters(CreateNoteParams {
1270 path: "src".to_string(),
1271 content: "source".to_string(),
1272 }))
1273 .await
1274 .unwrap();
1275 handler
1276 .create_note(Parameters(CreateNoteParams {
1277 path: "dst".to_string(),
1278 content: "destination".to_string(),
1279 }))
1280 .await
1281 .unwrap();
1282 let result = handler
1283 .move_note(Parameters(MoveNoteParams {
1284 path: "src".to_string(),
1285 new_path: "dst".to_string(),
1286 }))
1287 .await
1288 .unwrap();
1289 assert_eq!(result.is_error, Some(true));
1290 }
1291
1292 #[tokio::test]
1293 async fn test_list_notes_filters_by_prefix() {
1294 let (handler, _dir) = make_handler().await;
1295 handler
1296 .create_note(Parameters(CreateNoteParams {
1297 path: "projects/foo".to_string(),
1298 content: "foo".to_string(),
1299 }))
1300 .await
1301 .unwrap();
1302 handler
1303 .create_note(Parameters(CreateNoteParams {
1304 path: "journal/2026-01-01".to_string(),
1305 content: "journal".to_string(),
1306 }))
1307 .await
1308 .unwrap();
1309 let result = handler
1310 .list_notes(Parameters(ListNotesParams {
1311 path: Some("projects".to_string()),
1312 }))
1313 .await
1314 .unwrap();
1315 assert!(is_success(&result));
1316 let text = result_text(&result);
1317 assert!(
1318 text.contains("projects/foo"),
1319 "missing projects/foo: {}",
1320 text
1321 );
1322 assert!(
1323 !text.contains("journal/2026"),
1324 "should not include journal: {}",
1325 text
1326 );
1327 }
1328}