use std::ops::Deref;
use super::super::*;
#[derive(Clone)]
pub(super) struct FileProvider(CoreToolHandler);
impl FileProvider {
pub(super) fn new(core: CoreToolHandler) -> Self {
Self(core)
}
}
impl Deref for FileProvider {
type Target = CoreToolHandler;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[turbomcp::server(name = "obsidian-vault", version = "1.6.0")]
impl FileProvider {
#[tool(
description = "Read complete markdown content of a note from active vault",
usage = "Use before editing, analyzing, or displaying notes. Supports all Obsidian Flavored Markdown syntax including wikilinks [[note]], embeds ![[image.png]], and block references ^block-id",
performance = "Fast (<10ms typical). Returns path, content, and content hash for conflict detection",
related = ["write_note", "edit_note", "get_backlinks"],
examples = ["daily/2024-01-15.md", "projects/website-redesign.md"],
tags = ["read"],
read_only = true,
)]
async fn read_note(&self, path: String) -> McpResult<serde_json::Value> {
let (vault_name, manager) = self.get_vault_pair().await?;
let tools = FileTools::new(manager);
let content = tools.read_file(&path).await.map_err(to_mcp_error)?;
let hash = self.hash_for_active_backend(&content).await?;
let uri = obsidian_uri(&vault_name, &path);
StandardResponse::new(
&vault_name,
"read_note",
serde_json::json!({"path": path, "content": content, "hash": hash, "uri": uri}),
)
.with_read_next_steps()
.to_json()
}
#[tool(
description = "Write a note with overwrite/append/prepend mode and optimistic concurrency. Existing overwrite targets require expected_hash unless force=true. Git-backed vaults commit the mutation atomically.",
usage = "Read existing notes first and pass the returned expected_hash. Use force=true only for an intentional blind overwrite. commit_message controls the Git commit subject when using write_backend=git.",
performance = "Moderate (<50ms typical). Includes filesystem write and link graph update",
related = ["read_note", "edit_note", "create_from_template"],
examples = ["mode: overwrite (default)", "mode: append (add to end)", "mode: prepend (add after frontmatter)", "expected_hash: <hash from read_note>"],
tags = ["write"],
destructive = true,
)]
async fn write_note(
&self,
path: String,
content: String,
mode: Option<String>,
expected_hash: Option<String>,
force: Option<bool>,
commit_message: Option<String>,
) -> McpResult<serde_json::Value> {
let vault_name = self.get_active_vault_name().await?;
let write_mode = WriteMode::from_str_opt(mode.as_deref()).map_err(to_mcp_error)?;
let tools = self.get_active_write_tools().await?;
let message = self
.resolve_commit_message(commit_message, || format!("write_note {path}"))
.await?;
let force = force.unwrap_or(false);
if tools.is_git() && !force && expected_hash.is_none() && write_mode == WriteMode::Overwrite
{
let manager = self.get_active_vault_manager().await?;
if tokio::fs::try_exists(manager.vault_path().join(&path))
.await
.unwrap_or(false)
{
return Err(McpError::invalid_request(format!(
"write_note refused: '{path}' exists. Read it and pass expected_hash, or pass force=true to acknowledge a blind overwrite."
)));
}
tools
.create_file_with_message(&path, &content, &message)
.await
.map_err(to_mcp_error)?;
} else {
tools
.write_file_with_mode_and_message(
&path,
&content,
write_mode,
expected_hash.as_deref(),
&message,
)
.await
.map_err(to_mcp_error)?;
}
self.invalidate_similarity_cache().await;
self.invalidate_search_cache().await;
let mode_str = mode.as_deref().unwrap_or("overwrite");
StandardResponse::new(
vault_name,
"write_note",
serde_json::json!({"path": path, "status": "written", "bytes": content.len(), "mode": mode_str}),
)
.with_write_next_steps()
.to_json()
}
#[tool(
description = "Apply targeted edits using SEARCH/REPLACE blocks (safer than full overwrite)",
usage = "Use for precise modifications without reading/writing entire file. Requires exact match of search text. Supports optional content hash for conflict detection and dry_run mode for preview. Returns applied changes, rejected changes, and new hash",
performance = "Fast (<30ms typical). More efficient than read+write cycle for small edits",
related = ["read_note", "write_note"],
examples = [],
tags = ["write"],
destructive = true,
)]
async fn edit_note(
&self,
path: String,
edits: String,
expected_hash: Option<String>,
dry_run: Option<bool>,
commit_message: Option<String>,
) -> McpResult<serde_json::Value> {
let vault_name = self.get_active_vault_name().await?;
let tools = self.get_active_write_tools().await?;
let dry_run = dry_run.unwrap_or(false);
let message = self
.resolve_commit_message(commit_message, || format!("edit_note {path}"))
.await?;
let result = tools
.edit_file_with_message(&path, &edits, expected_hash.as_deref(), dry_run, &message)
.await
.map_err(to_mcp_error)?;
self.invalidate_similarity_cache().await;
self.invalidate_search_cache().await;
StandardResponse::new(
vault_name,
"edit_note",
serde_json::to_value(&result).map_err(|e| McpError::internal(e.to_string()))?,
)
.with_next_steps(&["read_note", "write_note"])
.to_json()
}
#[tool(
description = "Delete a note with confirmation, concurrency protection, and backlink safety. By default refuses when inbound links exist; use on_backlinks='rewrite-stale-callout' for an atomic delete+rewrite, or force=true to leave broken links.",
usage = "confirm_path must exactly match path. Pass expected_hash from read_note. Git-backed rewrite mode updates every linker in the same atomic commit.",
performance = "Fast (<20ms typical). Includes filesystem delete and link graph update",
related = ["get_backlinks", "get_broken_links", "move_note"],
examples = ["path: drafts/old-idea.md, confirm_path: drafts/old-idea.md"],
tags = ["write", "delete"],
destructive = true,
)]
async fn delete_note(
&self,
path: String,
confirm_path: String,
expected_hash: Option<String>,
commit_message: Option<String>,
force: Option<bool>,
on_backlinks: Option<String>,
) -> McpResult<serde_json::Value> {
if path != confirm_path {
return Err(McpError::invalid_request(format!(
"Confirmation failed: confirm_path '{}' does not match path '{}'. Both must be identical to proceed with deletion.",
confirm_path, path
)));
}
let vault_name = self.get_active_vault_name().await?;
let tools = self.get_active_write_tools().await?;
let message = self
.resolve_commit_message(commit_message, || format!("delete_note {path}"))
.await?;
let force = force.unwrap_or(!tools.is_git());
let backlinks = if force {
Vec::new()
} else {
tools
.list_inbound_backlinks(&path)
.await
.map_err(to_mcp_error)?
};
let updated_sources = if backlinks.is_empty() || force {
tools
.delete_file_with_hash_and_message(&path, expected_hash.as_deref(), &message)
.await
.map_err(to_mcp_error)?;
Vec::new()
} else if on_backlinks.as_deref() == Some("rewrite-stale-callout") {
tools
.delete_file_with_link_rewrite_to_stale(&path, expected_hash.as_deref(), &message)
.await
.map_err(to_mcp_error)?
.link_sources_updated
} else {
return Err(McpError::invalid_request(format!(
"delete_note refused: '{path}' has {} inbound backlink(s): {}. Pass on_backlinks='rewrite-stale-callout' or force=true.",
backlinks.len(),
backlinks
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join(", ")
)));
};
self.invalidate_similarity_cache().await;
self.invalidate_search_cache().await;
StandardResponse::new(
vault_name,
"delete_note",
serde_json::json!({"path": path, "status": "deleted", "link_sources_updated": updated_sources}),
)
.with_next_step("quick_health_check")
.to_json()
}
#[tool(
description = "Move or rename a note. Git-backed vaults update inbound wikilinks atomically by default; set update_backlinks=false for a rename-only operation.",
usage = "Pass expected_hash from read_note. With update_backlinks=true, a concurrent change to the source or any linker aborts the entire move with nothing committed.",
performance = "Fast (<20ms typical). Filesystem rename, falls back to copy+delete for cross-filesystem moves",
related = ["get_backlinks", "get_forward_links", "search"],
examples = [],
tags = ["write"],
destructive = true,
)]
async fn move_note(
&self,
from: String,
to: String,
expected_hash: Option<String>,
commit_message: Option<String>,
update_backlinks: Option<bool>,
) -> McpResult<serde_json::Value> {
let vault_name = self.get_active_vault_name().await?;
let tools = self.get_active_write_tools().await?;
let message = self
.resolve_commit_message(commit_message, || format!("move_note {from} -> {to}"))
.await?;
let update_backlinks = update_backlinks.unwrap_or_else(|| tools.is_git());
let updated_sources = if update_backlinks {
self.flush_reindex_for_active_vault().await?;
tools
.move_file_with_link_updates(&from, &to, expected_hash.as_deref(), &message)
.await
.map_err(to_mcp_error)?
.link_sources_updated
} else {
tools
.move_file_with_hash_and_message(&from, &to, expected_hash.as_deref(), &message)
.await
.map_err(to_mcp_error)?;
Vec::new()
};
self.invalidate_similarity_cache().await;
self.invalidate_search_cache().await;
let response = StandardResponse::new(
vault_name,
"move_note",
serde_json::json!({"from": from, "to": to, "status": "moved", "link_sources_updated": updated_sources}),
)
.with_next_steps(&["get_backlinks", "get_forward_links"]);
if update_backlinks {
response.to_json()
} else {
response
.with_warning(
"Links pointing to the old path were not updated and may now be broken.",
)
.to_json()
}
}
}