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