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