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(
634 "Kimun notes MCP server — read and write vault notes via tools. \
635 Search, listing, backlinks, and labels are served from an index that \
636 these tools keep in sync automatically. If vault files are modified \
637 outside Kimün (e.g. edited directly with sed, another editor, or a sync \
638 tool), the index goes stale and results may be wrong until the workspace \
639 is reindexed — run `kimun workspace reindex` and reconnect to this server.",
640 )
641 }
642
643 async fn list_resources(
644 &self,
645 _request: Option<PaginatedRequestParams>,
646 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
647 ) -> Result<ListResourcesResult, McpError> {
648 let notes = self
649 .vault
650 .get_all_notes()
651 .await
652 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
653
654 let resources: Vec<Resource> = notes
655 .into_iter()
656 .map(|(entry, content)| {
657 let mut rel_path = entry.path.clone();
659 rel_path.to_relative();
660 let uri = format!("note://{}", rel_path.to_string_with_ext());
661
662 let name = if content.title.is_empty() {
664 entry.path.get_clean_name()
665 } else {
666 content.title.clone()
667 };
668
669 RawResource::new(uri, name)
670 .with_mime_type("text/markdown")
671 .no_annotation()
672 })
673 .collect();
674
675 Ok(ListResourcesResult {
676 resources,
677 next_cursor: None,
678 meta: None,
679 })
680 }
681
682 async fn read_resource(
683 &self,
684 request: ReadResourceRequestParams,
685 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
686 ) -> Result<ReadResourceResult, McpError> {
687 let uri = &request.uri;
688
689 let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
691 McpError::invalid_params(
692 format!("invalid URI scheme — expected note://, got: {}", uri),
693 None,
694 )
695 })?;
696
697 let vault_path = VaultPath::note_path_from(path_with_ext);
698
699 match self.vault.get_note_text(&vault_path).await {
701 Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
702 text,
703 uri.clone(),
704 )])),
705 Err(kimun_core::error::VaultError::FSError(
706 kimun_core::error::FSError::VaultPathNotFound { .. },
707 )) => Err(McpError::invalid_params(
708 format!("note not found: {}", uri),
709 None,
710 )),
711 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
712 }
713 }
714
715 async fn list_resource_templates(
716 &self,
717 _request: Option<PaginatedRequestParams>,
718 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
719 ) -> Result<ListResourceTemplatesResult, McpError> {
720 Ok(ListResourceTemplatesResult {
721 resource_templates: vec![],
722 next_cursor: None,
723 meta: None,
724 })
725 }
726}
727
728pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
733 use crate::cli::helpers::create_and_init_vault;
734 let (vault, _) = create_and_init_vault(config_path).await?;
735 let handler = KimunHandler::new(vault);
736 let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
737 service.waiting().await.map_err(|e| eyre!("{e}"))?;
738 Ok(())
739}
740
741#[cfg(test)]
746mod tests {
747 use super::*;
748 use kimun_core::{NoteVault, VaultConfig};
749 use tempfile::TempDir;
750
751 async fn make_handler() -> (KimunHandler, TempDir) {
752 let dir = TempDir::new().unwrap();
753 let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
754 vault.validate_and_init().await.unwrap();
755 let handler = KimunHandler::new(vault);
756 (handler, dir)
757 }
758
759 fn is_success(result: &CallToolResult) -> bool {
760 result.is_error != Some(true)
761 }
762
763 fn result_text(result: &CallToolResult) -> String {
764 serde_json::to_string(&result.content).unwrap_or_default()
765 }
766
767 #[tokio::test]
768 async fn test_create_note_succeeds() {
769 let (handler, _dir) = make_handler().await;
770 let result = handler
771 .create_note(Parameters(CreateNoteParams {
772 path: "test/hello".to_string(),
773 content: "# Hello\n\nworld".to_string(),
774 }))
775 .await
776 .unwrap();
777 assert!(
778 is_success(&result),
779 "expected success, got: {:?}",
780 result_text(&result)
781 );
782 assert!(result_text(&result).contains("test/hello"));
783 }
784
785 #[tokio::test]
786 async fn test_create_note_fails_if_exists() {
787 let (handler, _dir) = make_handler().await;
788 handler
789 .create_note(Parameters(CreateNoteParams {
790 path: "test/hello".to_string(),
791 content: "first".to_string(),
792 }))
793 .await
794 .unwrap();
795 let result = handler
796 .create_note(Parameters(CreateNoteParams {
797 path: "test/hello".to_string(),
798 content: "second".to_string(),
799 }))
800 .await
801 .unwrap();
802 assert_eq!(result.is_error, Some(true));
803 }
804
805 #[tokio::test]
806 async fn test_overwrite_note_replaces_whole_body() {
807 let (handler, _dir) = make_handler().await;
808 handler
809 .create_note(Parameters(CreateNoteParams {
810 path: "n".to_string(),
811 content: "old body".to_string(),
812 }))
813 .await
814 .unwrap();
815
816 let result = handler
817 .overwrite_note(Parameters(OverwriteNoteParams {
818 path: "n".to_string(),
819 content: "new body".to_string(),
820 }))
821 .await
822 .unwrap();
823 assert!(is_success(&result), "got: {:?}", result_text(&result));
824
825 let shown = handler
826 .show_note(Parameters(ShowNoteParams {
827 path: "n".to_string(),
828 }))
829 .await
830 .unwrap();
831 assert!(result_text(&shown).contains("new body"));
832 assert!(!result_text(&shown).contains("old body"));
833 }
834
835 #[tokio::test]
836 async fn test_replace_in_note_unique_match() {
837 let (handler, _dir) = make_handler().await;
838 handler
839 .create_note(Parameters(CreateNoteParams {
840 path: "n".to_string(),
841 content: "hello world".to_string(),
842 }))
843 .await
844 .unwrap();
845
846 let result = handler
847 .replace_in_note(Parameters(ReplaceInNoteParams {
848 path: "n".to_string(),
849 old: "world".to_string(),
850 new: "there".to_string(),
851 replace_all: None,
852 regex: None,
853 preview: None,
854 }))
855 .await
856 .unwrap();
857 assert!(is_success(&result), "got: {:?}", result_text(&result));
858
859 let shown = handler
860 .show_note(Parameters(ShowNoteParams {
861 path: "n".to_string(),
862 }))
863 .await
864 .unwrap();
865 assert!(result_text(&shown).contains("hello there"));
866 }
867
868 #[tokio::test]
869 async fn test_replace_in_note_non_unique_is_error() {
870 let (handler, _dir) = make_handler().await;
871 handler
872 .create_note(Parameters(CreateNoteParams {
873 path: "n".to_string(),
874 content: "a a".to_string(),
875 }))
876 .await
877 .unwrap();
878
879 let result = handler
880 .replace_in_note(Parameters(ReplaceInNoteParams {
881 path: "n".to_string(),
882 old: "a".to_string(),
883 new: "b".to_string(),
884 replace_all: None,
885 regex: None,
886 preview: None,
887 }))
888 .await
889 .unwrap();
890 assert_eq!(result.is_error, Some(true));
891 }
892
893 #[tokio::test]
894 async fn test_delete_note_removes_it() {
895 let (handler, _dir) = make_handler().await;
896 handler
897 .create_note(Parameters(CreateNoteParams {
898 path: "n".to_string(),
899 content: "x".to_string(),
900 }))
901 .await
902 .unwrap();
903
904 let result = handler
905 .delete_note(Parameters(DeleteNoteParams {
906 path: "n".to_string(),
907 }))
908 .await
909 .unwrap();
910 assert!(is_success(&result), "got: {:?}", result_text(&result));
911
912 let shown = handler
913 .show_note(Parameters(ShowNoteParams {
914 path: "n".to_string(),
915 }))
916 .await
917 .unwrap();
918 assert_eq!(shown.is_error, Some(true));
919 }
920
921 #[tokio::test]
922 async fn test_show_note_returns_content() {
923 let (handler, _dir) = make_handler().await;
924 handler
925 .create_note(Parameters(CreateNoteParams {
926 path: "show/me".to_string(),
927 content: "# Show me\n\nsome content".to_string(),
928 }))
929 .await
930 .unwrap();
931 let result = handler
932 .show_note(Parameters(ShowNoteParams {
933 path: "show/me".to_string(),
934 }))
935 .await
936 .unwrap();
937 assert!(is_success(&result));
938 assert!(result_text(&result).contains("some content"));
939 }
940
941 #[tokio::test]
942 async fn test_show_note_not_found_returns_error_result() {
943 let (handler, _dir) = make_handler().await;
944 let result = handler
945 .show_note(Parameters(ShowNoteParams {
946 path: "missing/note".to_string(),
947 }))
948 .await
949 .unwrap();
950 assert_eq!(result.is_error, Some(true));
951 }
952
953 #[tokio::test]
954 async fn test_append_note_creates_if_absent() {
955 let (handler, _dir) = make_handler().await;
956 let result = handler
957 .append_note(Parameters(AppendNoteParams {
958 path: "new/note".to_string(),
959 content: "appended text".to_string(),
960 }))
961 .await
962 .unwrap();
963 assert!(is_success(&result));
964 let show = handler
965 .show_note(Parameters(ShowNoteParams {
966 path: "new/note".to_string(),
967 }))
968 .await
969 .unwrap();
970 assert!(result_text(&show).contains("appended text"));
971 }
972
973 #[tokio::test]
974 async fn test_append_note_appends_to_existing() {
975 let (handler, _dir) = make_handler().await;
976 handler
977 .create_note(Parameters(CreateNoteParams {
978 path: "exist/note".to_string(),
979 content: "original".to_string(),
980 }))
981 .await
982 .unwrap();
983 handler
984 .append_note(Parameters(AppendNoteParams {
985 path: "exist/note".to_string(),
986 content: "added".to_string(),
987 }))
988 .await
989 .unwrap();
990 let show = handler
991 .show_note(Parameters(ShowNoteParams {
992 path: "exist/note".to_string(),
993 }))
994 .await
995 .unwrap();
996 let text = result_text(&show);
997 assert!(text.contains("original"), "missing 'original' in: {}", text);
998 assert!(text.contains("added"), "missing 'added' in: {}", text);
999 let orig_pos = text.find("original").expect("original not found");
1000 let added_pos = text.find("added").expect("added not found");
1001 assert!(orig_pos < added_pos, "original should appear before added");
1002 }
1003
1004 #[tokio::test]
1005 async fn test_search_notes_finds_match() {
1006 let (handler, _dir) = make_handler().await;
1007 handler
1008 .create_note(Parameters(CreateNoteParams {
1009 path: "alpha/one".to_string(),
1010 content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
1011 }))
1012 .await
1013 .unwrap();
1014 let result = handler
1015 .search_notes(Parameters(SearchNotesParams {
1016 query: "unique_keyword_xyz".to_string(),
1017 }))
1018 .await
1019 .unwrap();
1020 assert!(
1021 is_success(&result),
1022 "expected success: {}",
1023 result_text(&result)
1024 );
1025 assert!(
1026 result_text(&result).contains("alpha/one"),
1027 "search result did not include 'alpha/one': {}",
1028 result_text(&result)
1029 );
1030 }
1031
1032 #[tokio::test]
1033 async fn test_search_notes_returns_empty_for_no_match() {
1034 let (handler, _dir) = make_handler().await;
1035 let result = handler
1036 .search_notes(Parameters(SearchNotesParams {
1037 query: "nonexistent_zzz_123".to_string(),
1038 }))
1039 .await
1040 .unwrap();
1041 assert!(is_success(&result));
1042 }
1043
1044 #[tokio::test]
1045 async fn test_list_notes_returns_all() {
1046 let (handler, _dir) = make_handler().await;
1047 handler
1048 .create_note(Parameters(CreateNoteParams {
1049 path: "folder/a".to_string(),
1050 content: "note a".to_string(),
1051 }))
1052 .await
1053 .unwrap();
1054 handler
1055 .create_note(Parameters(CreateNoteParams {
1056 path: "folder/b".to_string(),
1057 content: "note b".to_string(),
1058 }))
1059 .await
1060 .unwrap();
1061 let result = handler
1062 .list_notes(Parameters(ListNotesParams { path: None }))
1063 .await
1064 .unwrap();
1065 assert!(is_success(&result));
1066 let text = result_text(&result);
1067 assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
1068 assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
1069 }
1070
1071 #[tokio::test]
1072 async fn test_journal_appends_to_today() {
1073 let (handler, _dir) = make_handler().await;
1074 let result = handler
1075 .journal(Parameters(JournalParams {
1076 text: "Today's thought".to_string(),
1077 date: None,
1078 }))
1079 .await
1080 .unwrap();
1081 assert!(
1082 is_success(&result),
1083 "expected success: {}",
1084 result_text(&result)
1085 );
1086 assert!(
1087 result_text(&result).contains("saved"),
1088 "expected 'saved' in result: {}",
1089 result_text(&result)
1090 );
1091 }
1092
1093 #[tokio::test]
1094 async fn test_journal_with_explicit_date() {
1095 let (handler, _dir) = make_handler().await;
1096 let result = handler
1097 .journal(Parameters(JournalParams {
1098 text: "Entry for specific date".to_string(),
1099 date: Some("2026-01-15".to_string()),
1100 }))
1101 .await
1102 .unwrap();
1103 assert!(
1104 is_success(&result),
1105 "expected success: {}",
1106 result_text(&result)
1107 );
1108 }
1109
1110 #[tokio::test]
1111 async fn test_journal_invalid_date_returns_error() {
1112 let (handler, _dir) = make_handler().await;
1113 let result = handler
1114 .journal(Parameters(JournalParams {
1115 text: "bad date".to_string(),
1116 date: Some("not-a-date".to_string()),
1117 }))
1118 .await
1119 .unwrap();
1120 assert_eq!(
1121 result.is_error,
1122 Some(true),
1123 "expected error for invalid date"
1124 );
1125 }
1126
1127 #[tokio::test]
1128 async fn test_get_backlinks_empty_for_no_links() {
1129 let (handler, _dir) = make_handler().await;
1130 handler
1131 .create_note(Parameters(CreateNoteParams {
1132 path: "standalone".to_string(),
1133 content: "# Standalone\n\nNo links here.".to_string(),
1134 }))
1135 .await
1136 .unwrap();
1137 let result = handler
1138 .get_backlinks(Parameters(BacklinksParams {
1139 path: "standalone".to_string(),
1140 }))
1141 .await
1142 .unwrap();
1143 assert!(is_success(&result));
1144 }
1145
1146 #[tokio::test]
1147 async fn test_get_backlinks_finds_linking_note() {
1148 let (handler, _dir) = make_handler().await;
1149 handler
1150 .create_note(Parameters(CreateNoteParams {
1151 path: "target".to_string(),
1152 content: "# Target".to_string(),
1153 }))
1154 .await
1155 .unwrap();
1156 handler
1157 .create_note(Parameters(CreateNoteParams {
1158 path: "source".to_string(),
1159 content: "links to [[target]]".to_string(),
1160 }))
1161 .await
1162 .unwrap();
1163 let result = handler
1164 .get_backlinks(Parameters(BacklinksParams {
1165 path: "target".to_string(),
1166 }))
1167 .await
1168 .unwrap();
1169 assert!(is_success(&result));
1170 assert!(
1171 result_text(&result).contains("source"),
1172 "expected 'source' in backlinks: {}",
1173 result_text(&result)
1174 );
1175 }
1176
1177 #[tokio::test]
1178 async fn test_get_chunks_returns_sections() {
1179 let (handler, _dir) = make_handler().await;
1180 handler
1181 .create_note(Parameters(CreateNoteParams {
1182 path: "chunked".to_string(),
1183 content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
1184 .to_string(),
1185 }))
1186 .await
1187 .unwrap();
1188 let result = handler
1189 .get_chunks(Parameters(ChunksParams {
1190 path: "chunked".to_string(),
1191 }))
1192 .await
1193 .unwrap();
1194 assert!(is_success(&result));
1195 assert!(
1196 result_text(&result).contains("Section"),
1197 "expected section in chunks: {}",
1198 result_text(&result)
1199 );
1200 }
1201
1202 #[tokio::test]
1203 async fn test_get_chunks_missing_note_returns_gracefully() {
1204 let (handler, _dir) = make_handler().await;
1205 let result = handler
1208 .get_chunks(Parameters(ChunksParams {
1209 path: "missing/note".to_string(),
1210 }))
1211 .await;
1212 let _ = result;
1214 }
1215
1216 #[tokio::test]
1226 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1227 async fn test_list_resources_returns_notes() {
1228 let (handler, _dir) = make_handler().await;
1229 handler
1230 .create_note(Parameters(CreateNoteParams {
1231 path: "res/alpha".to_string(),
1232 content: "# Alpha Note".to_string(),
1233 }))
1234 .await
1235 .unwrap();
1236 unreachable!("test is ignored");
1241 }
1242
1243 #[tokio::test]
1244 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1245 async fn test_read_resource_returns_content() {
1246 let (handler, _dir) = make_handler().await;
1247 handler
1248 .create_note(Parameters(CreateNoteParams {
1249 path: "res/beta".to_string(),
1250 content: "# Beta\n\nbeta content".to_string(),
1251 }))
1252 .await
1253 .unwrap();
1254 unreachable!("test is ignored");
1257 }
1258
1259 #[tokio::test]
1260 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1261 async fn test_read_resource_not_found_returns_error() {
1262 let (handler, _dir) = make_handler().await;
1263 let _ = &handler;
1266 unreachable!("test is ignored");
1267 }
1268
1269 #[tokio::test]
1270 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1271 async fn test_read_resource_invalid_scheme_returns_error() {
1272 let (handler, _dir) = make_handler().await;
1273 let _ = &handler;
1276 unreachable!("test is ignored");
1277 }
1278
1279 #[tokio::test]
1280 async fn test_get_outlinks_returns_linked_notes() {
1281 let (handler, _dir) = make_handler().await;
1282 handler
1283 .create_note(Parameters(CreateNoteParams {
1284 path: "source".to_string(),
1285 content: "# Source\n\nSee [[target]] for more.".to_string(),
1286 }))
1287 .await
1288 .unwrap();
1289 handler
1290 .create_note(Parameters(CreateNoteParams {
1291 path: "target".to_string(),
1292 content: "# Target\n\nContent here.".to_string(),
1293 }))
1294 .await
1295 .unwrap();
1296 let result = handler
1297 .get_outlinks(Parameters(OutlinksParams {
1298 path: "source".to_string(),
1299 }))
1300 .await
1301 .unwrap();
1302 assert!(
1303 is_success(&result),
1304 "expected success: {}",
1305 result_text(&result)
1306 );
1307 assert!(
1308 result_text(&result).contains("target"),
1309 "expected 'target' in outlinks: {}",
1310 result_text(&result)
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn test_get_outlinks_no_links_returns_empty_message() {
1316 let (handler, _dir) = make_handler().await;
1317 handler
1318 .create_note(Parameters(CreateNoteParams {
1319 path: "no-links".to_string(),
1320 content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1321 }))
1322 .await
1323 .unwrap();
1324 let result = handler
1325 .get_outlinks(Parameters(OutlinksParams {
1326 path: "no-links".to_string(),
1327 }))
1328 .await
1329 .unwrap();
1330 assert!(is_success(&result));
1331 assert!(
1332 result_text(&result).contains("No outlinks found"),
1333 "expected empty message: {}",
1334 result_text(&result)
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn test_get_outlinks_note_not_found_returns_error() {
1340 let (handler, _dir) = make_handler().await;
1341 let result = handler
1342 .get_outlinks(Parameters(OutlinksParams {
1343 path: "missing/note".to_string(),
1344 }))
1345 .await
1346 .unwrap();
1347 assert_eq!(result.is_error, Some(true));
1348 }
1349
1350 #[tokio::test]
1351 async fn test_rename_note_succeeds() {
1352 let (handler, _dir) = make_handler().await;
1353 handler
1354 .create_note(Parameters(CreateNoteParams {
1355 path: "old-name".to_string(),
1356 content: "# Old\n\nunique_rename_content_xyz".to_string(),
1357 }))
1358 .await
1359 .unwrap();
1360 let result = handler
1361 .rename_note(Parameters(RenameNoteParams {
1362 path: "old-name".to_string(),
1363 new_name: "new-name".to_string(),
1364 }))
1365 .await
1366 .unwrap();
1367 assert!(
1368 is_success(&result),
1369 "expected success: {}",
1370 result_text(&result)
1371 );
1372 let show = handler
1373 .show_note(Parameters(ShowNoteParams {
1374 path: "new-name".to_string(),
1375 }))
1376 .await
1377 .unwrap();
1378 assert!(is_success(&show), "new path should be readable");
1379 assert!(result_text(&show).contains("unique_rename_content_xyz"));
1380 let old = handler
1381 .show_note(Parameters(ShowNoteParams {
1382 path: "old-name".to_string(),
1383 }))
1384 .await
1385 .unwrap();
1386 assert_eq!(old.is_error, Some(true), "old path should be gone");
1387 }
1388
1389 #[tokio::test]
1390 async fn test_rename_note_rejects_slash_in_name() {
1391 let (handler, _dir) = make_handler().await;
1392 handler
1393 .create_note(Parameters(CreateNoteParams {
1394 path: "some/note".to_string(),
1395 content: "content".to_string(),
1396 }))
1397 .await
1398 .unwrap();
1399 let result = handler
1400 .rename_note(Parameters(RenameNoteParams {
1401 path: "some/note".to_string(),
1402 new_name: "other/dir".to_string(),
1403 }))
1404 .await
1405 .unwrap();
1406 assert_eq!(result.is_error, Some(true));
1407 assert!(
1408 result_text(&result).contains("move_note"),
1409 "hint should mention move_note: {}",
1410 result_text(&result)
1411 );
1412 }
1413
1414 #[tokio::test]
1415 async fn test_rename_note_updates_backlinks() {
1416 let (handler, _dir) = make_handler().await;
1417 handler
1418 .create_note(Parameters(CreateNoteParams {
1419 path: "target".to_string(),
1420 content: "# Target".to_string(),
1421 }))
1422 .await
1423 .unwrap();
1424 handler
1425 .create_note(Parameters(CreateNoteParams {
1426 path: "linker".to_string(),
1427 content: "see [[target]] for details".to_string(),
1428 }))
1429 .await
1430 .unwrap();
1431 handler
1432 .rename_note(Parameters(RenameNoteParams {
1433 path: "target".to_string(),
1434 new_name: "renamed-target".to_string(),
1435 }))
1436 .await
1437 .unwrap();
1438 let show = handler
1439 .show_note(Parameters(ShowNoteParams {
1440 path: "linker".to_string(),
1441 }))
1442 .await
1443 .unwrap();
1444 assert!(
1445 result_text(&show).contains("renamed-target"),
1446 "backlink should be updated: {}",
1447 result_text(&show)
1448 );
1449 }
1450
1451 #[tokio::test]
1452 async fn test_move_note_succeeds() {
1453 let (handler, _dir) = make_handler().await;
1454 handler
1455 .create_note(Parameters(CreateNoteParams {
1456 path: "original".to_string(),
1457 content: "# Original\n\nunique_move_content_xyz".to_string(),
1458 }))
1459 .await
1460 .unwrap();
1461 let result = handler
1462 .move_note(Parameters(MoveNoteParams {
1463 path: "original".to_string(),
1464 new_path: "folder/moved".to_string(),
1465 }))
1466 .await
1467 .unwrap();
1468 assert!(
1469 is_success(&result),
1470 "expected success: {}",
1471 result_text(&result)
1472 );
1473 let show = handler
1474 .show_note(Parameters(ShowNoteParams {
1475 path: "folder/moved".to_string(),
1476 }))
1477 .await
1478 .unwrap();
1479 assert!(is_success(&show));
1480 assert!(result_text(&show).contains("unique_move_content_xyz"));
1481 let old = handler
1482 .show_note(Parameters(ShowNoteParams {
1483 path: "original".to_string(),
1484 }))
1485 .await
1486 .unwrap();
1487 assert_eq!(old.is_error, Some(true), "old path should be gone");
1488 }
1489
1490 #[tokio::test]
1491 async fn test_move_note_fails_if_destination_exists() {
1492 let (handler, _dir) = make_handler().await;
1493 handler
1494 .create_note(Parameters(CreateNoteParams {
1495 path: "src".to_string(),
1496 content: "source".to_string(),
1497 }))
1498 .await
1499 .unwrap();
1500 handler
1501 .create_note(Parameters(CreateNoteParams {
1502 path: "dst".to_string(),
1503 content: "destination".to_string(),
1504 }))
1505 .await
1506 .unwrap();
1507 let result = handler
1508 .move_note(Parameters(MoveNoteParams {
1509 path: "src".to_string(),
1510 new_path: "dst".to_string(),
1511 }))
1512 .await
1513 .unwrap();
1514 assert_eq!(result.is_error, Some(true));
1515 }
1516
1517 #[tokio::test]
1518 async fn test_list_notes_filters_by_prefix() {
1519 let (handler, _dir) = make_handler().await;
1520 handler
1521 .create_note(Parameters(CreateNoteParams {
1522 path: "projects/foo".to_string(),
1523 content: "foo".to_string(),
1524 }))
1525 .await
1526 .unwrap();
1527 handler
1528 .create_note(Parameters(CreateNoteParams {
1529 path: "journal/2026-01-01".to_string(),
1530 content: "journal".to_string(),
1531 }))
1532 .await
1533 .unwrap();
1534 let result = handler
1535 .list_notes(Parameters(ListNotesParams {
1536 path: Some("projects".to_string()),
1537 }))
1538 .await
1539 .unwrap();
1540 assert!(is_success(&result));
1541 let text = result_text(&result);
1542 assert!(
1543 text.contains("projects/foo"),
1544 "missing projects/foo: {}",
1545 text
1546 );
1547 assert!(
1548 !text.contains("journal/2026"),
1549 "should not include journal: {}",
1550 text
1551 );
1552 }
1553}