Files
ajarbot/mcp_tools.py
Jordan Ramos 50cf7165cb Add sub-agent orchestration, MCP tools, and critical bug fixes
Major Features:
- Sub-agent orchestration system with dynamic specialist spawning
  * spawn_sub_agent(): Create specialists with custom prompts
  * delegate(): Convenience method for task delegation
  * Cached specialists for reuse
  * Separate conversation histories and focused context

- MCP (Model Context Protocol) tool integration
  * Zettelkasten: fleeting_note, daily_note, permanent_note, literature_note
  * Search: search_vault (hybrid search), search_by_tags
  * Web: web_fetch for real-time data
  * Zero-cost file/system operations on Pro subscription

Critical Bug Fixes:
- Fixed max tool iterations (15 → 30, configurable)
- Fixed max_tokens error in Agent SDK query() call
- Fixed MCP tool routing in execute_tool()
  * Routes zettelkasten + web tools to async handlers
  * Prevents "Unknown tool" errors

Documentation:
- SUB_AGENTS.md: Complete guide to sub-agent system
- MCP_MIGRATION.md: Agent SDK migration details
- SOUL.example.md: Sanitized bot identity template
- scheduled_tasks.example.yaml: Sanitized task config template

Security:
- Added obsidian vault to .gitignore
- Protected SOUL.md and MEMORY.md (personal configs)
- Sanitized example configs with placeholders

Dependencies:
- Added beautifulsoup4, httpx, lxml for web scraping
- Updated requirements.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 07:43:31 -07:00

1055 lines
34 KiB
Python

"""MCP Tools - In-process tools using Claude Agent SDK.
These tools run directly in the Python process for better performance
compared to the traditional tool execution flow. File and system tools
are ideal candidates for MCP since they don't require external APIs.
"""
from pathlib import Path
import subprocess
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from datetime import datetime
from claude_agent_sdk import tool, create_sdk_mcp_server
import httpx
from bs4 import BeautifulSoup
# Import memory system for hybrid search
try:
from memory_system import MemorySystem
MEMORY_AVAILABLE = True
except ImportError:
MEMORY_AVAILABLE = False
# Maximum characters of tool output to return (prevents token explosion)
_MAX_TOOL_OUTPUT = 5000
# Maximum page size for web fetching (500KB)
_MAX_WEB_PAGE_SIZE = 500_000
# Maximum text content from web page (10,000 chars ≈ 2,500 tokens)
_MAX_WEB_TEXT = 10_000
# Zettelkasten vault paths
_VAULT_ROOT = Path("memory_workspace/obsidian")
_VAULT_FLEETING = _VAULT_ROOT / "fleeting"
_VAULT_DAILY = _VAULT_ROOT / "daily"
_VAULT_PERMANENT = _VAULT_ROOT / "permanent"
_VAULT_LITERATURE = _VAULT_ROOT / "literature"
def _generate_note_id() -> str:
"""Generate unique timestamp-based note ID (YYYYMMDDHHmmss)."""
return datetime.now().strftime("%Y%m%d%H%M%S")
def _create_frontmatter(note_id: str, title: str, tags: list = None, note_type: str = "fleeting") -> str:
"""Create YAML front matter for zettelkasten note."""
now = datetime.now()
tags_str = ", ".join(tags) if tags else ""
return f"""---
id: {note_id}
title: {title}
created: {now.strftime("%Y-%m-%dT%H:%M:%S")}
modified: {now.strftime("%Y-%m-%dT%H:%M:%S")}
type: {note_type}
tags: [{tags_str}]
---
"""
def _ensure_vault_structure():
"""Ensure zettelkasten vault directories exist."""
for dir_path in [_VAULT_FLEETING, _VAULT_DAILY, _VAULT_PERMANENT, _VAULT_LITERATURE]:
dir_path.mkdir(parents=True, exist_ok=True)
def _get_memory_system() -> Optional['MemorySystem']:
"""Get memory system instance for hybrid search."""
if not MEMORY_AVAILABLE:
return None
try:
# Initialize memory system pointing to obsidian vault
memory = MemorySystem(workspace_dir=_VAULT_ROOT)
return memory
except Exception:
return None
def _find_related_notes_hybrid(title: str, content: str, max_results: int = 5) -> List[tuple]:
"""Find related notes using hybrid search (vector + keyword).
Returns list of (note_title, score) tuples.
"""
memory = _get_memory_system()
if memory:
# Use hybrid search for better results
try:
# Index the vault if needed
for vault_dir in [_VAULT_FLEETING, _VAULT_PERMANENT, _VAULT_LITERATURE]:
if vault_dir.exists():
for note_path in vault_dir.glob("*.md"):
memory.index_file(note_path)
# Search with content + title as query
query = f"{title} {content[:500]}" # Limit content to first 500 chars
results = memory.search_hybrid(query, max_results=max_results)
# Convert results to (title, score) tuples
related = []
for result in results:
note_path = Path(result["path"])
note_title = note_path.stem
if ' - ' in note_title:
note_title = note_title.split(' - ', 1)[1]
# Use snippet match percentage as score
score = result.get("snippet", "").count("**") // 2 # Count of matches
related.append((note_title, score))
return related
except Exception:
pass # Fall back to keyword search
# Fallback: keyword search
related_notes = []
search_terms = title.lower().split() + content.lower().split()[:20]
search_terms = [term for term in search_terms if len(term) > 4]
for vault_dir in [_VAULT_FLEETING, _VAULT_PERMANENT, _VAULT_LITERATURE]:
if not vault_dir.exists():
continue
for note_path in vault_dir.glob("*.md"):
try:
note_content = note_path.read_text(encoding="utf-8").lower()
matches = sum(1 for term in search_terms if term in note_content)
if matches >= 2:
note_title = note_path.stem
if ' - ' in note_title:
note_title = note_title.split(' - ', 1)[1]
related_notes.append((note_title, matches))
except Exception:
continue
related_notes.sort(key=lambda x: x[1], reverse=True)
return related_notes[:max_results]
def _is_safe_url(url: str) -> bool:
"""Validate URL safety - blocks localhost, private IPs, and file:// URLs."""
try:
parsed = urlparse(url)
# Must be HTTP/HTTPS
if parsed.scheme not in ['http', 'https']:
return False
# Must have a hostname
if not parsed.hostname:
return False
# Block localhost and loopback IPs
blocked_hosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']
if parsed.hostname in blocked_hosts:
return False
# Block private IP ranges (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
if parsed.hostname:
parts = parsed.hostname.split('.')
if len(parts) == 4 and parts[0].isdigit():
first_octet = int(parts[0])
# Check for private ranges
if first_octet == 10: # 10.0.0.0/8
return False
if first_octet == 192 and len(parts) > 1 and parts[1].isdigit() and int(parts[1]) == 168: # 192.168.0.0/16
return False
if first_octet == 172 and len(parts) > 1 and parts[1].isdigit():
second_octet = int(parts[1])
if 16 <= second_octet <= 31: # 172.16.0.0/12
return False
return True
except Exception:
return False
@tool(
name="read_file",
description="Read the contents of a file. Use this to view configuration files, code, or any text file.",
input_schema={
"file_path": str,
},
)
async def read_file_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Read and return file contents."""
file_path = args["file_path"]
path = Path(file_path)
if not path.exists():
return {
"content": [{"type": "text", "text": f"Error: File not found: {file_path}"}],
"isError": True
}
try:
content = path.read_text(encoding="utf-8")
if len(content) > _MAX_TOOL_OUTPUT:
content = content[:_MAX_TOOL_OUTPUT] + "\n... (file truncated)"
return {
"content": [{"type": "text", "text": f"Content of {file_path}:\n\n{content}"}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error reading file: {str(e)}"}],
"isError": True
}
@tool(
name="write_file",
description="Write content to a file. Creates a new file or overwrites existing file completely.",
input_schema={
"file_path": str,
"content": str,
},
)
async def write_file_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Write content to a file."""
file_path = args["file_path"]
content = args["content"]
path = Path(file_path)
try:
# Create parent directories if they don't exist
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return {
"content": [{
"type": "text",
"text": f"Successfully wrote to {file_path} ({len(content)} characters)"
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error writing file: {str(e)}"}],
"isError": True
}
@tool(
name="edit_file",
description="Edit a file by replacing specific text. Use this to make targeted changes without rewriting the entire file.",
input_schema={
"file_path": str,
"old_text": str,
"new_text": str,
},
)
async def edit_file_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Edit file by replacing text."""
file_path = args["file_path"]
old_text = args["old_text"]
new_text = args["new_text"]
path = Path(file_path)
if not path.exists():
return {
"content": [{"type": "text", "text": f"Error: File not found: {file_path}"}],
"isError": True
}
try:
content = path.read_text(encoding="utf-8")
if old_text not in content:
return {
"content": [{
"type": "text",
"text": f"Error: Text not found in file. Could not find:\n{old_text[:100]}..."
}],
"isError": True
}
new_content = content.replace(old_text, new_text, 1)
path.write_text(new_content, encoding="utf-8")
return {
"content": [{
"type": "text",
"text": f"Successfully edited {file_path}. Replaced 1 occurrence."
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error editing file: {str(e)}"}],
"isError": True
}
@tool(
name="list_directory",
description="List files and directories in a given path. Useful for exploring the file system.",
input_schema={
"path": str,
},
)
async def list_directory_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""List directory contents."""
dir_path = Path(args.get("path", "."))
if not dir_path.exists():
return {
"content": [{"type": "text", "text": f"Error: Directory not found: {dir_path}"}],
"isError": True
}
if not dir_path.is_dir():
return {
"content": [{"type": "text", "text": f"Error: Not a directory: {dir_path}"}],
"isError": True
}
try:
items = []
for item in sorted(dir_path.iterdir()):
item_type = "DIR " if item.is_dir() else "FILE"
size = "" if item.is_dir() else f" ({item.stat().st_size} bytes)"
items.append(f" {item_type} {item.name}{size}")
if not items:
return {
"content": [{"type": "text", "text": f"Directory {dir_path} is empty"}]
}
return {
"content": [{
"type": "text",
"text": f"Contents of {dir_path}:\n" + "\n".join(items)
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error listing directory: {str(e)}"}],
"isError": True
}
@tool(
name="run_command",
description="Execute a shell command. Use for git operations, running scripts, installing packages, etc.",
input_schema={
"command": str,
"working_dir": str,
},
)
async def run_command_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a shell command."""
command = args["command"]
working_dir = args.get("working_dir", ".")
try:
result = subprocess.run(
command,
shell=True,
cwd=working_dir,
capture_output=True,
text=True,
timeout=30,
)
output = []
if result.stdout:
stdout = result.stdout
if len(stdout) > _MAX_TOOL_OUTPUT:
stdout = stdout[:_MAX_TOOL_OUTPUT] + "\n... (stdout truncated)"
output.append(f"STDOUT:\n{stdout}")
if result.stderr:
stderr = result.stderr
if len(stderr) > _MAX_TOOL_OUTPUT:
stderr = stderr[:_MAX_TOOL_OUTPUT] + "\n... (stderr truncated)"
output.append(f"STDERR:\n{stderr}")
status = f"Command exited with code {result.returncode}"
if not output:
text = status
else:
text = status + "\n\n" + "\n\n".join(output)
return {
"content": [{"type": "text", "text": text}],
"isError": result.returncode != 0
}
except subprocess.TimeoutExpired:
return {
"content": [{"type": "text", "text": "Error: Command timed out after 30 seconds"}],
"isError": True
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error running command: {str(e)}"}],
"isError": True
}
@tool(
name="web_fetch",
description="Fetch and parse content from a web page. Returns the page text content for analysis. Use this to get real-time information from the web.",
input_schema={
"url": str,
},
)
async def web_fetch_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch webpage and return parsed text content.
This is a zero-cost MCP tool - it fetches the HTML and converts to text,
then returns it to the main Agent SDK query for processing (no extra API cost).
"""
url = args["url"]
# Security: Validate URL
if not _is_safe_url(url):
return {
"content": [{
"type": "text",
"text": f"Error: Blocked unsafe URL - {url}\n\nOnly http/https URLs to public domains are allowed."
}],
"isError": True
}
try:
# Fetch page with timeout and size limit
headers = {
"User-Agent": "Garvis/1.0 (Personal Assistant Bot; +https://github.com/anthropics/claude-code)"
}
async with httpx.AsyncClient(
timeout=10.0,
follow_redirects=True,
limits=httpx.Limits(max_connections=5),
headers=headers
) as client:
response = await client.get(url)
response.raise_for_status()
# Check size limit
if len(response.content) > _MAX_WEB_PAGE_SIZE:
return {
"content": [{
"type": "text",
"text": f"Error: Page too large ({len(response.content)} bytes, max {_MAX_WEB_PAGE_SIZE})"
}],
"isError": True
}
# Parse HTML to text
soup = BeautifulSoup(response.content, 'html.parser')
# Remove unwanted elements
for element in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'form']):
element.decompose()
# Extract text
text = soup.get_text(separator='\n', strip=True)
# Clean up excessive whitespace
lines = [line.strip() for line in text.split('\n') if line.strip()]
text = '\n'.join(lines)
# Truncate if needed
if len(text) > _MAX_WEB_TEXT:
text = text[:_MAX_WEB_TEXT] + "\n\n... (content truncated, page is very long)"
# Get title if available
title = soup.title.string if soup.title else "No title"
return {
"content": [{
"type": "text",
"text": f"Fetched: {response.url}\nTitle: {title}\n\n{text}"
}]
}
except httpx.TimeoutException:
return {
"content": [{
"type": "text",
"text": f"Error: Request to {url} timed out after 10 seconds"
}],
"isError": True
}
except httpx.HTTPStatusError as e:
return {
"content": [{
"type": "text",
"text": f"Error: HTTP {e.response.status_code} - {url}"
}],
"isError": True
}
except Exception as e:
return {
"content": [{
"type": "text",
"text": f"Error fetching webpage: {str(e)}"
}],
"isError": True
}
@tool(
name="fleeting_note",
description="Quickly capture a thought or idea as a fleeting note in your zettelkasten. Use this for quick captures that can be processed later into permanent notes.",
input_schema={
"content": str,
"tags": str, # Comma-separated tags (optional)
},
)
async def fleeting_note_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Create a fleeting note (quick capture) in the zettelkasten vault.
Zero-cost MCP tool for instant thought capture. Perfect for ADHD-friendly quick notes.
"""
content = args["content"]
tags_str = args.get("tags", "")
try:
_ensure_vault_structure()
# Generate note ID and extract title from first line
note_id = _generate_note_id()
lines = content.split('\n', 1)
title = lines[0][:50] if lines else "Quick Note"
# Parse tags
tags = [tag.strip() for tag in tags_str.split(',')] if tags_str else []
tags.append("fleeting") # Always tag as fleeting
# Create note file
filename = f"{note_id} - {title.replace(':', '').replace('/', '-')}.md"
note_path = _VAULT_FLEETING / filename
# Write note with front matter
frontmatter = _create_frontmatter(note_id, title, tags, "fleeting")
full_content = frontmatter + content
note_path.write_text(full_content, encoding="utf-8")
return {
"content": [{
"type": "text",
"text": f"Created fleeting note: {filename}\nID: {note_id}\nLocation: {note_path}"
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error creating fleeting note: {str(e)}"}],
"isError": True
}
@tool(
name="daily_note",
description="Add an entry to today's daily note. Use this for journaling, logging activities, or tracking daily thoughts.",
input_schema={
"entry": str,
},
)
async def daily_note_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Append timestamped entry to today's daily note.
Zero-cost MCP tool for daily journaling and activity logging.
"""
entry = args["entry"]
try:
_ensure_vault_structure()
# Get today's date
now = datetime.now()
date_str = now.strftime("%Y-%m-%d")
time_str = now.strftime("%H:%M")
# Daily note path
note_path = _VAULT_DAILY / f"{date_str}.md"
# Create or update daily note
if note_path.exists():
# Append to existing note
current_content = note_path.read_text(encoding="utf-8")
new_entry = f"\n## {time_str}\n{entry}\n"
updated_content = current_content + new_entry
else:
# Create new daily note with front matter
frontmatter = f"""---
date: {date_str}
type: daily
tags: [daily-note]
---
# {date_str}
## {time_str}
{entry}
"""
updated_content = frontmatter
note_path.write_text(updated_content, encoding="utf-8")
return {
"content": [{
"type": "text",
"text": f"Added entry to daily note: {date_str}\nTime: {time_str}\nLocation: {note_path}"
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error updating daily note: {str(e)}"}],
"isError": True
}
@tool(
name="literature_note",
description="Create a literature note from a web article. Fetches the article, extracts key points, and creates a properly formatted zettelkasten note with source citation.",
input_schema={
"url": str,
"tags": str, # Comma-separated tags (optional)
},
)
async def literature_note_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Create literature note from web article.
Zero-cost MCP tool. Combines web_fetch with note creation.
"""
url = args["url"]
tags_str = args.get("tags", "")
try:
_ensure_vault_structure()
# Fetch article content using web_fetch logic
if not _is_safe_url(url):
return {
"content": [{
"type": "text",
"text": f"Error: Blocked unsafe URL - {url}"
}],
"isError": True
}
# Fetch the article
headers = {
"User-Agent": "Garvis/1.0 (Personal Assistant Bot; +https://github.com/anthropics/claude-code)"
}
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True, headers=headers) as client:
response = await client.get(url)
response.raise_for_status()
if len(response.content) > _MAX_WEB_PAGE_SIZE:
return {
"content": [{
"type": "text",
"text": f"Error: Page too large"
}],
"isError": True
}
# Parse content
soup = BeautifulSoup(response.content, 'html.parser')
for element in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'form']):
element.decompose()
text = soup.get_text(separator='\n', strip=True)
lines = [line.strip() for line in text.split('\n') if line.strip()]
text = '\n'.join(lines)[:_MAX_WEB_TEXT]
title = soup.title.string if soup.title else "Untitled Article"
# Generate note
note_id = _generate_note_id()
tags = [tag.strip() for tag in tags_str.split(',')] if tags_str else []
tags.extend(["literature", "web-article"])
# Create note content
note_content = f"""# {title}
**Source**: {url}
**Captured**: {datetime.now().strftime("%Y-%m-%d")}
## Content
{text}
## Notes
(Add your thoughts, key takeaways, and connections to other notes here)
## Related
-
---
*This is a literature note. Process it into permanent notes with key insights.*
"""
# Create filename and path
filename = f"{note_id} - {title[:50].replace(':', '').replace('/', '-')}.md"
note_path = _VAULT_LITERATURE / filename
# Write with frontmatter
frontmatter = _create_frontmatter(note_id, title, tags, "literature")
full_content = frontmatter + note_content
note_path.write_text(full_content, encoding="utf-8")
return {
"content": [{
"type": "text",
"text": f"Created literature note from article\nTitle: {title}\nID: {note_id}\nLocation: {note_path}"
}]
}
except httpx.TimeoutException:
return {
"content": [{"type": "text", "text": f"Error: Request timed out"}],
"isError": True
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error creating literature note: {str(e)}"}],
"isError": True
}
@tool(
name="permanent_note",
description="Create a permanent note with automatic link suggestions to related notes. Use this for refined, well-thought-out notes that form the core of your knowledge base.",
input_schema={
"title": str,
"content": str,
"tags": str, # Comma-separated tags (optional)
},
)
async def permanent_note_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Create permanent note with smart linking suggestions.
Zero-cost MCP tool. Uses simple search for now.
Future: Will use hybrid search for better suggestions.
"""
title = args["title"]
content = args["content"]
tags_str = args.get("tags", "")
try:
_ensure_vault_structure()
# Generate note ID
note_id = _generate_note_id()
# Parse tags
tags = [tag.strip() for tag in tags_str.split(',')] if tags_str else []
tags.append("permanent")
# Search for related notes using hybrid search (vector + keyword)
related_notes = _find_related_notes_hybrid(title, content, max_results=5)
# Build related section
related_section = "\n## Related Notes\n"
if related_notes:
for note_title, _ in related_notes:
related_section += f"- [[{note_title}]]\n"
else:
related_section += "- (No related notes found yet)\n"
# Create full note content
note_content = f"""# {title}
{content}
{related_section}
---
*Created: {datetime.now().strftime("%Y-%m-%d %H:%M")}*
"""
# Create filename
filename = f"{note_id} - {title[:50].replace(':', '').replace('/', '-')}.md"
note_path = _VAULT_PERMANENT / filename
# Write with frontmatter
frontmatter = _create_frontmatter(note_id, title, tags, "permanent")
full_content = frontmatter + note_content
note_path.write_text(full_content, encoding="utf-8")
# Build result message
result_msg = f"Created permanent note: {title}\nID: {note_id}\nLocation: {note_path}"
if related_notes:
result_msg += f"\n\nSuggested links ({len(related_notes)} related notes):"
for note_title, matches in related_notes:
result_msg += f"\n- [[{note_title}]] ({matches} keyword matches)"
return {
"content": [{
"type": "text",
"text": result_msg
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error creating permanent note: {str(e)}"}],
"isError": True
}
@tool(
name="search_by_tags",
description="Search zettelkasten vault by tags. Find all notes with specific tags or tag combinations.",
input_schema={
"tags": str, # Comma-separated tags to search for
"match_all": bool, # If true, note must have ALL tags. If false, ANY tag matches (optional, default false)
"limit": int, # Max results (optional, default 10)
},
)
async def search_by_tags_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Search vault by tags.
Zero-cost MCP tool. Searches YAML frontmatter for tag matches.
"""
tags_str = args["tags"]
match_all = args.get("match_all", False)
limit = args.get("limit", 10)
try:
_ensure_vault_structure()
# Parse search tags
search_tags = [tag.strip().lower() for tag in tags_str.split(',')]
results = []
for vault_dir in [_VAULT_FLEETING, _VAULT_DAILY, _VAULT_PERMANENT, _VAULT_LITERATURE]:
if not vault_dir.exists():
continue
for note_path in vault_dir.glob("*.md"):
try:
content = note_path.read_text(encoding="utf-8")
# Extract tags from frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 2:
frontmatter = parts[1]
# Look for tags line
for line in frontmatter.split('\n'):
if line.strip().startswith('tags:'):
tags_part = line.split(':', 1)[1].strip()
# Remove brackets and split
tags_part = tags_part.strip('[]')
note_tags = [t.strip().lower() for t in tags_part.split(',')]
# Check match
if match_all:
if all(tag in note_tags for tag in search_tags):
results.append(note_path.name)
else:
if any(tag in note_tags for tag in search_tags):
results.append(note_path.name)
break
except Exception:
continue
# Limit results
results = results[:limit]
if not results:
match_type = "all" if match_all else "any"
return {
"content": [{
"type": "text",
"text": f"No notes found with {match_type} of tags: {tags_str}"
}]
}
result_text = f"Found {len(results)} note(s) with tags '{tags_str}':\n\n"
for i, note_name in enumerate(results, 1):
result_text += f"{i}. {note_name}\n"
return {
"content": [{
"type": "text",
"text": result_text
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error searching by tags: {str(e)}"}],
"isError": True
}
@tool(
name="search_vault",
description="Search your zettelkasten vault for notes matching a query. Returns relevant notes with context. Optionally filter by tags.",
input_schema={
"query": str,
"tags": str, # Optional comma-separated tags to filter by
"limit": int, # Max results (optional, default 5)
},
)
async def search_vault_tool(args: Dict[str, Any]) -> Dict[str, Any]:
"""Search zettelkasten vault using hybrid search (vector + keyword).
Zero-cost MCP tool. Uses hybrid search when available for better results.
"""
query = args["query"]
tags_filter = args.get("tags", "")
limit = args.get("limit", 5)
try:
_ensure_vault_structure()
# Try hybrid search first
memory = _get_memory_system()
results = []
if memory and MEMORY_AVAILABLE:
try:
# Index vault
for vault_dir in [_VAULT_FLEETING, _VAULT_DAILY, _VAULT_PERMANENT, _VAULT_LITERATURE]:
if vault_dir.exists():
for note_path in vault_dir.glob("*.md"):
memory.index_file(note_path)
# Hybrid search
search_results = memory.search_hybrid(query, max_results=limit * 2)
# Convert to results format
for result in search_results:
note_path = Path(result["path"])
results.append({
"file": note_path.name,
"path": str(note_path),
"snippet": result.get("snippet", "")[:300]
})
except Exception:
pass # Fall back to simple search
# Fallback: simple keyword search
if not results:
query_lower = query.lower()
for vault_dir in [_VAULT_FLEETING, _VAULT_DAILY, _VAULT_PERMANENT, _VAULT_LITERATURE]:
if not vault_dir.exists():
continue
for note_path in vault_dir.glob("*.md"):
try:
content = note_path.read_text(encoding="utf-8")
if query_lower in content.lower():
# Extract snippet
lines = content.split('\n')
for i, line in enumerate(lines):
if query_lower in line.lower():
start = max(0, i - 2)
end = min(len(lines), i + 3)
snippet = '\n'.join(lines[start:end])
results.append({
"file": note_path.name,
"path": str(note_path),
"snippet": snippet[:300]
})
break
except Exception:
continue
# Filter by tags if specified
if tags_filter:
filter_tags = [t.strip().lower() for t in tags_filter.split(',')]
filtered_results = []
for result in results:
try:
note_path = Path(result["path"])
content = note_path.read_text(encoding="utf-8")
# Check tags in frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 2:
frontmatter = parts[1]
for line in frontmatter.split('\n'):
if line.strip().startswith('tags:'):
tags_part = line.split(':', 1)[1].strip().strip('[]')
note_tags = [t.strip().lower() for t in tags_part.split(',')]
if any(tag in note_tags for tag in filter_tags):
filtered_results.append(result)
break
except Exception:
continue
results = filtered_results
# Limit results
results = results[:limit]
if not results:
tag_msg = f" with tags '{tags_filter}'" if tags_filter else ""
return {
"content": [{
"type": "text",
"text": f"No notes found matching: {query}{tag_msg}"
}]
}
# Format results
tag_msg = f" (filtered by tags: {tags_filter})" if tags_filter else ""
result_text = f"Found {len(results)} note(s) matching '{query}'{tag_msg}:\n\n"
for i, result in enumerate(results, 1):
result_text += f"{i}. {result['file']}\n"
result_text += f" {result['snippet']}\n\n"
return {
"content": [{
"type": "text",
"text": result_text
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error searching vault: {str(e)}"}],
"isError": True
}
# Create the MCP server with all tools (file/system + web + zettelkasten)
file_system_server = create_sdk_mcp_server(
name="file_system",
version="1.4.0",
tools=[
read_file_tool,
write_file_tool,
edit_file_tool,
list_directory_tool,
run_command_tool,
web_fetch_tool,
fleeting_note_tool,
daily_note_tool,
literature_note_tool,
permanent_note_tool,
search_vault_tool,
search_by_tags_tool,
]
)