obsidian_patch_content
Insert content at specific locations within Obsidian notes using headings, blocks, or frontmatter to append, prepend, or replace text.
Instructions
Insert content at specific locations within notes using headings, blocks, or frontmatter.
CRITICAL: For heading targets, you MUST provide the FULL HIERARCHICAL PATH.
Args:
params (PatchContentInput): Contains:
- filepath (str): Path to file
- target_type (TargetType): 'heading', 'block', or 'frontmatter'
- target (str): See examples below for correct format
- operation (PatchOperation): 'append', 'prepend', or 'replace'
- content (str): Content to insert
Returns:
str: Success message with patch details
HEADING PATH EXAMPLES (MUST use full path with '/'):
✅ CORRECT:
- target="Introduction" (for top-level # Introduction)
- target="Methods/Data Collection" (for ## Data Collection under # Methods)
- target="Results/Analysis/Statistical Tests" (for ### Statistical Tests under ## Analysis under # Results)
❌ WRONG:
- target="Data Collection" (missing parent "Methods")
- target="Statistical Tests" (missing parents "Results/Analysis")
BLOCK REFERENCE EXAMPLE:
- target_type="block", target="^unique-block-id"
FRONTMATTER EXAMPLE:
- target_type="frontmatter", target="tags"
Note: Always read the file first to see the exact heading structure before patching.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| params | Yes |
Implementation Reference
- Main handler function that implements the obsidian_patch_content tool logic. Handles patching content at headings, blocks, or frontmatter using read/modify/write pattern with helper functions.async def patch_content(params: PatchContentInput) -> str: """Insert content at specific locations within notes using headings, blocks, or frontmatter. CRITICAL: For heading targets, you MUST provide the FULL HIERARCHICAL PATH. Args: params (PatchContentInput): Contains: - filepath (str): Path to file - target_type (TargetType): 'heading', 'block', or 'frontmatter' - target (str): See examples below for correct format - operation (PatchOperation): 'append', 'prepend', or 'replace' - content (str): Content to insert Returns: str: Success message with patch details HEADING PATH EXAMPLES (MUST use full path with '/'): ✅ CORRECT: - target="Introduction" (for top-level # Introduction) - target="Methods/Data Collection" (for ## Data Collection under # Methods) - target="Results/Analysis/Statistical Tests" (for ### Statistical Tests under ## Analysis under # Results) ❌ WRONG: - target="Data Collection" (missing parent "Methods") - target="Statistical Tests" (missing parents "Results/Analysis") BLOCK REFERENCE EXAMPLE: - target_type="block", target="^unique-block-id" FRONTMATTER EXAMPLE: - target_type="frontmatter", target="tags" Note: Always read the file first to see the exact heading structure before patching. """ try: # Read current content current_content = await obsidian_client.read_file(params.filepath) if params.target_type == TargetType.FRONTMATTER: # Update frontmatter field fm, body = obsidian_client.parse_frontmatter(current_content) if params.operation == PatchOperation.REPLACE: fm[params.target] = params.content elif params.operation == PatchOperation.APPEND: current_value = fm.get(params.target, "") fm[params.target] = str(current_value) + "\n" + params.content if current_value else params.content elif params.operation == PatchOperation.PREPEND: current_value = fm.get(params.target, "") fm[params.target] = params.content + "\n" + str(current_value) if current_value else params.content new_content = obsidian_client.serialize_with_frontmatter(fm, body) elif params.target_type == TargetType.HEADING: # Find heading position position = find_heading_position(current_content, params.target) if position is None: return json.dumps({ "error": f"Heading not found: '{params.target}'", "filepath": params.filepath, "hint": "Use FULL hierarchical path like 'Parent/Child/Grandchild'. Read the file first to see exact heading structure.", "suggestion": "Use obsidian_get_file_contents to view the note's heading structure, then use the complete path.", "success": False }, indent=2) if params.operation == PatchOperation.APPEND: new_content = current_content[:position] + "\n" + params.content + current_content[position:] elif params.operation == PatchOperation.PREPEND: # Find start of heading line lines_before = current_content[:position].split("\n") heading_line_start = position - len(lines_before[-1]) - 1 new_content = current_content[:heading_line_start] + params.content + "\n" + current_content[heading_line_start:] else: # REPLACE # Find next heading or end of file lines = current_content[position:].split("\n") next_heading_idx = None for i, line in enumerate(lines): if re.match(r'^#{1,6}\s+', line.strip()): next_heading_idx = i break if next_heading_idx: section_end = position + sum(len(l) + 1 for l in lines[:next_heading_idx]) new_content = current_content[:position] + "\n" + params.content + "\n" + current_content[section_end:] else: new_content = current_content[:position] + "\n" + params.content elif params.target_type == TargetType.BLOCK: # Find block reference position = find_block_position(current_content, params.target) if position is None: return json.dumps({ "error": f"Block reference not found: {params.target}", "filepath": params.filepath, "success": False }, indent=2) if params.operation == PatchOperation.APPEND: new_content = current_content[:position] + "\n" + params.content + current_content[position:] elif params.operation == PatchOperation.PREPEND: # Find start of block line lines_before = current_content[:position].split("\n") block_line_start = position - len(lines_before[-1]) - 1 new_content = current_content[:block_line_start] + params.content + "\n" + current_content[block_line_start:] else: # REPLACE - replace the entire line containing the block reference lines_before = current_content[:position].split("\n") block_line_start = position - len(lines_before[-1]) - 1 line_end = current_content.find("\n", position) if line_end == -1: line_end = len(current_content) new_content = current_content[:block_line_start] + params.content + current_content[line_end:] # Write updated content await obsidian_client.write_file(params.filepath, new_content) return json.dumps({ "success": True, "message": f"Content patched successfully using {params.operation.value} on {params.target_type.value}", "filepath": params.filepath, "target": params.target }, indent=2) except ObsidianAPIError as e: return json.dumps({ "error": str(e), "filepath": params.filepath, "success": False }, indent=2)
- src/custom_obsidian_mcp/server.py:776-785 (registration)MCP tool registration decorator that registers the patch_content function as 'obsidian_patch_content' tool.@mcp.tool( name="obsidian_patch_content", annotations={ "title": "Patch Content at Specific Location", "readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": False } )
- Pydantic input schema defining parameters for the tool: filepath, target_type (heading/block/frontmatter), target, operation (append/prepend/replace), content.class PatchContentInput(BaseModel): """Input for patching content relative to headings/blocks/frontmatter.""" model_config = ConfigDict(str_strip_whitespace=True, extra='forbid') filepath: str = Field( description="Path to the file to patch", min_length=1, max_length=500 ) target_type: TargetType = Field( description="Type of target: 'heading' for markdown headers, 'block' for block references, 'frontmatter' for YAML metadata" ) target: str = Field( description=( "IMPORTANT: For 'heading' type, MUST use FULL HIERARCHICAL PATH with '/' separator. " "Examples: 'Introduction' (top-level), 'Methods/Data Collection' (nested), 'Results/Analysis/Statistics' (deeply nested). " "For 'block' type: block reference like '^block-id'. " "For 'frontmatter' type: field name like 'tags' or 'status'." ), min_length=1, max_length=200 ) operation: PatchOperation = Field( description="Operation: 'append' to add after target, 'prepend' to add before target, 'replace' to overwrite target" ) content: str = Field( description="Content to insert", min_length=1, max_length=50000 )
- Helper function to locate the exact position after a hierarchical heading path in markdown content (e.g., 'Parent/Child'). Used for targeted patching.def find_heading_position(content: str, heading_path: str) -> Optional[int]: """ Find the position of a heading in markdown content. Args: content: The markdown content heading_path: Path like "Section/Subsection" Returns: Position after the heading line, or None if not found """ parts = heading_path.split("/") lines = content.split("\n") current_level = 0 current_path = [] for i, line in enumerate(lines): # Check if this is a heading heading_match = re.match(r'^(#{1,6})\s+(.+)$', line.strip()) if heading_match: level = len(heading_match.group(1)) title = heading_match.group(2).strip() # Adjust path based on heading level if level <= current_level: current_path = current_path[:level-1] current_path.append(title) current_level = level # Check if we found our target if current_path == parts: # Return position at end of this line return sum(len(l) + 1 for l in lines[:i+1]) return None
- Helper function to locate a block reference (e.g., '^block-id') in content for precise patching operations.def find_block_position(content: str, block_ref: str) -> Optional[int]: """ Find the position of a block reference. Args: content: The markdown content block_ref: Block reference like "^block-id" Returns: Position after the block, or None if not found """ # Remove ^ prefix if present block_id = block_ref.lstrip("^") # Look for the block reference pattern = rf'\^{re.escape(block_id)}\b' match = re.search(pattern, content) if match: return match.end() return None