Skip to content

Commit 93baefc

Browse files
committed
feat(serve): register edit/rewrite/edit_frontmatter/delete MCP tools + watcher coordination
1 parent dbd4159 commit 93baefc

2 files changed

Lines changed: 279 additions & 3 deletions

File tree

src/serve.rs

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
use std::collections::HashMap;
12
use std::path::{Path, PathBuf};
23
use std::sync::Arc;
4+
use std::time::SystemTime;
35

46
use anyhow::Result;
57
use rmcp::handler::server::tool::ToolRouter;
@@ -17,6 +19,7 @@ use crate::llm::{EmbedModel, OrchestratorModel, RerankModel};
1719
use crate::profile::VaultProfile;
1820
use crate::search;
1921
use 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)]
132135
pub 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)]
139184
pub 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

151198
fn 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]
171321
impl 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]
464706
impl 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...");

src/watcher.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::indexer;
1414
use crate::llm::EmbedModel;
1515
use crate::placement;
1616
use crate::profile::VaultProfile;
17+
use crate::serve::RecentWrites;
1718
use crate::store::Store;
1819

1920
/// Start the file watcher and consumer. Returns a thread handle for the producer
@@ -27,6 +28,7 @@ pub fn start_watcher(
2728
profile: Arc<Option<VaultProfile>>,
2829
config: Config,
2930
exclude: Vec<String>,
31+
recent_writes: RecentWrites,
3032
) -> anyhow::Result<(std::thread::JoinHandle<()>, oneshot::Sender<()>)> {
3133
let (tx, rx) = mpsc::channel::<Vec<WatchEvent>>(64);
3234
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
@@ -64,6 +66,7 @@ pub fn start_watcher(
6466
vault_clone,
6567
profile_clone,
6668
config_clone,
69+
recent_writes,
6770
)
6871
.await;
6972
});
@@ -270,6 +273,26 @@ fn detect_moves(events: &mut Vec<WatchEvent>, store: &Store, vault_path: &Path)
270273
}
271274
}
272275

276+
/// Check if a file was recently written by an MCP tool (so the watcher should skip it).
277+
/// Returns true if the file's current mtime matches the recorded write mtime.
278+
async fn is_recent_write(recent_writes: &RecentWrites, path: &Path) -> bool {
279+
let mut map = recent_writes.lock().await;
280+
if let Some(recorded_mtime) = map.get(path) {
281+
if let Ok(meta) = std::fs::metadata(path) {
282+
if let Ok(current_mtime) = meta.modified() {
283+
if current_mtime == *recorded_mtime {
284+
// Match — this file was written by us; remove entry and skip
285+
map.remove(path);
286+
return true;
287+
}
288+
}
289+
}
290+
// mtime doesn't match (file was modified again externally) — remove stale entry
291+
map.remove(path);
292+
}
293+
false
294+
}
295+
273296
/// Consumer async task that processes batches of watch events.
274297
///
275298
/// Two-pass processing:
@@ -282,6 +305,7 @@ pub async fn run_consumer(
282305
vault_path: Arc<PathBuf>,
283306
_profile: Arc<Option<VaultProfile>>,
284307
config: Config,
308+
recent_writes: RecentWrites,
285309
) {
286310
tracing::info!("Watcher consumer started");
287311

@@ -301,6 +325,12 @@ pub async fn run_consumer(
301325
for event in &events {
302326
match event {
303327
WatchEvent::Changed(path) => {
328+
// Skip files recently written by MCP tools to avoid redundant re-indexing
329+
if is_recent_write(&recent_writes, path).await {
330+
tracing::debug!(path = %path.display(), "skipping re-index for MCP-written file");
331+
continue;
332+
}
333+
304334
let rel = path
305335
.strip_prefix(vault_path.as_ref())
306336
.unwrap_or(path)

0 commit comments

Comments
 (0)