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(Debug, Deserialize, schemars::JsonSchema)]
98pub struct OverwriteNoteParams {
99 pub path: String,
100 pub content: String,
101}
102
103#[derive(Debug, Deserialize, schemars::JsonSchema)]
104pub struct ReplaceInNoteParams {
105 pub path: String,
106 pub old: String,
108 pub new: String,
110 pub replace_all: Option<bool>,
112 pub regex: Option<bool>,
114 pub preview: Option<bool>,
116}
117
118#[derive(Debug, Deserialize, schemars::JsonSchema)]
119pub struct DeleteNoteParams {
120 pub path: String,
121}
122
123#[derive(Clone)]
128pub struct KimunHandler {
129 vault: Arc<NoteVault>,
130 #[allow(dead_code)]
134 tool_router: ToolRouter<KimunHandler>,
135 #[allow(dead_code)]
136 prompt_router: PromptRouter<KimunHandler>,
137}
138
139#[tool_router]
144impl KimunHandler {
145 pub fn new(vault: NoteVault) -> Self {
146 Self {
147 vault: Arc::new(vault),
148 tool_router: Self::tool_router(),
149 prompt_router: Self::prompt_router(),
150 }
151 }
152
153 fn resolve_path(path: &str) -> VaultPath {
154 VaultPath::note_path_from(path)
155 }
156
157 #[tool(
158 description = "Create a new note at the given vault path with the given markdown content. Fails if the note already exists."
159 )]
160 async fn create_note(
161 &self,
162 Parameters(p): Parameters<CreateNoteParams>,
163 ) -> Result<CallToolResult, McpError> {
164 let vault_path = Self::resolve_path(&p.path);
165 match self.vault.create_note(&vault_path, &p.content).await {
166 Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
167 "Note created: {}",
168 vault_path
169 ))])),
170 Err(kimun_core::error::VaultError::NoteExists { .. }) => {
171 Ok(CallToolResult::error(vec![Content::text(format!(
172 "Note already exists: {}",
173 vault_path
174 ))]))
175 }
176 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
177 }
178 }
179
180 #[tool(description = "Append text to an existing note. Creates the note if it does not exist.")]
181 async fn append_note(
182 &self,
183 Parameters(p): Parameters<AppendNoteParams>,
184 ) -> Result<CallToolResult, McpError> {
185 let vault_path = Self::resolve_path(&p.path);
186 self.vault
187 .append_to_note(&vault_path, &p.content, None)
188 .await
189 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
190 Ok(CallToolResult::success(vec![Content::text(format!(
191 "Note saved: {}",
192 vault_path
193 ))]))
194 }
195
196 #[tool(
197 description = "Replace a note's entire content with new markdown. The previous content is backed up first. Destructive.",
198 annotations(destructive_hint = true)
199 )]
200 async fn overwrite_note(
201 &self,
202 Parameters(p): Parameters<OverwriteNoteParams>,
203 ) -> Result<CallToolResult, McpError> {
204 let vault_path = Self::resolve_path(&p.path);
205 if p.content.is_empty() {
206 return Ok(CallToolResult::error(vec![Content::text(
207 "Refusing to overwrite with empty content (this would wipe the note); pass content, or use delete_note to remove it",
208 )]));
209 }
210 match self.vault.save_note(&vault_path, &p.content).await {
211 Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
212 "Note saved: {}",
213 vault_path
214 ))])),
215 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
216 }
217 }
218
219 #[tool(
220 description = "Replace text in a note. `old` is a literal substring by default; set regex=true to treat it as a regular expression, in which case `new` may reference capture groups ($1, ${name}; $$ for a literal $). The match must be unique unless replace_all is true. Set preview=true to get the resulting content back without writing (dry run). The previous content is backed up first. Destructive.",
221 annotations(destructive_hint = true)
222 )]
223 async fn replace_in_note(
224 &self,
225 Parameters(p): Parameters<ReplaceInNoteParams>,
226 ) -> Result<CallToolResult, McpError> {
227 let vault_path = Self::resolve_path(&p.path);
228 let all = p.replace_all.unwrap_or(false);
229 let regex = p.regex.unwrap_or(false);
230
231 if p.preview.unwrap_or(false) {
232 return match self
233 .vault
234 .preview_replace(&vault_path, &p.old, &p.new, all, regex)
235 .await
236 {
237 Ok(pv) => Ok(CallToolResult::success(vec![Content::text(format!(
238 "{} occurrence(s) would be replaced in {} (preview — not written). Resulting content:\n\n{}",
239 pv.count, vault_path, pv.content
240 ))])),
241 Err(
242 e @ (kimun_core::error::VaultError::ReplaceTextNotFound { .. }
243 | kimun_core::error::VaultError::ReplaceTextNotUnique { .. }
244 | kimun_core::error::VaultError::InvalidRegex { .. }),
245 ) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
246 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
247 };
248 }
249
250 match self
251 .vault
252 .replace_in_note(&vault_path, &p.old, &p.new, all, regex)
253 .await
254 {
255 Ok(n) => Ok(CallToolResult::success(vec![Content::text(format!(
256 "Replaced {} occurrence(s) in {}",
257 n, vault_path
258 ))])),
259 Err(
260 e @ (kimun_core::error::VaultError::ReplaceTextNotFound { .. }
261 | kimun_core::error::VaultError::ReplaceTextNotUnique { .. }
262 | kimun_core::error::VaultError::InvalidRegex { .. }),
263 ) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
264 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
265 }
266 }
267
268 #[tool(
269 description = "Delete a note. The content is backed up first. Destructive.",
270 annotations(destructive_hint = true)
271 )]
272 async fn delete_note(
273 &self,
274 Parameters(p): Parameters<DeleteNoteParams>,
275 ) -> Result<CallToolResult, McpError> {
276 let vault_path = Self::resolve_path(&p.path);
277 match self.vault.delete_note(&vault_path).await {
278 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
279 "Note deleted: {}",
280 vault_path
281 ))])),
282 Err(kimun_core::error::VaultError::FSError(
283 kimun_core::error::FSError::VaultPathNotFound { .. },
284 )) => Ok(CallToolResult::error(vec![Content::text(format!(
285 "Note not found: {}",
286 vault_path
287 ))])),
288 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
289 }
290 }
291
292 #[tool(description = "Return the full markdown content of a note.")]
293 async fn show_note(
294 &self,
295 Parameters(p): Parameters<ShowNoteParams>,
296 ) -> Result<CallToolResult, McpError> {
297 let vault_path = Self::resolve_path(&p.path);
298 match self.vault.get_note_text(&vault_path).await {
299 Ok(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
300 Err(kimun_core::error::VaultError::FSError(
301 kimun_core::error::FSError::VaultPathNotFound { .. },
302 )) => Ok(CallToolResult::error(vec![Content::text(format!(
303 "Note not found: {}",
304 vault_path
305 ))])),
306 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
307 }
308 }
309
310 #[tool(
311 description = "Search notes by query. Supports =name (or name:name) to match by note name, @heading (or in:heading), /path prefix, #label (or lb:label) for hashtag-derived labels, <note (or lk:note) for notes that link to the given note (its backlinks), >note (or fwd:note) for the notes the given note links to (its forward links), and - prefix for exclusion (e.g. -term, -#label, -lb:label, -=name, -@heading, -/path, -<note, -lk:note, ->note, -fwd:note). The link filters match by note name (the .md extension is optional, case-insensitive); a bare name matches a linked note in any folder, a path like <dir/note disambiguates, and * wildcards are allowed (<proj*). Hashtag labels (#label) are extracted from note body text only — hashtags inside YAML/TOML frontmatter, fenced code blocks, inline code, HTML, markdown link bodies, and [[wikilinks]] are not indexed. Label names are ASCII [A-Za-z0-9_]+ and matched case-insensitively. Long queries are truncated at 8 KB."
312 )]
313 async fn search_notes(
314 &self,
315 Parameters(p): Parameters<SearchNotesParams>,
316 ) -> Result<CallToolResult, McpError> {
317 let results = self
318 .vault
319 .search_notes(&p.query)
320 .await
321 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
322 if results.is_empty() {
323 return Ok(CallToolResult::success(vec![Content::text(
324 "No results found.",
325 )]));
326 }
327 let lines: Vec<String> = results
328 .iter()
329 .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
330 .collect();
331 Ok(CallToolResult::success(vec![Content::text(
332 lines.join("\n"),
333 )]))
334 }
335
336 #[tool(description = "List all notes in the vault, optionally filtered by path prefix.")]
337 async fn list_notes(
338 &self,
339 Parameters(p): Parameters<ListNotesParams>,
340 ) -> Result<CallToolResult, McpError> {
341 let all = self
342 .vault
343 .get_all_notes()
344 .await
345 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
346 let filtered: Vec<_> = match &p.path {
347 None => all,
348 Some(prefix) => {
349 let norm = prefix.trim_matches('/');
350 all.into_iter()
351 .filter(|(entry, _)| {
352 let mut p = entry.path.clone();
353 p.to_relative();
354 p.to_string().starts_with(norm)
355 })
356 .collect()
357 }
358 };
359 if filtered.is_empty() {
360 return Ok(CallToolResult::success(vec![Content::text(
361 "No notes found.",
362 )]));
363 }
364 let lines: Vec<String> = filtered
365 .iter()
366 .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
367 .collect();
368 Ok(CallToolResult::success(vec![Content::text(
369 lines.join("\n"),
370 )]))
371 }
372
373 #[tool(
374 description = "Append text to today's journal entry (or a specific date). Creates the entry if absent."
375 )]
376 async fn journal(
377 &self,
378 Parameters(p): Parameters<JournalParams>,
379 ) -> Result<CallToolResult, McpError> {
380 let date_str = match p.date.as_deref() {
382 None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
383 Some(d) => {
384 if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
385 return Ok(CallToolResult::error(vec![Content::text(format!(
386 "Invalid date '{}' — expected YYYY-MM-DD",
387 d
388 ))]));
389 }
390 d.to_string()
391 }
392 };
393
394 let vault_path = self
397 .vault
398 .journal_path()
399 .append(&VaultPath::note_path_from(&date_str))
400 .absolute();
401 self.vault
402 .append_to_note(&vault_path, &p.text, Some(format!("# {}\n\n", date_str)))
403 .await
404 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
405
406 Ok(CallToolResult::success(vec![Content::text(format!(
407 "Note saved: {}",
408 vault_path
409 ))]))
410 }
411
412 #[tool(description = "Return the list of notes that link to the given note (backlinks).")]
413 async fn get_backlinks(
414 &self,
415 Parameters(p): Parameters<BacklinksParams>,
416 ) -> Result<CallToolResult, McpError> {
417 let vault_path = Self::resolve_path(&p.path);
418 let backlinks = self
419 .vault
420 .get_backlinks(&vault_path)
421 .await
422 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
423 if backlinks.is_empty() {
424 return Ok(CallToolResult::success(vec![Content::text(
425 "No backlinks found.",
426 )]));
427 }
428 let lines: Vec<String> = backlinks
429 .iter()
430 .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
431 .collect();
432 Ok(CallToolResult::success(vec![Content::text(
433 lines.join("\n"),
434 )]))
435 }
436
437 #[tool(description = "Return the content chunks (sections) of a note as JSON.")]
438 async fn get_chunks(
439 &self,
440 Parameters(p): Parameters<ChunksParams>,
441 ) -> Result<CallToolResult, McpError> {
442 let vault_path = Self::resolve_path(&p.path);
443 let chunks_map = self
444 .vault
445 .get_note_chunks(&vault_path)
446 .await
447 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
448
449 let mut lines: Vec<String> = Vec::new();
450 for chunks in chunks_map.values() {
451 for chunk in chunks {
452 let breadcrumb = chunk
453 .breadcrumb
454 .replace(kimun_core::note::BREADCRUMB_SEP, " > ");
455 lines.push(format!("[{}] {}", breadcrumb, chunk.text.trim()));
456 }
457 }
458
459 if lines.is_empty() {
460 return Ok(CallToolResult::success(vec![Content::text(
461 "No chunks found.",
462 )]));
463 }
464 Ok(CallToolResult::success(vec![Content::text(
465 lines.join("\n\n"),
466 )]))
467 }
468
469 #[tool(description = "Return the list of notes that this note links to (outgoing wikilinks).")]
470 async fn get_outlinks(
471 &self,
472 Parameters(p): Parameters<OutlinksParams>,
473 ) -> Result<CallToolResult, McpError> {
474 use kimun_core::error::{FSError, VaultError};
475 use kimun_core::note::{LinkType, NoteDetails};
476
477 let vault_path = Self::resolve_path(&p.path);
478
479 let md_note = match self.vault.get_markdown_and_links(&vault_path).await {
480 Ok(n) => n,
481 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
482 return Ok(CallToolResult::error(vec![Content::text(format!(
483 "Note not found: {}",
484 vault_path
485 ))]));
486 }
487 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
488 };
489
490 let note_links: Vec<_> = md_note
491 .links
492 .into_iter()
493 .filter_map(|link| {
494 if let LinkType::Note(path) = link.ltype {
495 Some(path)
496 } else {
497 None
498 }
499 })
500 .collect();
501
502 if note_links.is_empty() {
503 return Ok(CallToolResult::success(vec![Content::text(
504 "No outlinks found.",
505 )]));
506 }
507
508 let mut lines: Vec<String> = Vec::new();
509 for path in note_links {
510 let title = match self.vault.get_note_text(&path).await {
511 Ok(text) => {
512 let t = NoteDetails::get_title_from_text(&text);
513 if t.is_empty() {
514 path.get_clean_name()
515 } else {
516 t
517 }
518 }
519 Err(_) => path.get_clean_name(),
520 };
521 lines.push(format!("{} — {}", path, title));
522 }
523
524 Ok(CallToolResult::success(vec![Content::text(
525 lines.join("\n"),
526 )]))
527 }
528
529 #[tool(
530 description = "Rename a note within its current directory (filename only). Use move_note to change the directory."
531 )]
532 async fn rename_note(
533 &self,
534 Parameters(p): Parameters<RenameNoteParams>,
535 ) -> Result<CallToolResult, McpError> {
536 if p.new_name.contains('/') {
537 return Ok(CallToolResult::error(vec![Content::text(
538 "new_name must not contain '/'. Use move_note to change a note's directory.",
539 )]));
540 }
541
542 let from = Self::resolve_path(&p.path);
543 let (parent, _) = from.get_parent_path();
544 let to = parent
545 .append(&VaultPath::note_path_from(&p.new_name))
546 .absolute();
547
548 match self.vault.rename_note(&from, &to).await {
549 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
550 "Note renamed: {} → {}",
551 from, to
552 ))])),
553 Err(
554 kimun_core::error::VaultError::NoteExists { .. }
555 | kimun_core::error::VaultError::FSError(
556 kimun_core::error::FSError::VaultPathNotFound { .. }
557 | kimun_core::error::FSError::InvalidPath { .. },
558 ),
559 ) => Ok(CallToolResult::error(vec![Content::text(format!(
560 "Note not found or destination already exists: {} → {}",
561 from, to
562 ))])),
563 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
564 }
565 }
566
567 #[tool(
568 description = "Move a note to a new vault path (different directory and/or name). Backlinks in other notes are updated automatically."
569 )]
570 async fn move_note(
571 &self,
572 Parameters(p): Parameters<MoveNoteParams>,
573 ) -> Result<CallToolResult, McpError> {
574 let from = Self::resolve_path(&p.path);
575 let to = Self::resolve_path(&p.new_path);
576
577 match self.vault.rename_note(&from, &to).await {
578 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
579 "Note moved: {} → {}",
580 from, to
581 ))])),
582 Err(
583 kimun_core::error::VaultError::NoteExists { .. }
584 | kimun_core::error::VaultError::FSError(
585 kimun_core::error::FSError::VaultPathNotFound { .. }
586 | kimun_core::error::FSError::InvalidPath { .. },
587 ),
588 ) => Ok(CallToolResult::error(vec![Content::text(format!(
589 "Note not found or destination already exists: {} → {}",
590 from, to
591 ))])),
592 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
593 }
594 }
595
596 #[tool(
597 description = "Quickly capture a thought into a timestamped note in the inbox directory. Returns the path of the created note."
598 )]
599 async fn quick_note(
600 &self,
601 Parameters(p): Parameters<QuickNoteParams>,
602 ) -> Result<CallToolResult, McpError> {
603 if p.content.trim().is_empty() {
604 return Ok(CallToolResult::error(vec![Content::text(
605 "Content cannot be empty.",
606 )]));
607 }
608 match self.vault.quick_note(&p.content).await {
609 Ok(details) => Ok(CallToolResult::success(vec![Content::text(format!(
610 "Note saved: {}",
611 details.path
612 ))])),
613 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
614 }
615 }
616}
617
618#[tool_handler]
623#[prompt_handler]
624impl ServerHandler for KimunHandler {
625 fn get_info(&self) -> ServerInfo {
626 ServerInfo::new(
627 ServerCapabilities::builder()
628 .enable_tools()
629 .enable_resources()
630 .enable_prompts()
631 .build(),
632 )
633 .with_instructions("Kimun notes MCP server — read and write vault notes via tools.")
634 }
635
636 async fn list_resources(
637 &self,
638 _request: Option<PaginatedRequestParams>,
639 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
640 ) -> Result<ListResourcesResult, McpError> {
641 let notes = self
642 .vault
643 .get_all_notes()
644 .await
645 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
646
647 let resources: Vec<Resource> = notes
648 .into_iter()
649 .map(|(entry, content)| {
650 let mut rel_path = entry.path.clone();
652 rel_path.to_relative();
653 let uri = format!("note://{}", rel_path.to_string_with_ext());
654
655 let name = if content.title.is_empty() {
657 entry.path.get_clean_name()
658 } else {
659 content.title.clone()
660 };
661
662 RawResource::new(uri, name)
663 .with_mime_type("text/markdown")
664 .no_annotation()
665 })
666 .collect();
667
668 Ok(ListResourcesResult {
669 resources,
670 next_cursor: None,
671 meta: None,
672 })
673 }
674
675 async fn read_resource(
676 &self,
677 request: ReadResourceRequestParams,
678 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
679 ) -> Result<ReadResourceResult, McpError> {
680 let uri = &request.uri;
681
682 let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
684 McpError::invalid_params(
685 format!("invalid URI scheme — expected note://, got: {}", uri),
686 None,
687 )
688 })?;
689
690 let vault_path = VaultPath::note_path_from(path_with_ext);
691
692 match self.vault.get_note_text(&vault_path).await {
694 Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
695 text,
696 uri.clone(),
697 )])),
698 Err(kimun_core::error::VaultError::FSError(
699 kimun_core::error::FSError::VaultPathNotFound { .. },
700 )) => Err(McpError::invalid_params(
701 format!("note not found: {}", uri),
702 None,
703 )),
704 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
705 }
706 }
707
708 async fn list_resource_templates(
709 &self,
710 _request: Option<PaginatedRequestParams>,
711 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
712 ) -> Result<ListResourceTemplatesResult, McpError> {
713 Ok(ListResourceTemplatesResult {
714 resource_templates: vec![],
715 next_cursor: None,
716 meta: None,
717 })
718 }
719}
720
721pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
726 use crate::cli::helpers::create_and_init_vault;
727 let (vault, _) = create_and_init_vault(config_path).await?;
728 let handler = KimunHandler::new(vault);
729 let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
730 service.waiting().await.map_err(|e| eyre!("{e}"))?;
731 Ok(())
732}
733
734#[cfg(test)]
739mod tests {
740 use super::*;
741 use kimun_core::{NoteVault, VaultConfig};
742 use tempfile::TempDir;
743
744 async fn make_handler() -> (KimunHandler, TempDir) {
745 let dir = TempDir::new().unwrap();
746 let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
747 vault.validate_and_init().await.unwrap();
748 let handler = KimunHandler::new(vault);
749 (handler, dir)
750 }
751
752 fn is_success(result: &CallToolResult) -> bool {
753 result.is_error != Some(true)
754 }
755
756 fn result_text(result: &CallToolResult) -> String {
757 serde_json::to_string(&result.content).unwrap_or_default()
758 }
759
760 #[tokio::test]
761 async fn test_create_note_succeeds() {
762 let (handler, _dir) = make_handler().await;
763 let result = handler
764 .create_note(Parameters(CreateNoteParams {
765 path: "test/hello".to_string(),
766 content: "# Hello\n\nworld".to_string(),
767 }))
768 .await
769 .unwrap();
770 assert!(
771 is_success(&result),
772 "expected success, got: {:?}",
773 result_text(&result)
774 );
775 assert!(result_text(&result).contains("test/hello"));
776 }
777
778 #[tokio::test]
779 async fn test_create_note_fails_if_exists() {
780 let (handler, _dir) = make_handler().await;
781 handler
782 .create_note(Parameters(CreateNoteParams {
783 path: "test/hello".to_string(),
784 content: "first".to_string(),
785 }))
786 .await
787 .unwrap();
788 let result = handler
789 .create_note(Parameters(CreateNoteParams {
790 path: "test/hello".to_string(),
791 content: "second".to_string(),
792 }))
793 .await
794 .unwrap();
795 assert_eq!(result.is_error, Some(true));
796 }
797
798 #[tokio::test]
799 async fn test_overwrite_note_replaces_whole_body() {
800 let (handler, _dir) = make_handler().await;
801 handler
802 .create_note(Parameters(CreateNoteParams {
803 path: "n".to_string(),
804 content: "old body".to_string(),
805 }))
806 .await
807 .unwrap();
808
809 let result = handler
810 .overwrite_note(Parameters(OverwriteNoteParams {
811 path: "n".to_string(),
812 content: "new body".to_string(),
813 }))
814 .await
815 .unwrap();
816 assert!(is_success(&result), "got: {:?}", result_text(&result));
817
818 let shown = handler
819 .show_note(Parameters(ShowNoteParams {
820 path: "n".to_string(),
821 }))
822 .await
823 .unwrap();
824 assert!(result_text(&shown).contains("new body"));
825 assert!(!result_text(&shown).contains("old body"));
826 }
827
828 #[tokio::test]
829 async fn test_replace_in_note_unique_match() {
830 let (handler, _dir) = make_handler().await;
831 handler
832 .create_note(Parameters(CreateNoteParams {
833 path: "n".to_string(),
834 content: "hello world".to_string(),
835 }))
836 .await
837 .unwrap();
838
839 let result = handler
840 .replace_in_note(Parameters(ReplaceInNoteParams {
841 path: "n".to_string(),
842 old: "world".to_string(),
843 new: "there".to_string(),
844 replace_all: None,
845 regex: None,
846 preview: None,
847 }))
848 .await
849 .unwrap();
850 assert!(is_success(&result), "got: {:?}", result_text(&result));
851
852 let shown = handler
853 .show_note(Parameters(ShowNoteParams {
854 path: "n".to_string(),
855 }))
856 .await
857 .unwrap();
858 assert!(result_text(&shown).contains("hello there"));
859 }
860
861 #[tokio::test]
862 async fn test_replace_in_note_non_unique_is_error() {
863 let (handler, _dir) = make_handler().await;
864 handler
865 .create_note(Parameters(CreateNoteParams {
866 path: "n".to_string(),
867 content: "a a".to_string(),
868 }))
869 .await
870 .unwrap();
871
872 let result = handler
873 .replace_in_note(Parameters(ReplaceInNoteParams {
874 path: "n".to_string(),
875 old: "a".to_string(),
876 new: "b".to_string(),
877 replace_all: None,
878 regex: None,
879 preview: None,
880 }))
881 .await
882 .unwrap();
883 assert_eq!(result.is_error, Some(true));
884 }
885
886 #[tokio::test]
887 async fn test_delete_note_removes_it() {
888 let (handler, _dir) = make_handler().await;
889 handler
890 .create_note(Parameters(CreateNoteParams {
891 path: "n".to_string(),
892 content: "x".to_string(),
893 }))
894 .await
895 .unwrap();
896
897 let result = handler
898 .delete_note(Parameters(DeleteNoteParams {
899 path: "n".to_string(),
900 }))
901 .await
902 .unwrap();
903 assert!(is_success(&result), "got: {:?}", result_text(&result));
904
905 let shown = handler
906 .show_note(Parameters(ShowNoteParams {
907 path: "n".to_string(),
908 }))
909 .await
910 .unwrap();
911 assert_eq!(shown.is_error, Some(true));
912 }
913
914 #[tokio::test]
915 async fn test_show_note_returns_content() {
916 let (handler, _dir) = make_handler().await;
917 handler
918 .create_note(Parameters(CreateNoteParams {
919 path: "show/me".to_string(),
920 content: "# Show me\n\nsome content".to_string(),
921 }))
922 .await
923 .unwrap();
924 let result = handler
925 .show_note(Parameters(ShowNoteParams {
926 path: "show/me".to_string(),
927 }))
928 .await
929 .unwrap();
930 assert!(is_success(&result));
931 assert!(result_text(&result).contains("some content"));
932 }
933
934 #[tokio::test]
935 async fn test_show_note_not_found_returns_error_result() {
936 let (handler, _dir) = make_handler().await;
937 let result = handler
938 .show_note(Parameters(ShowNoteParams {
939 path: "missing/note".to_string(),
940 }))
941 .await
942 .unwrap();
943 assert_eq!(result.is_error, Some(true));
944 }
945
946 #[tokio::test]
947 async fn test_append_note_creates_if_absent() {
948 let (handler, _dir) = make_handler().await;
949 let result = handler
950 .append_note(Parameters(AppendNoteParams {
951 path: "new/note".to_string(),
952 content: "appended text".to_string(),
953 }))
954 .await
955 .unwrap();
956 assert!(is_success(&result));
957 let show = handler
958 .show_note(Parameters(ShowNoteParams {
959 path: "new/note".to_string(),
960 }))
961 .await
962 .unwrap();
963 assert!(result_text(&show).contains("appended text"));
964 }
965
966 #[tokio::test]
967 async fn test_append_note_appends_to_existing() {
968 let (handler, _dir) = make_handler().await;
969 handler
970 .create_note(Parameters(CreateNoteParams {
971 path: "exist/note".to_string(),
972 content: "original".to_string(),
973 }))
974 .await
975 .unwrap();
976 handler
977 .append_note(Parameters(AppendNoteParams {
978 path: "exist/note".to_string(),
979 content: "added".to_string(),
980 }))
981 .await
982 .unwrap();
983 let show = handler
984 .show_note(Parameters(ShowNoteParams {
985 path: "exist/note".to_string(),
986 }))
987 .await
988 .unwrap();
989 let text = result_text(&show);
990 assert!(text.contains("original"), "missing 'original' in: {}", text);
991 assert!(text.contains("added"), "missing 'added' in: {}", text);
992 let orig_pos = text.find("original").expect("original not found");
993 let added_pos = text.find("added").expect("added not found");
994 assert!(orig_pos < added_pos, "original should appear before added");
995 }
996
997 #[tokio::test]
998 async fn test_search_notes_finds_match() {
999 let (handler, _dir) = make_handler().await;
1000 handler
1001 .create_note(Parameters(CreateNoteParams {
1002 path: "alpha/one".to_string(),
1003 content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
1004 }))
1005 .await
1006 .unwrap();
1007 let result = handler
1008 .search_notes(Parameters(SearchNotesParams {
1009 query: "unique_keyword_xyz".to_string(),
1010 }))
1011 .await
1012 .unwrap();
1013 assert!(
1014 is_success(&result),
1015 "expected success: {}",
1016 result_text(&result)
1017 );
1018 assert!(
1019 result_text(&result).contains("alpha/one"),
1020 "search result did not include 'alpha/one': {}",
1021 result_text(&result)
1022 );
1023 }
1024
1025 #[tokio::test]
1026 async fn test_search_notes_returns_empty_for_no_match() {
1027 let (handler, _dir) = make_handler().await;
1028 let result = handler
1029 .search_notes(Parameters(SearchNotesParams {
1030 query: "nonexistent_zzz_123".to_string(),
1031 }))
1032 .await
1033 .unwrap();
1034 assert!(is_success(&result));
1035 }
1036
1037 #[tokio::test]
1038 async fn test_list_notes_returns_all() {
1039 let (handler, _dir) = make_handler().await;
1040 handler
1041 .create_note(Parameters(CreateNoteParams {
1042 path: "folder/a".to_string(),
1043 content: "note a".to_string(),
1044 }))
1045 .await
1046 .unwrap();
1047 handler
1048 .create_note(Parameters(CreateNoteParams {
1049 path: "folder/b".to_string(),
1050 content: "note b".to_string(),
1051 }))
1052 .await
1053 .unwrap();
1054 let result = handler
1055 .list_notes(Parameters(ListNotesParams { path: None }))
1056 .await
1057 .unwrap();
1058 assert!(is_success(&result));
1059 let text = result_text(&result);
1060 assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
1061 assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
1062 }
1063
1064 #[tokio::test]
1065 async fn test_journal_appends_to_today() {
1066 let (handler, _dir) = make_handler().await;
1067 let result = handler
1068 .journal(Parameters(JournalParams {
1069 text: "Today's thought".to_string(),
1070 date: None,
1071 }))
1072 .await
1073 .unwrap();
1074 assert!(
1075 is_success(&result),
1076 "expected success: {}",
1077 result_text(&result)
1078 );
1079 assert!(
1080 result_text(&result).contains("saved"),
1081 "expected 'saved' in result: {}",
1082 result_text(&result)
1083 );
1084 }
1085
1086 #[tokio::test]
1087 async fn test_journal_with_explicit_date() {
1088 let (handler, _dir) = make_handler().await;
1089 let result = handler
1090 .journal(Parameters(JournalParams {
1091 text: "Entry for specific date".to_string(),
1092 date: Some("2026-01-15".to_string()),
1093 }))
1094 .await
1095 .unwrap();
1096 assert!(
1097 is_success(&result),
1098 "expected success: {}",
1099 result_text(&result)
1100 );
1101 }
1102
1103 #[tokio::test]
1104 async fn test_journal_invalid_date_returns_error() {
1105 let (handler, _dir) = make_handler().await;
1106 let result = handler
1107 .journal(Parameters(JournalParams {
1108 text: "bad date".to_string(),
1109 date: Some("not-a-date".to_string()),
1110 }))
1111 .await
1112 .unwrap();
1113 assert_eq!(
1114 result.is_error,
1115 Some(true),
1116 "expected error for invalid date"
1117 );
1118 }
1119
1120 #[tokio::test]
1121 async fn test_get_backlinks_empty_for_no_links() {
1122 let (handler, _dir) = make_handler().await;
1123 handler
1124 .create_note(Parameters(CreateNoteParams {
1125 path: "standalone".to_string(),
1126 content: "# Standalone\n\nNo links here.".to_string(),
1127 }))
1128 .await
1129 .unwrap();
1130 let result = handler
1131 .get_backlinks(Parameters(BacklinksParams {
1132 path: "standalone".to_string(),
1133 }))
1134 .await
1135 .unwrap();
1136 assert!(is_success(&result));
1137 }
1138
1139 #[tokio::test]
1140 async fn test_get_backlinks_finds_linking_note() {
1141 let (handler, _dir) = make_handler().await;
1142 handler
1143 .create_note(Parameters(CreateNoteParams {
1144 path: "target".to_string(),
1145 content: "# Target".to_string(),
1146 }))
1147 .await
1148 .unwrap();
1149 handler
1150 .create_note(Parameters(CreateNoteParams {
1151 path: "source".to_string(),
1152 content: "links to [[target]]".to_string(),
1153 }))
1154 .await
1155 .unwrap();
1156 let result = handler
1157 .get_backlinks(Parameters(BacklinksParams {
1158 path: "target".to_string(),
1159 }))
1160 .await
1161 .unwrap();
1162 assert!(is_success(&result));
1163 assert!(
1164 result_text(&result).contains("source"),
1165 "expected 'source' in backlinks: {}",
1166 result_text(&result)
1167 );
1168 }
1169
1170 #[tokio::test]
1171 async fn test_get_chunks_returns_sections() {
1172 let (handler, _dir) = make_handler().await;
1173 handler
1174 .create_note(Parameters(CreateNoteParams {
1175 path: "chunked".to_string(),
1176 content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
1177 .to_string(),
1178 }))
1179 .await
1180 .unwrap();
1181 let result = handler
1182 .get_chunks(Parameters(ChunksParams {
1183 path: "chunked".to_string(),
1184 }))
1185 .await
1186 .unwrap();
1187 assert!(is_success(&result));
1188 assert!(
1189 result_text(&result).contains("Section"),
1190 "expected section in chunks: {}",
1191 result_text(&result)
1192 );
1193 }
1194
1195 #[tokio::test]
1196 async fn test_get_chunks_missing_note_returns_gracefully() {
1197 let (handler, _dir) = make_handler().await;
1198 let result = handler
1201 .get_chunks(Parameters(ChunksParams {
1202 path: "missing/note".to_string(),
1203 }))
1204 .await;
1205 let _ = result;
1207 }
1208
1209 #[tokio::test]
1219 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1220 async fn test_list_resources_returns_notes() {
1221 let (handler, _dir) = make_handler().await;
1222 handler
1223 .create_note(Parameters(CreateNoteParams {
1224 path: "res/alpha".to_string(),
1225 content: "# Alpha Note".to_string(),
1226 }))
1227 .await
1228 .unwrap();
1229 unreachable!("test is ignored");
1234 }
1235
1236 #[tokio::test]
1237 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1238 async fn test_read_resource_returns_content() {
1239 let (handler, _dir) = make_handler().await;
1240 handler
1241 .create_note(Parameters(CreateNoteParams {
1242 path: "res/beta".to_string(),
1243 content: "# Beta\n\nbeta content".to_string(),
1244 }))
1245 .await
1246 .unwrap();
1247 unreachable!("test is ignored");
1250 }
1251
1252 #[tokio::test]
1253 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1254 async fn test_read_resource_not_found_returns_error() {
1255 let (handler, _dir) = make_handler().await;
1256 let _ = &handler;
1259 unreachable!("test is ignored");
1260 }
1261
1262 #[tokio::test]
1263 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1264 async fn test_read_resource_invalid_scheme_returns_error() {
1265 let (handler, _dir) = make_handler().await;
1266 let _ = &handler;
1269 unreachable!("test is ignored");
1270 }
1271
1272 #[tokio::test]
1273 async fn test_get_outlinks_returns_linked_notes() {
1274 let (handler, _dir) = make_handler().await;
1275 handler
1276 .create_note(Parameters(CreateNoteParams {
1277 path: "source".to_string(),
1278 content: "# Source\n\nSee [[target]] for more.".to_string(),
1279 }))
1280 .await
1281 .unwrap();
1282 handler
1283 .create_note(Parameters(CreateNoteParams {
1284 path: "target".to_string(),
1285 content: "# Target\n\nContent here.".to_string(),
1286 }))
1287 .await
1288 .unwrap();
1289 let result = handler
1290 .get_outlinks(Parameters(OutlinksParams {
1291 path: "source".to_string(),
1292 }))
1293 .await
1294 .unwrap();
1295 assert!(
1296 is_success(&result),
1297 "expected success: {}",
1298 result_text(&result)
1299 );
1300 assert!(
1301 result_text(&result).contains("target"),
1302 "expected 'target' in outlinks: {}",
1303 result_text(&result)
1304 );
1305 }
1306
1307 #[tokio::test]
1308 async fn test_get_outlinks_no_links_returns_empty_message() {
1309 let (handler, _dir) = make_handler().await;
1310 handler
1311 .create_note(Parameters(CreateNoteParams {
1312 path: "no-links".to_string(),
1313 content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1314 }))
1315 .await
1316 .unwrap();
1317 let result = handler
1318 .get_outlinks(Parameters(OutlinksParams {
1319 path: "no-links".to_string(),
1320 }))
1321 .await
1322 .unwrap();
1323 assert!(is_success(&result));
1324 assert!(
1325 result_text(&result).contains("No outlinks found"),
1326 "expected empty message: {}",
1327 result_text(&result)
1328 );
1329 }
1330
1331 #[tokio::test]
1332 async fn test_get_outlinks_note_not_found_returns_error() {
1333 let (handler, _dir) = make_handler().await;
1334 let result = handler
1335 .get_outlinks(Parameters(OutlinksParams {
1336 path: "missing/note".to_string(),
1337 }))
1338 .await
1339 .unwrap();
1340 assert_eq!(result.is_error, Some(true));
1341 }
1342
1343 #[tokio::test]
1344 async fn test_rename_note_succeeds() {
1345 let (handler, _dir) = make_handler().await;
1346 handler
1347 .create_note(Parameters(CreateNoteParams {
1348 path: "old-name".to_string(),
1349 content: "# Old\n\nunique_rename_content_xyz".to_string(),
1350 }))
1351 .await
1352 .unwrap();
1353 let result = handler
1354 .rename_note(Parameters(RenameNoteParams {
1355 path: "old-name".to_string(),
1356 new_name: "new-name".to_string(),
1357 }))
1358 .await
1359 .unwrap();
1360 assert!(
1361 is_success(&result),
1362 "expected success: {}",
1363 result_text(&result)
1364 );
1365 let show = handler
1366 .show_note(Parameters(ShowNoteParams {
1367 path: "new-name".to_string(),
1368 }))
1369 .await
1370 .unwrap();
1371 assert!(is_success(&show), "new path should be readable");
1372 assert!(result_text(&show).contains("unique_rename_content_xyz"));
1373 let old = handler
1374 .show_note(Parameters(ShowNoteParams {
1375 path: "old-name".to_string(),
1376 }))
1377 .await
1378 .unwrap();
1379 assert_eq!(old.is_error, Some(true), "old path should be gone");
1380 }
1381
1382 #[tokio::test]
1383 async fn test_rename_note_rejects_slash_in_name() {
1384 let (handler, _dir) = make_handler().await;
1385 handler
1386 .create_note(Parameters(CreateNoteParams {
1387 path: "some/note".to_string(),
1388 content: "content".to_string(),
1389 }))
1390 .await
1391 .unwrap();
1392 let result = handler
1393 .rename_note(Parameters(RenameNoteParams {
1394 path: "some/note".to_string(),
1395 new_name: "other/dir".to_string(),
1396 }))
1397 .await
1398 .unwrap();
1399 assert_eq!(result.is_error, Some(true));
1400 assert!(
1401 result_text(&result).contains("move_note"),
1402 "hint should mention move_note: {}",
1403 result_text(&result)
1404 );
1405 }
1406
1407 #[tokio::test]
1408 async fn test_rename_note_updates_backlinks() {
1409 let (handler, _dir) = make_handler().await;
1410 handler
1411 .create_note(Parameters(CreateNoteParams {
1412 path: "target".to_string(),
1413 content: "# Target".to_string(),
1414 }))
1415 .await
1416 .unwrap();
1417 handler
1418 .create_note(Parameters(CreateNoteParams {
1419 path: "linker".to_string(),
1420 content: "see [[target]] for details".to_string(),
1421 }))
1422 .await
1423 .unwrap();
1424 handler
1425 .rename_note(Parameters(RenameNoteParams {
1426 path: "target".to_string(),
1427 new_name: "renamed-target".to_string(),
1428 }))
1429 .await
1430 .unwrap();
1431 let show = handler
1432 .show_note(Parameters(ShowNoteParams {
1433 path: "linker".to_string(),
1434 }))
1435 .await
1436 .unwrap();
1437 assert!(
1438 result_text(&show).contains("renamed-target"),
1439 "backlink should be updated: {}",
1440 result_text(&show)
1441 );
1442 }
1443
1444 #[tokio::test]
1445 async fn test_move_note_succeeds() {
1446 let (handler, _dir) = make_handler().await;
1447 handler
1448 .create_note(Parameters(CreateNoteParams {
1449 path: "original".to_string(),
1450 content: "# Original\n\nunique_move_content_xyz".to_string(),
1451 }))
1452 .await
1453 .unwrap();
1454 let result = handler
1455 .move_note(Parameters(MoveNoteParams {
1456 path: "original".to_string(),
1457 new_path: "folder/moved".to_string(),
1458 }))
1459 .await
1460 .unwrap();
1461 assert!(
1462 is_success(&result),
1463 "expected success: {}",
1464 result_text(&result)
1465 );
1466 let show = handler
1467 .show_note(Parameters(ShowNoteParams {
1468 path: "folder/moved".to_string(),
1469 }))
1470 .await
1471 .unwrap();
1472 assert!(is_success(&show));
1473 assert!(result_text(&show).contains("unique_move_content_xyz"));
1474 let old = handler
1475 .show_note(Parameters(ShowNoteParams {
1476 path: "original".to_string(),
1477 }))
1478 .await
1479 .unwrap();
1480 assert_eq!(old.is_error, Some(true), "old path should be gone");
1481 }
1482
1483 #[tokio::test]
1484 async fn test_move_note_fails_if_destination_exists() {
1485 let (handler, _dir) = make_handler().await;
1486 handler
1487 .create_note(Parameters(CreateNoteParams {
1488 path: "src".to_string(),
1489 content: "source".to_string(),
1490 }))
1491 .await
1492 .unwrap();
1493 handler
1494 .create_note(Parameters(CreateNoteParams {
1495 path: "dst".to_string(),
1496 content: "destination".to_string(),
1497 }))
1498 .await
1499 .unwrap();
1500 let result = handler
1501 .move_note(Parameters(MoveNoteParams {
1502 path: "src".to_string(),
1503 new_path: "dst".to_string(),
1504 }))
1505 .await
1506 .unwrap();
1507 assert_eq!(result.is_error, Some(true));
1508 }
1509
1510 #[tokio::test]
1511 async fn test_list_notes_filters_by_prefix() {
1512 let (handler, _dir) = make_handler().await;
1513 handler
1514 .create_note(Parameters(CreateNoteParams {
1515 path: "projects/foo".to_string(),
1516 content: "foo".to_string(),
1517 }))
1518 .await
1519 .unwrap();
1520 handler
1521 .create_note(Parameters(CreateNoteParams {
1522 path: "journal/2026-01-01".to_string(),
1523 content: "journal".to_string(),
1524 }))
1525 .await
1526 .unwrap();
1527 let result = handler
1528 .list_notes(Parameters(ListNotesParams {
1529 path: Some("projects".to_string()),
1530 }))
1531 .await
1532 .unwrap();
1533 assert!(is_success(&result));
1534 let text = result_text(&result);
1535 assert!(
1536 text.contains("projects/foo"),
1537 "missing projects/foo: {}",
1538 text
1539 );
1540 assert!(
1541 !text.contains("journal/2026"),
1542 "should not include journal: {}",
1543 text
1544 );
1545 }
1546}