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(dir.path())).await.unwrap();
719 vault.validate_and_init().await.unwrap();
720 let handler = KimunHandler::new(vault);
721 (handler, dir)
722 }
723
724 fn is_success(result: &CallToolResult) -> bool {
725 result.is_error != Some(true)
726 }
727
728 fn result_text(result: &CallToolResult) -> String {
729 serde_json::to_string(&result.content).unwrap_or_default()
730 }
731
732 #[tokio::test]
733 async fn test_create_note_succeeds() {
734 let (handler, _dir) = make_handler().await;
735 let result = handler
736 .create_note(Parameters(CreateNoteParams {
737 path: "test/hello".to_string(),
738 content: "# Hello\n\nworld".to_string(),
739 }))
740 .await
741 .unwrap();
742 assert!(
743 is_success(&result),
744 "expected success, got: {:?}",
745 result_text(&result)
746 );
747 assert!(result_text(&result).contains("test/hello"));
748 }
749
750 #[tokio::test]
751 async fn test_create_note_fails_if_exists() {
752 let (handler, _dir) = make_handler().await;
753 handler
754 .create_note(Parameters(CreateNoteParams {
755 path: "test/hello".to_string(),
756 content: "first".to_string(),
757 }))
758 .await
759 .unwrap();
760 let result = handler
761 .create_note(Parameters(CreateNoteParams {
762 path: "test/hello".to_string(),
763 content: "second".to_string(),
764 }))
765 .await
766 .unwrap();
767 assert_eq!(result.is_error, Some(true));
768 }
769
770 #[tokio::test]
771 async fn test_overwrite_note_replaces_whole_body() {
772 let (handler, _dir) = make_handler().await;
773 handler
774 .create_note(Parameters(CreateNoteParams {
775 path: "n".to_string(),
776 content: "old body".to_string(),
777 }))
778 .await
779 .unwrap();
780
781 let result = handler
782 .overwrite_note(Parameters(OverwriteNoteParams {
783 path: "n".to_string(),
784 content: "new body".to_string(),
785 }))
786 .await
787 .unwrap();
788 assert!(is_success(&result), "got: {:?}", result_text(&result));
789
790 let shown = handler
791 .show_note(Parameters(ShowNoteParams {
792 path: "n".to_string(),
793 }))
794 .await
795 .unwrap();
796 assert!(result_text(&shown).contains("new body"));
797 assert!(!result_text(&shown).contains("old body"));
798 }
799
800 #[tokio::test]
801 async fn test_replace_in_note_unique_match() {
802 let (handler, _dir) = make_handler().await;
803 handler
804 .create_note(Parameters(CreateNoteParams {
805 path: "n".to_string(),
806 content: "hello world".to_string(),
807 }))
808 .await
809 .unwrap();
810
811 let result = handler
812 .replace_in_note(Parameters(ReplaceInNoteParams {
813 path: "n".to_string(),
814 old: "world".to_string(),
815 new: "there".to_string(),
816 replace_all: None,
817 regex: None,
818 preview: None,
819 }))
820 .await
821 .unwrap();
822 assert!(is_success(&result), "got: {:?}", result_text(&result));
823
824 let shown = handler
825 .show_note(Parameters(ShowNoteParams {
826 path: "n".to_string(),
827 }))
828 .await
829 .unwrap();
830 assert!(result_text(&shown).contains("hello there"));
831 }
832
833 #[tokio::test]
834 async fn test_replace_in_note_non_unique_is_error() {
835 let (handler, _dir) = make_handler().await;
836 handler
837 .create_note(Parameters(CreateNoteParams {
838 path: "n".to_string(),
839 content: "a a".to_string(),
840 }))
841 .await
842 .unwrap();
843
844 let result = handler
845 .replace_in_note(Parameters(ReplaceInNoteParams {
846 path: "n".to_string(),
847 old: "a".to_string(),
848 new: "b".to_string(),
849 replace_all: None,
850 regex: None,
851 preview: None,
852 }))
853 .await
854 .unwrap();
855 assert_eq!(result.is_error, Some(true));
856 }
857
858 #[tokio::test]
859 async fn test_delete_note_removes_it() {
860 let (handler, _dir) = make_handler().await;
861 handler
862 .create_note(Parameters(CreateNoteParams {
863 path: "n".to_string(),
864 content: "x".to_string(),
865 }))
866 .await
867 .unwrap();
868
869 let result = handler
870 .delete_note(Parameters(DeleteNoteParams {
871 path: "n".to_string(),
872 }))
873 .await
874 .unwrap();
875 assert!(is_success(&result), "got: {:?}", result_text(&result));
876
877 let shown = handler
878 .show_note(Parameters(ShowNoteParams {
879 path: "n".to_string(),
880 }))
881 .await
882 .unwrap();
883 assert_eq!(shown.is_error, Some(true));
884 }
885
886 #[tokio::test]
887 async fn test_show_note_returns_content() {
888 let (handler, _dir) = make_handler().await;
889 handler
890 .create_note(Parameters(CreateNoteParams {
891 path: "show/me".to_string(),
892 content: "# Show me\n\nsome content".to_string(),
893 }))
894 .await
895 .unwrap();
896 let result = handler
897 .show_note(Parameters(ShowNoteParams {
898 path: "show/me".to_string(),
899 }))
900 .await
901 .unwrap();
902 assert!(is_success(&result));
903 assert!(result_text(&result).contains("some content"));
904 }
905
906 #[tokio::test]
907 async fn test_show_note_not_found_returns_error_result() {
908 let (handler, _dir) = make_handler().await;
909 let result = handler
910 .show_note(Parameters(ShowNoteParams {
911 path: "missing/note".to_string(),
912 }))
913 .await
914 .unwrap();
915 assert_eq!(result.is_error, Some(true));
916 }
917
918 #[tokio::test]
919 async fn test_append_note_creates_if_absent() {
920 let (handler, _dir) = make_handler().await;
921 let result = handler
922 .append_note(Parameters(AppendNoteParams {
923 path: "new/note".to_string(),
924 content: "appended text".to_string(),
925 }))
926 .await
927 .unwrap();
928 assert!(is_success(&result));
929 let show = handler
930 .show_note(Parameters(ShowNoteParams {
931 path: "new/note".to_string(),
932 }))
933 .await
934 .unwrap();
935 assert!(result_text(&show).contains("appended text"));
936 }
937
938 #[tokio::test]
939 async fn test_append_note_appends_to_existing() {
940 let (handler, _dir) = make_handler().await;
941 handler
942 .create_note(Parameters(CreateNoteParams {
943 path: "exist/note".to_string(),
944 content: "original".to_string(),
945 }))
946 .await
947 .unwrap();
948 handler
949 .append_note(Parameters(AppendNoteParams {
950 path: "exist/note".to_string(),
951 content: "added".to_string(),
952 }))
953 .await
954 .unwrap();
955 let show = handler
956 .show_note(Parameters(ShowNoteParams {
957 path: "exist/note".to_string(),
958 }))
959 .await
960 .unwrap();
961 let text = result_text(&show);
962 assert!(text.contains("original"), "missing 'original' in: {}", text);
963 assert!(text.contains("added"), "missing 'added' in: {}", text);
964 let orig_pos = text.find("original").expect("original not found");
965 let added_pos = text.find("added").expect("added not found");
966 assert!(orig_pos < added_pos, "original should appear before added");
967 }
968
969 #[tokio::test]
970 async fn test_search_notes_finds_match() {
971 let (handler, _dir) = make_handler().await;
972 handler
973 .create_note(Parameters(CreateNoteParams {
974 path: "alpha/one".to_string(),
975 content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
976 }))
977 .await
978 .unwrap();
979 let result = handler
980 .search_notes(Parameters(SearchNotesParams {
981 query: "unique_keyword_xyz".to_string(),
982 }))
983 .await
984 .unwrap();
985 assert!(
986 is_success(&result),
987 "expected success: {}",
988 result_text(&result)
989 );
990 assert!(
991 result_text(&result).contains("alpha/one"),
992 "search result did not include 'alpha/one': {}",
993 result_text(&result)
994 );
995 }
996
997 #[tokio::test]
998 async fn test_search_notes_returns_empty_for_no_match() {
999 let (handler, _dir) = make_handler().await;
1000 let result = handler
1001 .search_notes(Parameters(SearchNotesParams {
1002 query: "nonexistent_zzz_123".to_string(),
1003 }))
1004 .await
1005 .unwrap();
1006 assert!(is_success(&result));
1007 }
1008
1009 #[tokio::test]
1010 async fn test_list_notes_returns_all() {
1011 let (handler, _dir) = make_handler().await;
1012 handler
1013 .create_note(Parameters(CreateNoteParams {
1014 path: "folder/a".to_string(),
1015 content: "note a".to_string(),
1016 }))
1017 .await
1018 .unwrap();
1019 handler
1020 .create_note(Parameters(CreateNoteParams {
1021 path: "folder/b".to_string(),
1022 content: "note b".to_string(),
1023 }))
1024 .await
1025 .unwrap();
1026 let result = handler
1027 .list_notes(Parameters(ListNotesParams { path: None }))
1028 .await
1029 .unwrap();
1030 assert!(is_success(&result));
1031 let text = result_text(&result);
1032 assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
1033 assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
1034 }
1035
1036 #[tokio::test]
1037 async fn test_journal_appends_to_today() {
1038 let (handler, _dir) = make_handler().await;
1039 let result = handler
1040 .journal(Parameters(JournalParams {
1041 text: "Today's thought".to_string(),
1042 date: None,
1043 }))
1044 .await
1045 .unwrap();
1046 assert!(
1047 is_success(&result),
1048 "expected success: {}",
1049 result_text(&result)
1050 );
1051 assert!(
1052 result_text(&result).contains("saved"),
1053 "expected 'saved' in result: {}",
1054 result_text(&result)
1055 );
1056 }
1057
1058 #[tokio::test]
1059 async fn test_journal_with_explicit_date() {
1060 let (handler, _dir) = make_handler().await;
1061 let result = handler
1062 .journal(Parameters(JournalParams {
1063 text: "Entry for specific date".to_string(),
1064 date: Some("2026-01-15".to_string()),
1065 }))
1066 .await
1067 .unwrap();
1068 assert!(
1069 is_success(&result),
1070 "expected success: {}",
1071 result_text(&result)
1072 );
1073 }
1074
1075 #[tokio::test]
1076 async fn test_journal_invalid_date_returns_error() {
1077 let (handler, _dir) = make_handler().await;
1078 let result = handler
1079 .journal(Parameters(JournalParams {
1080 text: "bad date".to_string(),
1081 date: Some("not-a-date".to_string()),
1082 }))
1083 .await
1084 .unwrap();
1085 assert_eq!(
1086 result.is_error,
1087 Some(true),
1088 "expected error for invalid date"
1089 );
1090 }
1091
1092 #[tokio::test]
1093 async fn test_get_backlinks_empty_for_no_links() {
1094 let (handler, _dir) = make_handler().await;
1095 handler
1096 .create_note(Parameters(CreateNoteParams {
1097 path: "standalone".to_string(),
1098 content: "# Standalone\n\nNo links here.".to_string(),
1099 }))
1100 .await
1101 .unwrap();
1102 let result = handler
1103 .get_backlinks(Parameters(BacklinksParams {
1104 path: "standalone".to_string(),
1105 }))
1106 .await
1107 .unwrap();
1108 assert!(is_success(&result));
1109 }
1110
1111 #[tokio::test]
1112 async fn test_get_backlinks_finds_linking_note() {
1113 let (handler, _dir) = make_handler().await;
1114 handler
1115 .create_note(Parameters(CreateNoteParams {
1116 path: "target".to_string(),
1117 content: "# Target".to_string(),
1118 }))
1119 .await
1120 .unwrap();
1121 handler
1122 .create_note(Parameters(CreateNoteParams {
1123 path: "source".to_string(),
1124 content: "links to [[target]]".to_string(),
1125 }))
1126 .await
1127 .unwrap();
1128 let result = handler
1129 .get_backlinks(Parameters(BacklinksParams {
1130 path: "target".to_string(),
1131 }))
1132 .await
1133 .unwrap();
1134 assert!(is_success(&result));
1135 assert!(
1136 result_text(&result).contains("source"),
1137 "expected 'source' in backlinks: {}",
1138 result_text(&result)
1139 );
1140 }
1141
1142 #[tokio::test]
1143 async fn test_get_chunks_returns_sections() {
1144 let (handler, _dir) = make_handler().await;
1145 handler
1146 .create_note(Parameters(CreateNoteParams {
1147 path: "chunked".to_string(),
1148 content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
1149 .to_string(),
1150 }))
1151 .await
1152 .unwrap();
1153 let result = handler
1154 .get_chunks(Parameters(ChunksParams {
1155 path: "chunked".to_string(),
1156 }))
1157 .await
1158 .unwrap();
1159 assert!(is_success(&result));
1160 assert!(
1161 result_text(&result).contains("Section"),
1162 "expected section in chunks: {}",
1163 result_text(&result)
1164 );
1165 }
1166
1167 #[tokio::test]
1168 async fn test_get_chunks_missing_note_returns_gracefully() {
1169 let (handler, _dir) = make_handler().await;
1170 let result = handler
1173 .get_chunks(Parameters(ChunksParams {
1174 path: "missing/note".to_string(),
1175 }))
1176 .await;
1177 let _ = result;
1179 }
1180
1181 #[tokio::test]
1191 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1192 async fn test_list_resources_returns_notes() {
1193 let (handler, _dir) = make_handler().await;
1194 handler
1195 .create_note(Parameters(CreateNoteParams {
1196 path: "res/alpha".to_string(),
1197 content: "# Alpha Note".to_string(),
1198 }))
1199 .await
1200 .unwrap();
1201 unreachable!("test is ignored");
1206 }
1207
1208 #[tokio::test]
1209 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1210 async fn test_read_resource_returns_content() {
1211 let (handler, _dir) = make_handler().await;
1212 handler
1213 .create_note(Parameters(CreateNoteParams {
1214 path: "res/beta".to_string(),
1215 content: "# Beta\n\nbeta content".to_string(),
1216 }))
1217 .await
1218 .unwrap();
1219 unreachable!("test is ignored");
1222 }
1223
1224 #[tokio::test]
1225 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1226 async fn test_read_resource_not_found_returns_error() {
1227 let (handler, _dir) = make_handler().await;
1228 let _ = &handler;
1231 unreachable!("test is ignored");
1232 }
1233
1234 #[tokio::test]
1235 #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1236 async fn test_read_resource_invalid_scheme_returns_error() {
1237 let (handler, _dir) = make_handler().await;
1238 let _ = &handler;
1241 unreachable!("test is ignored");
1242 }
1243
1244 #[tokio::test]
1245 async fn test_get_outlinks_returns_linked_notes() {
1246 let (handler, _dir) = make_handler().await;
1247 handler
1248 .create_note(Parameters(CreateNoteParams {
1249 path: "source".to_string(),
1250 content: "# Source\n\nSee [[target]] for more.".to_string(),
1251 }))
1252 .await
1253 .unwrap();
1254 handler
1255 .create_note(Parameters(CreateNoteParams {
1256 path: "target".to_string(),
1257 content: "# Target\n\nContent here.".to_string(),
1258 }))
1259 .await
1260 .unwrap();
1261 let result = handler
1262 .get_outlinks(Parameters(OutlinksParams {
1263 path: "source".to_string(),
1264 }))
1265 .await
1266 .unwrap();
1267 assert!(
1268 is_success(&result),
1269 "expected success: {}",
1270 result_text(&result)
1271 );
1272 assert!(
1273 result_text(&result).contains("target"),
1274 "expected 'target' in outlinks: {}",
1275 result_text(&result)
1276 );
1277 }
1278
1279 #[tokio::test]
1280 async fn test_get_outlinks_no_links_returns_empty_message() {
1281 let (handler, _dir) = make_handler().await;
1282 handler
1283 .create_note(Parameters(CreateNoteParams {
1284 path: "no-links".to_string(),
1285 content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1286 }))
1287 .await
1288 .unwrap();
1289 let result = handler
1290 .get_outlinks(Parameters(OutlinksParams {
1291 path: "no-links".to_string(),
1292 }))
1293 .await
1294 .unwrap();
1295 assert!(is_success(&result));
1296 assert!(
1297 result_text(&result).contains("No outlinks found"),
1298 "expected empty message: {}",
1299 result_text(&result)
1300 );
1301 }
1302
1303 #[tokio::test]
1304 async fn test_get_outlinks_note_not_found_returns_error() {
1305 let (handler, _dir) = make_handler().await;
1306 let result = handler
1307 .get_outlinks(Parameters(OutlinksParams {
1308 path: "missing/note".to_string(),
1309 }))
1310 .await
1311 .unwrap();
1312 assert_eq!(result.is_error, Some(true));
1313 }
1314
1315 #[tokio::test]
1316 async fn test_rename_note_succeeds() {
1317 let (handler, _dir) = make_handler().await;
1318 handler
1319 .create_note(Parameters(CreateNoteParams {
1320 path: "old-name".to_string(),
1321 content: "# Old\n\nunique_rename_content_xyz".to_string(),
1322 }))
1323 .await
1324 .unwrap();
1325 let result = handler
1326 .rename_note(Parameters(RenameNoteParams {
1327 path: "old-name".to_string(),
1328 new_name: "new-name".to_string(),
1329 }))
1330 .await
1331 .unwrap();
1332 assert!(
1333 is_success(&result),
1334 "expected success: {}",
1335 result_text(&result)
1336 );
1337 let show = handler
1338 .show_note(Parameters(ShowNoteParams {
1339 path: "new-name".to_string(),
1340 }))
1341 .await
1342 .unwrap();
1343 assert!(is_success(&show), "new path should be readable");
1344 assert!(result_text(&show).contains("unique_rename_content_xyz"));
1345 let old = handler
1346 .show_note(Parameters(ShowNoteParams {
1347 path: "old-name".to_string(),
1348 }))
1349 .await
1350 .unwrap();
1351 assert_eq!(old.is_error, Some(true), "old path should be gone");
1352 }
1353
1354 #[tokio::test]
1355 async fn test_rename_note_rejects_slash_in_name() {
1356 let (handler, _dir) = make_handler().await;
1357 handler
1358 .create_note(Parameters(CreateNoteParams {
1359 path: "some/note".to_string(),
1360 content: "content".to_string(),
1361 }))
1362 .await
1363 .unwrap();
1364 let result = handler
1365 .rename_note(Parameters(RenameNoteParams {
1366 path: "some/note".to_string(),
1367 new_name: "other/dir".to_string(),
1368 }))
1369 .await
1370 .unwrap();
1371 assert_eq!(result.is_error, Some(true));
1372 assert!(
1373 result_text(&result).contains("move_note"),
1374 "hint should mention move_note: {}",
1375 result_text(&result)
1376 );
1377 }
1378
1379 #[tokio::test]
1380 async fn test_rename_note_updates_backlinks() {
1381 let (handler, _dir) = make_handler().await;
1382 handler
1383 .create_note(Parameters(CreateNoteParams {
1384 path: "target".to_string(),
1385 content: "# Target".to_string(),
1386 }))
1387 .await
1388 .unwrap();
1389 handler
1390 .create_note(Parameters(CreateNoteParams {
1391 path: "linker".to_string(),
1392 content: "see [[target]] for details".to_string(),
1393 }))
1394 .await
1395 .unwrap();
1396 handler
1397 .rename_note(Parameters(RenameNoteParams {
1398 path: "target".to_string(),
1399 new_name: "renamed-target".to_string(),
1400 }))
1401 .await
1402 .unwrap();
1403 let show = handler
1404 .show_note(Parameters(ShowNoteParams {
1405 path: "linker".to_string(),
1406 }))
1407 .await
1408 .unwrap();
1409 assert!(
1410 result_text(&show).contains("renamed-target"),
1411 "backlink should be updated: {}",
1412 result_text(&show)
1413 );
1414 }
1415
1416 #[tokio::test]
1417 async fn test_move_note_succeeds() {
1418 let (handler, _dir) = make_handler().await;
1419 handler
1420 .create_note(Parameters(CreateNoteParams {
1421 path: "original".to_string(),
1422 content: "# Original\n\nunique_move_content_xyz".to_string(),
1423 }))
1424 .await
1425 .unwrap();
1426 let result = handler
1427 .move_note(Parameters(MoveNoteParams {
1428 path: "original".to_string(),
1429 new_path: "folder/moved".to_string(),
1430 }))
1431 .await
1432 .unwrap();
1433 assert!(
1434 is_success(&result),
1435 "expected success: {}",
1436 result_text(&result)
1437 );
1438 let show = handler
1439 .show_note(Parameters(ShowNoteParams {
1440 path: "folder/moved".to_string(),
1441 }))
1442 .await
1443 .unwrap();
1444 assert!(is_success(&show));
1445 assert!(result_text(&show).contains("unique_move_content_xyz"));
1446 let old = handler
1447 .show_note(Parameters(ShowNoteParams {
1448 path: "original".to_string(),
1449 }))
1450 .await
1451 .unwrap();
1452 assert_eq!(old.is_error, Some(true), "old path should be gone");
1453 }
1454
1455 #[tokio::test]
1456 async fn test_move_note_fails_if_destination_exists() {
1457 let (handler, _dir) = make_handler().await;
1458 handler
1459 .create_note(Parameters(CreateNoteParams {
1460 path: "src".to_string(),
1461 content: "source".to_string(),
1462 }))
1463 .await
1464 .unwrap();
1465 handler
1466 .create_note(Parameters(CreateNoteParams {
1467 path: "dst".to_string(),
1468 content: "destination".to_string(),
1469 }))
1470 .await
1471 .unwrap();
1472 let result = handler
1473 .move_note(Parameters(MoveNoteParams {
1474 path: "src".to_string(),
1475 new_path: "dst".to_string(),
1476 }))
1477 .await
1478 .unwrap();
1479 assert_eq!(result.is_error, Some(true));
1480 }
1481
1482 #[tokio::test]
1483 async fn test_list_notes_filters_by_prefix() {
1484 let (handler, _dir) = make_handler().await;
1485 handler
1486 .create_note(Parameters(CreateNoteParams {
1487 path: "projects/foo".to_string(),
1488 content: "foo".to_string(),
1489 }))
1490 .await
1491 .unwrap();
1492 handler
1493 .create_note(Parameters(CreateNoteParams {
1494 path: "journal/2026-01-01".to_string(),
1495 content: "journal".to_string(),
1496 }))
1497 .await
1498 .unwrap();
1499 let result = handler
1500 .list_notes(Parameters(ListNotesParams {
1501 path: Some("projects".to_string()),
1502 }))
1503 .await
1504 .unwrap();
1505 assert!(is_success(&result));
1506 let text = result_text(&result);
1507 assert!(
1508 text.contains("projects/foo"),
1509 "missing projects/foo: {}",
1510 text
1511 );
1512 assert!(
1513 !text.contains("journal/2026"),
1514 "should not include journal: {}",
1515 text
1516 );
1517 }
1518}