1+ use std:: collections:: HashMap ;
12use std:: path:: { Path , PathBuf } ;
23use std:: sync:: Arc ;
4+ use std:: time:: SystemTime ;
35
46use anyhow:: Result ;
57use rmcp:: handler:: server:: tool:: ToolRouter ;
@@ -17,6 +19,7 @@ use crate::llm::{EmbedModel, OrchestratorModel, RerankModel};
1719use crate :: profile:: VaultProfile ;
1820use crate :: search;
1921use crate :: store:: Store ;
22+ use crate :: writer:: FrontmatterOp ;
2023
2124// ---------------------------------------------------------------------------
2225// Parameter structs
@@ -131,10 +134,52 @@ pub struct ReadSectionParams {
131134#[ derive( Debug , Deserialize , JsonSchema ) ]
132135pub struct HealthParams { }
133136
137+ #[ derive( Debug , Deserialize , JsonSchema ) ]
138+ pub struct EditParams {
139+ /// Target note: file path, basename, or #docid.
140+ pub file : String ,
141+ /// Section heading to edit (case-insensitive).
142+ pub heading : String ,
143+ /// Content to add/replace in the section.
144+ pub content : String ,
145+ /// Edit mode: "replace", "prepend", or "append" (default: "append").
146+ pub mode : Option < String > ,
147+ }
148+
149+ #[ derive( Debug , Deserialize , JsonSchema ) ]
150+ pub struct RewriteParams {
151+ /// Target note: file path, basename, or #docid.
152+ pub file : String ,
153+ /// New body content (replaces everything below frontmatter).
154+ pub content : String ,
155+ /// Whether to preserve existing frontmatter (default: true).
156+ pub preserve_frontmatter : Option < bool > ,
157+ }
158+
159+ #[ derive( Debug , Deserialize , JsonSchema ) ]
160+ pub struct EditFrontmatterParams {
161+ /// Target note: file path, basename, or #docid.
162+ pub file : String ,
163+ /// Operations to apply. Array of objects like {"op": "add_tag", "value": "rust"} or {"op": "set", "key": "status", "value": "done"} or {"op": "remove", "key": "status"} or {"op": "remove_tag", "value": "old"}.
164+ pub operations : Vec < serde_json:: Value > ,
165+ }
166+
167+ #[ derive( Debug , Deserialize , JsonSchema ) ]
168+ pub struct DeleteParams {
169+ /// Target note: file path, basename, or #docid.
170+ pub file : String ,
171+ /// Delete mode: "soft" (archive, default) or "hard" (permanent).
172+ pub mode : Option < String > ,
173+ }
174+
134175// ---------------------------------------------------------------------------
135176// Server
136177// ---------------------------------------------------------------------------
137178
179+ /// Map of recently-written file paths to their mtime.
180+ /// Used to tell the watcher "I just wrote this file, skip re-indexing it."
181+ pub type RecentWrites = Arc < Mutex < HashMap < PathBuf , SystemTime > > > ;
182+
138183#[ derive( Clone ) ]
139184pub struct EngraphServer {
140185 store : Arc < Mutex < Store > > ,
@@ -146,6 +191,8 @@ pub struct EngraphServer {
146191 orchestrator : Option < Arc < Mutex < Box < dyn OrchestratorModel + Send > > > > ,
147192 /// Result reranker (None when intelligence is disabled or failed to load).
148193 reranker : Option < Arc < Mutex < Box < dyn RerankModel + Send > > > > ,
194+ /// Tracks files recently written by MCP tools so the watcher can skip re-indexing them.
195+ recent_writes : RecentWrites ,
149196}
150197
151198fn mcp_err ( e : & anyhow:: Error ) -> McpError {
@@ -167,6 +214,109 @@ fn to_json_result<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpE
167214 Ok ( CallToolResult :: success ( vec ! [ Content :: text( json) ] ) )
168215}
169216
217+ /// Record a recently-written file path + mtime so the watcher can skip re-indexing it.
218+ async fn record_write ( recent_writes : & RecentWrites , path : & Path ) {
219+ if let Ok ( meta) = std:: fs:: metadata ( path) {
220+ if let Ok ( mtime) = meta. modified ( ) {
221+ recent_writes. lock ( ) . await . insert ( path. to_path_buf ( ) , mtime) ;
222+ }
223+ }
224+ }
225+
226+ /// Parse a JSON operations array into `Vec<FrontmatterOp>`.
227+ fn parse_frontmatter_ops ( operations : & [ serde_json:: Value ] ) -> Result < Vec < FrontmatterOp > , McpError > {
228+ let mut ops = Vec :: with_capacity ( operations. len ( ) ) ;
229+ for op_val in operations {
230+ let op_str = op_val
231+ . get ( "op" )
232+ . and_then ( |v| v. as_str ( ) )
233+ . ok_or_else ( || {
234+ McpError :: new (
235+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
236+ "each operation must have an \" op\" string field" ,
237+ None :: < serde_json:: Value > ,
238+ )
239+ } ) ?;
240+ match op_str {
241+ "set" => {
242+ let key = op_val. get ( "key" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
243+ McpError :: new (
244+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
245+ "\" set\" operation requires a \" key\" field" ,
246+ None :: < serde_json:: Value > ,
247+ )
248+ } ) ?;
249+ let value = op_val. get ( "value" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
250+ McpError :: new (
251+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
252+ "\" set\" operation requires a \" value\" field" ,
253+ None :: < serde_json:: Value > ,
254+ )
255+ } ) ?;
256+ ops. push ( FrontmatterOp :: Set ( key. to_string ( ) , value. to_string ( ) ) ) ;
257+ }
258+ "remove" => {
259+ let key = op_val. get ( "key" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
260+ McpError :: new (
261+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
262+ "\" remove\" operation requires a \" key\" field" ,
263+ None :: < serde_json:: Value > ,
264+ )
265+ } ) ?;
266+ ops. push ( FrontmatterOp :: Remove ( key. to_string ( ) ) ) ;
267+ }
268+ "add_tag" => {
269+ let value = op_val. get ( "value" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
270+ McpError :: new (
271+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
272+ "\" add_tag\" operation requires a \" value\" field" ,
273+ None :: < serde_json:: Value > ,
274+ )
275+ } ) ?;
276+ ops. push ( FrontmatterOp :: AddTag ( value. to_string ( ) ) ) ;
277+ }
278+ "remove_tag" => {
279+ let value = op_val. get ( "value" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
280+ McpError :: new (
281+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
282+ "\" remove_tag\" operation requires a \" value\" field" ,
283+ None :: < serde_json:: Value > ,
284+ )
285+ } ) ?;
286+ ops. push ( FrontmatterOp :: RemoveTag ( value. to_string ( ) ) ) ;
287+ }
288+ "add_alias" => {
289+ let value = op_val. get ( "value" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
290+ McpError :: new (
291+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
292+ "\" add_alias\" operation requires a \" value\" field" ,
293+ None :: < serde_json:: Value > ,
294+ )
295+ } ) ?;
296+ ops. push ( FrontmatterOp :: AddAlias ( value. to_string ( ) ) ) ;
297+ }
298+ "remove_alias" => {
299+ let value = op_val. get ( "value" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
300+ McpError :: new (
301+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
302+ "\" remove_alias\" operation requires a \" value\" field" ,
303+ None :: < serde_json:: Value > ,
304+ )
305+ } ) ?;
306+ ops. push ( FrontmatterOp :: RemoveAlias ( value. to_string ( ) ) ) ;
307+ }
308+ unknown => {
309+ return Err ( McpError :: new (
310+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
311+ format ! ( "unknown frontmatter operation: \" {unknown}\" " ) ,
312+ None :: < serde_json:: Value > ,
313+ ) ) ;
314+ }
315+ }
316+ }
317+ Ok ( ops)
318+ }
319+
170320#[ tool_router]
171321impl EngraphServer {
172322 #[ tool(
@@ -458,16 +608,109 @@ impl EngraphServer {
458608 crate :: health:: generate_health_report ( & store, & config) . map_err ( |e| mcp_err ( & e) ) ?;
459609 to_json_result ( & report)
460610 }
611+
612+ #[ tool(
613+ name = "edit" ,
614+ description = "Edit a specific section of a note. Supports replace, prepend, or append modes. Targets sections by heading name."
615+ ) ]
616+ async fn edit ( & self , params : Parameters < EditParams > ) -> Result < CallToolResult , McpError > {
617+ let store = self . store . lock ( ) . await ;
618+ let mode = match params. 0 . mode . as_deref ( ) . unwrap_or ( "append" ) {
619+ "replace" => crate :: writer:: EditMode :: Replace ,
620+ "prepend" => crate :: writer:: EditMode :: Prepend ,
621+ _ => crate :: writer:: EditMode :: Append ,
622+ } ;
623+ let input = crate :: writer:: EditInput {
624+ file : params. 0 . file ,
625+ heading : params. 0 . heading ,
626+ content : params. 0 . content ,
627+ mode,
628+ modified_by : "claude-code" . into ( ) ,
629+ } ;
630+ let result = crate :: writer:: edit_note ( & store, & self . vault_path , & input, None )
631+ . map_err ( |e| mcp_err ( & e) ) ?;
632+ // Record write so the watcher skips re-indexing
633+ let full_path = self . vault_path . join ( & result. path ) ;
634+ record_write ( & self . recent_writes , & full_path) . await ;
635+ to_json_result ( & result)
636+ }
637+
638+ #[ tool(
639+ name = "rewrite" ,
640+ description = "Replace the entire body of a note. Optionally preserves existing frontmatter. Use for major content overhauls."
641+ ) ]
642+ async fn rewrite ( & self , params : Parameters < RewriteParams > ) -> Result < CallToolResult , McpError > {
643+ let store = self . store . lock ( ) . await ;
644+ let input = crate :: writer:: RewriteInput {
645+ file : params. 0 . file ,
646+ content : params. 0 . content ,
647+ preserve_frontmatter : params. 0 . preserve_frontmatter . unwrap_or ( true ) ,
648+ modified_by : "claude-code" . into ( ) ,
649+ } ;
650+ let result = crate :: writer:: rewrite_note ( & store, & self . vault_path , & input)
651+ . map_err ( |e| mcp_err ( & e) ) ?;
652+ let full_path = self . vault_path . join ( & result. path ) ;
653+ record_write ( & self . recent_writes , & full_path) . await ;
654+ to_json_result ( & result)
655+ }
656+
657+ #[ tool(
658+ name = "edit_frontmatter" ,
659+ description = "Edit frontmatter fields with granular operations: set/remove properties, add/remove tags, add/remove aliases."
660+ ) ]
661+ async fn edit_frontmatter (
662+ & self ,
663+ params : Parameters < EditFrontmatterParams > ,
664+ ) -> Result < CallToolResult , McpError > {
665+ let ops = parse_frontmatter_ops ( & params. 0 . operations ) ?;
666+ let store = self . store . lock ( ) . await ;
667+ let input = crate :: writer:: EditFrontmatterInput {
668+ file : params. 0 . file ,
669+ operations : ops,
670+ modified_by : "claude-code" . into ( ) ,
671+ } ;
672+ let result = crate :: writer:: edit_frontmatter ( & store, & self . vault_path , & input)
673+ . map_err ( |e| mcp_err ( & e) ) ?;
674+ let full_path = self . vault_path . join ( & result. path ) ;
675+ record_write ( & self . recent_writes , & full_path) . await ;
676+ to_json_result ( & result)
677+ }
678+
679+ #[ tool(
680+ name = "delete" ,
681+ description = "Delete a note. Soft mode (default) moves it to the archive folder. Hard mode permanently removes it from disk and index."
682+ ) ]
683+ async fn delete ( & self , params : Parameters < DeleteParams > ) -> Result < CallToolResult , McpError > {
684+ let store = self . store . lock ( ) . await ;
685+ let mode = match params. 0 . mode . as_deref ( ) . unwrap_or ( "soft" ) {
686+ "hard" => crate :: writer:: DeleteMode :: Hard ,
687+ _ => crate :: writer:: DeleteMode :: Soft ,
688+ } ;
689+ let archive_folder = self
690+ . profile
691+ . as_ref ( )
692+ . as_ref ( )
693+ . and_then ( |p| p. structure . folders . archive . as_deref ( ) )
694+ . unwrap_or ( "04-Archive" ) ;
695+ crate :: writer:: delete_note ( & store, & self . vault_path , & params. 0 . file , mode, archive_folder)
696+ . map_err ( |e| mcp_err ( & e) ) ?;
697+ let result = serde_json:: json!( {
698+ "deleted" : params. 0 . file,
699+ "mode" : params. 0 . mode. as_deref( ) . unwrap_or( "soft" ) ,
700+ } ) ;
701+ to_json_result ( & result)
702+ }
461703}
462704
463705#[ tool_handler]
464706impl rmcp:: handler:: server:: ServerHandler for EngraphServer {
465707 fn get_info ( & self ) -> ServerInfo {
466708 ServerInfo :: new ( ServerCapabilities :: builder ( ) . enable_tools ( ) . build ( ) ) . with_instructions (
467709 "engraph: vault intelligence for Obsidian. \
468- Read: vault_map to orient, search to find, read for content, who/project for context. \
469- Write: create for new notes, append to add content, update_metadata for tags/aliases, move_note to relocate. \
470- Lifecycle: archive to soft-delete (moves to archive, removes from index), unarchive to restore.",
710+ Read: vault_map to orient, search to find, read/read_section for content, who/project for context bundles, health for vault diagnostics. \
711+ Write: create for new notes, append to add content, edit to modify a section, rewrite to replace body, \
712+ edit_frontmatter for tags/properties, update_metadata for bulk tag/alias replacement. \
713+ Lifecycle: move_note to relocate, archive to soft-delete, unarchive to restore, delete for permanent removal.",
471714 )
472715 }
473716}
@@ -540,6 +783,7 @@ pub async fn run_serve(data_dir: &Path) -> Result<()> {
540783 Arc :: new ( Mutex :: new ( Box :: new ( embedder) as Box < dyn EmbedModel + Send > ) ) ;
541784 let vault_path_arc = Arc :: new ( vault_path) ;
542785 let profile_arc = Arc :: new ( profile) ;
786+ let recent_writes: RecentWrites = Arc :: new ( Mutex :: new ( HashMap :: new ( ) ) ) ;
543787
544788 // Start file watcher for real-time index updates
545789 let mut exclude = config. exclude . clone ( ) ;
@@ -558,6 +802,7 @@ pub async fn run_serve(data_dir: &Path) -> Result<()> {
558802 profile_arc. clone ( ) ,
559803 config,
560804 exclude,
805+ recent_writes. clone ( ) ,
561806 ) ?;
562807
563808 let server = EngraphServer {
@@ -568,6 +813,7 @@ pub async fn run_serve(data_dir: &Path) -> Result<()> {
568813 tool_router : EngraphServer :: tool_router ( ) ,
569814 orchestrator,
570815 reranker,
816+ recent_writes,
571817 } ;
572818
573819 eprintln ! ( "engraph MCP server starting..." ) ;
0 commit comments