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
353 .breadcrumb
354 .replace(kimun_core::note::BREADCRUMB_SEP, " > ");
355 lines.push(format!("[{}] {}", breadcrumb, chunk.text.trim()));
356 }
357 }
358
359 if lines.is_empty() {
360 return Ok(CallToolResult::success(vec![Content::text(
361 "No chunks found.",
362 )]));
363 }
364 Ok(CallToolResult::success(vec![Content::text(
365 lines.join("\n\n"),
366 )]))
367 }
368
369 #[tool(description = "Return the list of notes that this note links to (outgoing wikilinks).")]
370 async fn get_outlinks(
371 &self,
372 Parameters(p): Parameters<OutlinksParams>,
373 ) -> Result<CallToolResult, McpError> {
374 use kimun_core::error::{FSError, VaultError};
375 use kimun_core::note::{LinkType, NoteDetails};
376
377 let vault_path = Self::resolve_path(&p.path);
378
379 let md_note = match self.vault.get_markdown_and_links(&vault_path).await {
380 Ok(n) => n,
381 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
382 return Ok(CallToolResult::error(vec![Content::text(format!(
383 "Note not found: {}",
384 vault_path
385 ))]));
386 }
387 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
388 };
389
390 let note_links: Vec<_> = md_note
391 .links
392 .into_iter()
393 .filter_map(|link| {
394 if let LinkType::Note(path) = link.ltype {
395 Some(path)
396 } else {
397 None
398 }
399 })
400 .collect();
401
402 if note_links.is_empty() {
403 return Ok(CallToolResult::success(vec![Content::text(
404 "No outlinks found.",
405 )]));
406 }
407
408 let mut lines: Vec<String> = Vec::new();
409 for path in note_links {
410 let title = match self.vault.get_note_text(&path).await {
411 Ok(text) => {
412 let t = NoteDetails::get_title_from_text(&text);
413 if t.is_empty() {
414 path.get_clean_name()
415 } else {
416 t
417 }
418 }
419 Err(_) => path.get_clean_name(),
420 };
421 lines.push(format!("{} — {}", path, title));
422 }
423
424 Ok(CallToolResult::success(vec![Content::text(
425 lines.join("\n"),
426 )]))
427 }
428
429 #[tool(
430 description = "Rename a note within its current directory (filename only). Use move_note to change the directory."
431 )]
432 async fn rename_note(
433 &self,
434 Parameters(p): Parameters<RenameNoteParams>,
435 ) -> Result<CallToolResult, McpError> {
436 if p.new_name.contains('/') {
437 return Ok(CallToolResult::error(vec![Content::text(
438 "new_name must not contain '/'. Use move_note to change a note's directory.",
439 )]));
440 }
441
442 let from = Self::resolve_path(&p.path);
443 let (parent, _) = from.get_parent_path();
444 let to = parent
445 .append(&VaultPath::note_path_from(&p.new_name))
446 .absolute();
447
448 match self.vault.rename_note(&from, &to).await {
449 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
450 "Note renamed: {} → {}",
451 from, to
452 ))])),
453 Err(
454 kimun_core::error::VaultError::NoteExists { .. }
455 | kimun_core::error::VaultError::FSError(
456 kimun_core::error::FSError::VaultPathNotFound { .. }
457 | kimun_core::error::FSError::InvalidPath { .. },
458 ),
459 ) => Ok(CallToolResult::error(vec![Content::text(format!(
460 "Note not found or destination already exists: {} → {}",
461 from, to
462 ))])),
463 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
464 }
465 }
466
467 #[tool(
468 description = "Move a note to a new vault path (different directory and/or name). Backlinks in other notes are updated automatically."
469 )]
470 async fn move_note(
471 &self,
472 Parameters(p): Parameters<MoveNoteParams>,
473 ) -> Result<CallToolResult, McpError> {
474 let from = Self::resolve_path(&p.path);
475 let to = Self::resolve_path(&p.new_path);
476
477 match self.vault.rename_note(&from, &to).await {
478 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
479 "Note moved: {} → {}",
480 from, to
481 ))])),
482 Err(
483 kimun_core::error::VaultError::NoteExists { .. }
484 | kimun_core::error::VaultError::FSError(
485 kimun_core::error::FSError::VaultPathNotFound { .. }
486 | kimun_core::error::FSError::InvalidPath { .. },
487 ),
488 ) => Ok(CallToolResult::error(vec![Content::text(format!(
489 "Note not found or destination already exists: {} → {}",
490 from, to
491 ))])),
492 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
493 }
494 }
495
496 #[tool(
497 description = "Quickly capture a thought into a timestamped note in the inbox directory. Returns the path of the created note."
498 )]
499 async fn quick_note(
500 &self,
501 Parameters(p): Parameters<QuickNoteParams>,
502 ) -> Result<CallToolResult, McpError> {
503 if p.content.trim().is_empty() {
504 return Ok(CallToolResult::error(vec![Content::text(
505 "Content cannot be empty.",
506 )]));
507 }
508 match self.vault.quick_note(&p.content).await {
509 Ok(details) => Ok(CallToolResult::success(vec![Content::text(format!(
510 "Note saved: {}",
511 details.path
512 ))])),
513 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
514 }
515 }
516}
517
518#[tool_handler]
523#[prompt_handler]
524impl ServerHandler for KimunHandler {
525 fn get_info(&self) -> ServerInfo {
526 ServerInfo::new(
527 ServerCapabilities::builder()
528 .enable_tools()
529 .enable_resources()
530 .enable_prompts()
531 .build(),
532 )
533 .with_instructions("Kimun notes MCP server — read and write vault notes via tools.")
534 }
535
536 async fn list_resources(
537 &self,
538 _request: Option<PaginatedRequestParams>,
539 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
540 ) -> Result<ListResourcesResult, McpError> {
541 let notes = self
542 .vault
543 .get_all_notes()
544 .await
545 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
546
547 let resources: Vec<Resource> = notes
548 .into_iter()
549 .map(|(entry, content)| {
550 let mut rel_path = entry.path.clone();
552 rel_path.to_relative();
553 let uri = format!("note://{}", rel_path.to_string_with_ext());
554
555 let name = if content.title.is_empty() {
557 entry.path.get_clean_name()
558 } else {
559 content.title.clone()
560 };
561
562 RawResource::new(uri, name)
563 .with_mime_type("text/markdown")
564 .no_annotation()
565 })
566 .collect();
567
568 Ok(ListResourcesResult {
569 resources,
570 next_cursor: None,
571 meta: None,
572 })
573 }
574
575 async fn read_resource(
576 &self,
577 request: ReadResourceRequestParams,
578 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
579 ) -> Result<ReadResourceResult, McpError> {
580 let uri = &request.uri;
581
582 let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
584 McpError::invalid_params(
585 format!("invalid URI scheme — expected note://, got: {}", uri),
586 None,
587 )
588 })?;
589
590 let vault_path = VaultPath::note_path_from(path_with_ext);
591
592 match self.vault.get_note_text(&vault_path).await {
594 Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
595 text,
596 uri.clone(),
597 )])),
598 Err(kimun_core::error::VaultError::FSError(
599 kimun_core::error::FSError::VaultPathNotFound { .. },
600 )) => Err(McpError::invalid_params(
601 format!("note not found: {}", uri),
602 None,
603 )),
604 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
605 }
606 }
607
608 async fn list_resource_templates(
609 &self,
610 _request: Option<PaginatedRequestParams>,
611 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
612 ) -> Result<ListResourceTemplatesResult, McpError> {
613 Ok(ListResourceTemplatesResult {
614 resource_templates: vec![],
615 next_cursor: None,
616 meta: None,
617 })
618 }
619}
620
621pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
626 use crate::cli::helpers::create_and_init_vault;
627 let (vault, _) = create_and_init_vault(config_path).await?;
628 let handler = KimunHandler::new(vault);
629 let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
630 service.waiting().await.map_err(|e| eyre!("{e}"))?;
631 Ok(())
632}
633
634#[cfg(test)]
639mod tests {
640 use super::*;
641 use kimun_core::{NoteVault, VaultConfig};
642 use tempfile::TempDir;
643
644 async fn make_handler() -> (KimunHandler, TempDir) {
645 let dir = TempDir::new().unwrap();
646 let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
647 vault.validate_and_init().await.unwrap();
648 let handler = KimunHandler::new(vault);
649 (handler, dir)
650 }
651
652 fn is_success(result: &CallToolResult) -> bool {
653 result.is_error != Some(true)
654 }
655
656 fn result_text(result: &CallToolResult) -> String {
657 serde_json::to_string(&result.content).unwrap_or_default()
658 }
659
660 #[tokio::test]
661 async fn test_create_note_succeeds() {
662 let (handler, _dir) = make_handler().await;
663 let result = handler
664 .create_note(Parameters(CreateNoteParams {
665 path: "test/hello".to_string(),
666 content: "# Hello\n\nworld".to_string(),
667 }))
668 .await
669 .unwrap();
670 assert!(
671 is_success(&result),
672 "expected success, got: {:?}",
673 result_text(&result)
674 );
675 assert!(result_text(&result).contains("test/hello"));
676 }
677
678 #[tokio::test]
679 async fn test_create_note_fails_if_exists() {
680 let (handler, _dir) = make_handler().await;
681 handler
682 .create_note(Parameters(CreateNoteParams {
683 path: "test/hello".to_string(),
684 content: "first".to_string(),
685 }))
686 .await
687 .unwrap();
688 let result = handler
689 .create_note(Parameters(CreateNoteParams {
690 path: "test/hello".to_string(),
691 content: "second".to_string(),
692 }))
693 .await
694 .unwrap();
695 assert_eq!(result.is_error, Some(true));
696 }
697
698 #[tokio::test]
699 async fn test_show_note_returns_content() {
700 let (handler, _dir) = make_handler().await;
701 handler
702 .create_note(Parameters(CreateNoteParams {
703 path: "show/me".to_string(),
704 content: "# Show me\n\nsome content".to_string(),
705 }))
706 .await
707 .unwrap();
708 let result = handler
709 .show_note(Parameters(ShowNoteParams {
710 path: "show/me".to_string(),
711 }))
712 .await
713 .unwrap();
714 assert!(is_success(&result));
715 assert!(result_text(&result).contains("some content"));
716 }
717
718 #[tokio::test]
719 async fn test_show_note_not_found_returns_error_result() {
720 let (handler, _dir) = make_handler().await;
721 let result = handler
722 .show_note(Parameters(ShowNoteParams {
723 path: "missing/note".to_string(),
724 }))
725 .await
726 .unwrap();
727 assert_eq!(result.is_error, Some(true));
728 }
729
730 #[tokio::test]
731 async fn test_append_note_creates_if_absent() {
732 let (handler, _dir) = make_handler().await;
733 let result = handler
734 .append_note(Parameters(AppendNoteParams {
735 path: "new/note".to_string(),
736 content: "appended text".to_string(),
737 }))
738 .await
739 .unwrap();
740 assert!(is_success(&result));
741 let show = handler
742 .show_note(Parameters(ShowNoteParams {
743 path: "new/note".to_string(),
744 }))
745 .await
746 .unwrap();
747 assert!(result_text(&show).contains("appended text"));
748 }
749
750 #[tokio::test]
751 async fn test_append_note_appends_to_existing() {
752 let (handler, _dir) = make_handler().await;
753 handler
754 .create_note(Parameters(CreateNoteParams {
755 path: "exist/note".to_string(),
756 content: "original".to_string(),
757 }))
758 .await
759 .unwrap();
760 handler
761 .append_note(Parameters(AppendNoteParams {
762 path: "exist/note".to_string(),
763 content: "added".to_string(),
764 }))
765 .await
766 .unwrap();
767 let show = handler
768 .show_note(Parameters(ShowNoteParams {
769 path: "exist/note".to_string(),
770 }))
771 .await
772 .unwrap();
773 let text = result_text(&show);
774 assert!(text.contains("original"), "missing 'original' in: {}", text);
775 assert!(text.contains("added"), "missing 'added' in: {}", text);
776 let orig_pos = text.find("original").expect("original not found");
777 let added_pos = text.find("added").expect("added not found");
778 assert!(orig_pos < added_pos, "original should appear before added");
779 }
780
781 #[tokio::test]
782 async fn test_search_notes_finds_match() {
783 let (handler, _dir) = make_handler().await;
784 handler
785 .create_note(Parameters(CreateNoteParams {
786 path: "alpha/one".to_string(),
787 content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
788 }))
789 .await
790 .unwrap();
791 let result = handler
792 .search_notes(Parameters(SearchNotesParams {
793 query: "unique_keyword_xyz".to_string(),
794 }))
795 .await
796 .unwrap();
797 assert!(
798 is_success(&result),
799 "expected success: {}",
800 result_text(&result)
801 );
802 assert!(
803 result_text(&result).contains("alpha/one"),
804 "search result did not include 'alpha/one': {}",
805 result_text(&result)
806 );
807 }
808
809 #[tokio::test]
810 async fn test_search_notes_returns_empty_for_no_match() {
811 let (handler, _dir) = make_handler().await;
812 let result = handler
813 .search_notes(Parameters(SearchNotesParams {
814 query: "nonexistent_zzz_123".to_string(),
815 }))
816 .await
817 .unwrap();
818 assert!(is_success(&result));
819 }
820
821 #[tokio::test]
822 async fn test_list_notes_returns_all() {
823 let (handler, _dir) = make_handler().await;
824 handler
825 .create_note(Parameters(CreateNoteParams {
826 path: "folder/a".to_string(),
827 content: "note a".to_string(),
828 }))
829 .await
830 .unwrap();
831 handler
832 .create_note(Parameters(CreateNoteParams {
833 path: "folder/b".to_string(),
834 content: "note b".to_string(),
835 }))
836 .await
837 .unwrap();
838 let result = handler
839 .list_notes(Parameters(ListNotesParams { path: None }))
840 .await
841 .unwrap();
842 assert!(is_success(&result));
843 let text = result_text(&result);
844 assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
845 assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
846 }
847
848 #[tokio::test]
849 async fn test_journal_appends_to_today() {
850 let (handler, _dir) = make_handler().await;
851 let result = handler
852 .journal(Parameters(JournalParams {
853 text: "Today's thought".to_string(),
854 date: None,
855 }))
856 .await
857 .unwrap();
858 assert!(
859 is_success(&result),
860 "expected success: {}",
861 result_text(&result)
862 );
863 assert!(
864 result_text(&result).contains("saved"),
865 "expected 'saved' in result: {}",
866 result_text(&result)
867 );
868 }
869
870 #[tokio::test]
871 async fn test_journal_with_explicit_date() {
872 let (handler, _dir) = make_handler().await;
873 let result = handler
874 .journal(Parameters(JournalParams {
875 text: "Entry for specific date".to_string(),
876 date: Some("2026-01-15".to_string()),
877 }))
878 .await
879 .unwrap();
880 assert!(
881 is_success(&result),
882 "expected success: {}",
883 result_text(&result)
884 );
885 }
886
887 #[tokio::test]
888 async fn test_journal_invalid_date_returns_error() {
889 let (handler, _dir) = make_handler().await;
890 let result = handler
891 .journal(Parameters(JournalParams {
892 text: "bad date".to_string(),
893 date: Some("not-a-date".to_string()),
894 }))
895 .await
896 .unwrap();
897 assert_eq!(
898 result.is_error,
899 Some(true),
900 "expected error for invalid date"
901 );
902 }
903
904 #[tokio::test]
905 async fn test_get_backlinks_empty_for_no_links() {
906 let (handler, _dir) = make_handler().await;
907 handler
908 .create_note(Parameters(CreateNoteParams {
909 path: "standalone".to_string(),
910 content: "# Standalone\n\nNo links here.".to_string(),
911 }))
912 .await
913 .unwrap();
914 let result = handler
915 .get_backlinks(Parameters(BacklinksParams {
916 path: "standalone".to_string(),
917 }))
918 .await
919 .unwrap();
920 assert!(is_success(&result));
921 }
922
923 #[tokio::test]
924 async fn test_get_backlinks_finds_linking_note() {
925 let (handler, _dir) = make_handler().await;
926 handler
927 .create_note(Parameters(CreateNoteParams {
928 path: "target".to_string(),
929 content: "# Target".to_string(),
930 }))
931 .await
932 .unwrap();
933 handler
934 .create_note(Parameters(CreateNoteParams {
935 path: "source".to_string(),
936 content: "links to [[target]]".to_string(),
937 }))
938 .await
939 .unwrap();
940 let result = handler
941 .get_backlinks(Parameters(BacklinksParams {
942 path: "target".to_string(),
943 }))
944 .await
945 .unwrap();
946 assert!(is_success(&result));
947 assert!(
948 result_text(&result).contains("source"),
949 "expected 'source' in backlinks: {}",
950 result_text(&result)
951 );
952 }
953
954 #[tokio::test]
955 async fn test_get_chunks_returns_sections() {
956 let (handler, _dir) = make_handler().await;
957 handler
958 .create_note(Parameters(CreateNoteParams {
959 path: "chunked".to_string(),
960 content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
961 .to_string(),
962 }))
963 .await
964 .unwrap();
965 let result = handler
966 .get_chunks(Parameters(ChunksParams {
967 path: "chunked".to_string(),
968 }))
969 .await
970 .unwrap();
971 assert!(is_success(&result));
972 assert!(
973 result_text(&result).contains("Section"),
974 "expected section in chunks: {}",
975 result_text(&result)
976 );
977 }
978
979 #[tokio::test]
980 async fn test_get_chunks_missing_note_returns_gracefully() {
981 let (handler, _dir) = make_handler().await;
982 let result = handler
985 .get_chunks(Parameters(ChunksParams {
986 path: "missing/note".to_string(),
987 }))
988 .await;
989 let _ = result;
991 }
992
993 #[tokio::test]
1003 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1004 async fn test_list_resources_returns_notes() {
1005 let (handler, _dir) = make_handler().await;
1006 handler
1007 .create_note(Parameters(CreateNoteParams {
1008 path: "res/alpha".to_string(),
1009 content: "# Alpha Note".to_string(),
1010 }))
1011 .await
1012 .unwrap();
1013 unreachable!("test is ignored");
1018 }
1019
1020 #[tokio::test]
1021 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1022 async fn test_read_resource_returns_content() {
1023 let (handler, _dir) = make_handler().await;
1024 handler
1025 .create_note(Parameters(CreateNoteParams {
1026 path: "res/beta".to_string(),
1027 content: "# Beta\n\nbeta content".to_string(),
1028 }))
1029 .await
1030 .unwrap();
1031 unreachable!("test is ignored");
1034 }
1035
1036 #[tokio::test]
1037 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1038 async fn test_read_resource_not_found_returns_error() {
1039 let (handler, _dir) = make_handler().await;
1040 let _ = &handler;
1043 unreachable!("test is ignored");
1044 }
1045
1046 #[tokio::test]
1047 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1048 async fn test_read_resource_invalid_scheme_returns_error() {
1049 let (handler, _dir) = make_handler().await;
1050 let _ = &handler;
1053 unreachable!("test is ignored");
1054 }
1055
1056 #[tokio::test]
1057 async fn test_get_outlinks_returns_linked_notes() {
1058 let (handler, _dir) = make_handler().await;
1059 handler
1060 .create_note(Parameters(CreateNoteParams {
1061 path: "source".to_string(),
1062 content: "# Source\n\nSee [[target]] for more.".to_string(),
1063 }))
1064 .await
1065 .unwrap();
1066 handler
1067 .create_note(Parameters(CreateNoteParams {
1068 path: "target".to_string(),
1069 content: "# Target\n\nContent here.".to_string(),
1070 }))
1071 .await
1072 .unwrap();
1073 let result = handler
1074 .get_outlinks(Parameters(OutlinksParams {
1075 path: "source".to_string(),
1076 }))
1077 .await
1078 .unwrap();
1079 assert!(
1080 is_success(&result),
1081 "expected success: {}",
1082 result_text(&result)
1083 );
1084 assert!(
1085 result_text(&result).contains("target"),
1086 "expected 'target' in outlinks: {}",
1087 result_text(&result)
1088 );
1089 }
1090
1091 #[tokio::test]
1092 async fn test_get_outlinks_no_links_returns_empty_message() {
1093 let (handler, _dir) = make_handler().await;
1094 handler
1095 .create_note(Parameters(CreateNoteParams {
1096 path: "no-links".to_string(),
1097 content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1098 }))
1099 .await
1100 .unwrap();
1101 let result = handler
1102 .get_outlinks(Parameters(OutlinksParams {
1103 path: "no-links".to_string(),
1104 }))
1105 .await
1106 .unwrap();
1107 assert!(is_success(&result));
1108 assert!(
1109 result_text(&result).contains("No outlinks found"),
1110 "expected empty message: {}",
1111 result_text(&result)
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn test_get_outlinks_note_not_found_returns_error() {
1117 let (handler, _dir) = make_handler().await;
1118 let result = handler
1119 .get_outlinks(Parameters(OutlinksParams {
1120 path: "missing/note".to_string(),
1121 }))
1122 .await
1123 .unwrap();
1124 assert_eq!(result.is_error, Some(true));
1125 }
1126
1127 #[tokio::test]
1128 async fn test_rename_note_succeeds() {
1129 let (handler, _dir) = make_handler().await;
1130 handler
1131 .create_note(Parameters(CreateNoteParams {
1132 path: "old-name".to_string(),
1133 content: "# Old\n\nunique_rename_content_xyz".to_string(),
1134 }))
1135 .await
1136 .unwrap();
1137 let result = handler
1138 .rename_note(Parameters(RenameNoteParams {
1139 path: "old-name".to_string(),
1140 new_name: "new-name".to_string(),
1141 }))
1142 .await
1143 .unwrap();
1144 assert!(
1145 is_success(&result),
1146 "expected success: {}",
1147 result_text(&result)
1148 );
1149 let show = handler
1150 .show_note(Parameters(ShowNoteParams {
1151 path: "new-name".to_string(),
1152 }))
1153 .await
1154 .unwrap();
1155 assert!(is_success(&show), "new path should be readable");
1156 assert!(result_text(&show).contains("unique_rename_content_xyz"));
1157 let old = handler
1158 .show_note(Parameters(ShowNoteParams {
1159 path: "old-name".to_string(),
1160 }))
1161 .await
1162 .unwrap();
1163 assert_eq!(old.is_error, Some(true), "old path should be gone");
1164 }
1165
1166 #[tokio::test]
1167 async fn test_rename_note_rejects_slash_in_name() {
1168 let (handler, _dir) = make_handler().await;
1169 handler
1170 .create_note(Parameters(CreateNoteParams {
1171 path: "some/note".to_string(),
1172 content: "content".to_string(),
1173 }))
1174 .await
1175 .unwrap();
1176 let result = handler
1177 .rename_note(Parameters(RenameNoteParams {
1178 path: "some/note".to_string(),
1179 new_name: "other/dir".to_string(),
1180 }))
1181 .await
1182 .unwrap();
1183 assert_eq!(result.is_error, Some(true));
1184 assert!(
1185 result_text(&result).contains("move_note"),
1186 "hint should mention move_note: {}",
1187 result_text(&result)
1188 );
1189 }
1190
1191 #[tokio::test]
1192 async fn test_rename_note_updates_backlinks() {
1193 let (handler, _dir) = make_handler().await;
1194 handler
1195 .create_note(Parameters(CreateNoteParams {
1196 path: "target".to_string(),
1197 content: "# Target".to_string(),
1198 }))
1199 .await
1200 .unwrap();
1201 handler
1202 .create_note(Parameters(CreateNoteParams {
1203 path: "linker".to_string(),
1204 content: "see [[target]] for details".to_string(),
1205 }))
1206 .await
1207 .unwrap();
1208 handler
1209 .rename_note(Parameters(RenameNoteParams {
1210 path: "target".to_string(),
1211 new_name: "renamed-target".to_string(),
1212 }))
1213 .await
1214 .unwrap();
1215 let show = handler
1216 .show_note(Parameters(ShowNoteParams {
1217 path: "linker".to_string(),
1218 }))
1219 .await
1220 .unwrap();
1221 assert!(
1222 result_text(&show).contains("renamed-target"),
1223 "backlink should be updated: {}",
1224 result_text(&show)
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn test_move_note_succeeds() {
1230 let (handler, _dir) = make_handler().await;
1231 handler
1232 .create_note(Parameters(CreateNoteParams {
1233 path: "original".to_string(),
1234 content: "# Original\n\nunique_move_content_xyz".to_string(),
1235 }))
1236 .await
1237 .unwrap();
1238 let result = handler
1239 .move_note(Parameters(MoveNoteParams {
1240 path: "original".to_string(),
1241 new_path: "folder/moved".to_string(),
1242 }))
1243 .await
1244 .unwrap();
1245 assert!(
1246 is_success(&result),
1247 "expected success: {}",
1248 result_text(&result)
1249 );
1250 let show = handler
1251 .show_note(Parameters(ShowNoteParams {
1252 path: "folder/moved".to_string(),
1253 }))
1254 .await
1255 .unwrap();
1256 assert!(is_success(&show));
1257 assert!(result_text(&show).contains("unique_move_content_xyz"));
1258 let old = handler
1259 .show_note(Parameters(ShowNoteParams {
1260 path: "original".to_string(),
1261 }))
1262 .await
1263 .unwrap();
1264 assert_eq!(old.is_error, Some(true), "old path should be gone");
1265 }
1266
1267 #[tokio::test]
1268 async fn test_move_note_fails_if_destination_exists() {
1269 let (handler, _dir) = make_handler().await;
1270 handler
1271 .create_note(Parameters(CreateNoteParams {
1272 path: "src".to_string(),
1273 content: "source".to_string(),
1274 }))
1275 .await
1276 .unwrap();
1277 handler
1278 .create_note(Parameters(CreateNoteParams {
1279 path: "dst".to_string(),
1280 content: "destination".to_string(),
1281 }))
1282 .await
1283 .unwrap();
1284 let result = handler
1285 .move_note(Parameters(MoveNoteParams {
1286 path: "src".to_string(),
1287 new_path: "dst".to_string(),
1288 }))
1289 .await
1290 .unwrap();
1291 assert_eq!(result.is_error, Some(true));
1292 }
1293
1294 #[tokio::test]
1295 async fn test_list_notes_filters_by_prefix() {
1296 let (handler, _dir) = make_handler().await;
1297 handler
1298 .create_note(Parameters(CreateNoteParams {
1299 path: "projects/foo".to_string(),
1300 content: "foo".to_string(),
1301 }))
1302 .await
1303 .unwrap();
1304 handler
1305 .create_note(Parameters(CreateNoteParams {
1306 path: "journal/2026-01-01".to_string(),
1307 content: "journal".to_string(),
1308 }))
1309 .await
1310 .unwrap();
1311 let result = handler
1312 .list_notes(Parameters(ListNotesParams {
1313 path: Some("projects".to_string()),
1314 }))
1315 .await
1316 .unwrap();
1317 assert!(is_success(&result));
1318 let text = result_text(&result);
1319 assert!(
1320 text.contains("projects/foo"),
1321 "missing projects/foo: {}",
1322 text
1323 );
1324 assert!(
1325 !text.contains("journal/2026"),
1326 "should not include journal: {}",
1327 text
1328 );
1329 }
1330}