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