diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md new file mode 100644 index 00000000..a363224c --- /dev/null +++ b/docs/mcp-tools.md @@ -0,0 +1,1345 @@ + + +# Basic Memory MCP Tool Reference + +Complete reference for all MCP tools exposed by the Basic Memory server. +Tools are grouped by function. Parameters marked *(required)* have no default value. + +> **Regenerating this file**: run `uv run scripts/generate_tool_docs.py` from the +> repository root. The output is deterministic; running it twice should produce +> an identical file (zero diff). + +## Table of Contents + + +- [Note Management](#note-management) + - [`write_note`](#write-note) + - [`read_note`](#read-note) + - [`view_note`](#view-note) + - [`edit_note`](#edit-note) + - [`move_note`](#move-note) + - [`delete_note`](#delete-note) +- [Reading & Navigation](#reading-navigation) + - [`read_content`](#read-content) + - [`build_context`](#build-context) + - [`recent_activity`](#recent-activity) + - [`list_directory`](#list-directory) +- [Search](#search) + - [`search_notes`](#search-notes) + - [`search`](#search) + - [`fetch`](#fetch) +- [Project & Workspace Management](#project-workspace-management) + - [`list_memory_projects`](#list-memory-projects) + - [`create_memory_project`](#create-memory-project) + - [`delete_project`](#delete-project) + - [`list_workspaces`](#list-workspaces) +- [Schema Tools](#schema-tools) + - [`schema_validate`](#schema-validate) + - [`schema_infer`](#schema-infer) + - [`schema_diff`](#schema-diff) +- [Visualization](#visualization) + - [`canvas`](#canvas) +- [Info & Utilities](#info-utilities) + - [`cloud_info`](#cloud-info) + - [`release_notes`](#release-notes) + + +--- + + +## Note Management + +### `write_note` + +Create a markdown note. If the note already exists, returns an error by default — pass overwrite=True to replace. + +Write a markdown note to the knowledge base. + +Creates a markdown note with semantic observations and relations. +If the note already exists, returns an error by default. Pass overwrite=True +to replace the existing note. For incremental updates, use edit_note instead. + +Project Resolution: +Server resolves projects using a unified priority chain (same in local and cloud modes): +Single Project Mode → project parameter → default project. +Uses default project automatically. Specify `project` parameter to target a different project. + +The content can include semantic observations and relations using markdown syntax: + +Observations format: + `- [category] Observation text #tag1 #tag2 (optional context)` + + Examples: + `- [design] Files are the source of truth #architecture (All state comes from files)` + `- [tech] Using SQLite for storage #implementation` + `- [note] Need to add error handling #todo` + +Relations format: + - Explicit: `- relation_type [[Entity]] (optional context)` + - Quoted: `- "multi word relation type" [[Entity]] (optional context)` + - Quoted: `- 'multi word relation type' [[Entity]] (optional context)` + - Inline: Any other `[[Entity]]` reference creates a `links_to` relation + + Examples: + `- depends_on [[Content Parser]] (Need for semantic extraction)` + `- "based on" [[Design Notes]]` + `- 'in response to' [[Incident Review]]` + `- implements [[Search Spec]] (Initial implementation)` + `- This feature extends [[Base Design]] and uses [[Core Utils]]` + +Returns: + A markdown formatted summary of the semantic content, including: + - Creation/update status with project name + - File path and checksum + - Observation counts by category + - Relation counts (resolved/unresolved) + - Tags if present + - Session tracking metadata for project awareness + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | `str` | *(required)* | | +| `content` | `str` | *(required)* | | +| `directory` | `str` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `tags` | `list[str] | str | None` | `None` | | +| `note_type` | `str` | `'note'` | | +| `metadata` | `dict | None` | `None` | | +| `overwrite` | `bool | None` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Create a simple note (uses default project automatically) + write_note( + project="my-research", + title="Meeting Notes", + directory="meetings", + content="# Weekly Standup\n\n- [decision] Use SQLite for storage #tech" + ) + + # Create a note with tags and note type + write_note( + project="work-project", + title="API Design", + directory="specs", + content="# REST API Specification\n\n- implements [[Authentication]]", + tags=["api", "design"], + note_type="guide" + ) + + # Overwrite an existing note explicitly + write_note( + project="my-research", + title="Meeting Notes", + directory="meetings", + content="# Weekly Standup\n\n- [decision] Use PostgreSQL instead #tech", + overwrite=True + ) + + # Create a schema note with custom frontmatter via metadata + write_note( + title="Person", + directory="schemas", + note_type="schema", + content="# Person\n\nSchema for person entities.", + metadata={ + "entity": "person", + "version": 1, + "schema": {"name": "string", "role?": "string"}, + "settings": {"validation": "warn"}, + }, + ) + +Raises: + HTTPError: If project doesn't exist or is inaccessible + SecurityError: If directory path attempts path traversal +``` + +*Source: `src/basic_memory/mcp/tools/write_note.py`* + +--- + +### `read_note` + +Read a markdown note by title or permalink. + +Return the raw markdown for a note, or guidance text if no match is found. + +Finds and retrieves a note by its title, permalink, or content search, +returning the raw markdown content including observations, relations, and metadata. + +Project Resolution: +Server resolves projects using a unified priority chain (same in local and cloud modes): +Single Project Mode → project parameter → default project. +Uses default project automatically. Specify `project` parameter to target a different project. + +This tool will try multiple lookup strategies to find the most relevant note: +1. Direct permalink lookup +2. Title search fallback +3. Text search as last resort + +Returns: + The full markdown content of the note if found, or helpful guidance if not found. + Content includes frontmatter, observations, relations, and all markdown formatting. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `identifier` | `str` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `page` | `int` | `1` | | +| `page_size` | `int` | `10` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | +| `include_frontmatter` | `bool` | `False` | | + +**Examples** + +```python +# Read by permalink + read_note("my-research", "specs/search-spec") + + # Read by title + read_note("work-project", "Search Specification") + + # Read with memory URL + read_note("my-research", "memory://specs/search-spec") + + # Read recent meeting notes + read_note("team-docs", "Weekly Standup") + + # Page through fallback-search suggestions when nothing matches directly + read_note("unknown topic", page=2, page_size=5) + +Raises: + HTTPError: If project doesn't exist or is inaccessible + SecurityError: If identifier attempts path traversal + +Note: + If the exact note isn't found, this tool provides helpful suggestions + including related notes, search commands, and note creation templates. +``` + +*Source: `src/basic_memory/mcp/tools/read_note.py`* + +--- + +### `view_note` + +View a note as a formatted artifact for better readability. + +View a markdown note as a formatted artifact. + +This tool reads a note using the same logic as read_note but instructs Claude +to display the content as a markdown artifact in the Claude Desktop app. +Project parameter optional with server resolution. + +Returns: + Instructions for Claude to create a markdown artifact with the note content. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `identifier` | `str` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | + +**Examples** + +```python +# View a note by title + view_note("Meeting Notes") + + # View a note by permalink + view_note("meetings/weekly-standup") + + # Explicit project specification + view_note("Meeting Notes", project="my-project") + +Raises: + HTTPError: If project doesn't exist or is inaccessible + SecurityError: If identifier attempts path traversal +``` + +*Source: `src/basic_memory/mcp/tools/view_note.py`* + +--- + +### `edit_note` + +Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section. + +Edit an existing markdown note in the knowledge base. + +Makes targeted changes to existing notes without rewriting the entire content. + +Project Resolution: +Server resolves projects in this order: Single Project Mode → project parameter → default project. +If project unknown, use list_memory_projects() or recent_activity() first. + +Returns: + A markdown formatted summary of the edit operation and resulting semantic content, + including operation details, file path, observations, relations, and project metadata. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `identifier` | `str` | *(required)* | | +| `operation` | `str` | *(required)* | | +| `content` | `str` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `workspace` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `section` | `Annotated[Optional[str], Field(default=None, validation_alias=AliasChoices('section', 'section_heading', 'heading'))]` | `None` | | +| `find_text` | `Annotated[Optional[str], Field(default=None, validation_alias=AliasChoices('find_text', 'find', 'old_text', 'old_content', 'search'))]` | `None` | | +| `expected_replacements` | `Optional[int]` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Add new content to end of note + edit_note("my-project", "project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y") + + # Add timestamp at beginning (frontmatter-aware) + edit_note("work-docs", "meeting-notes", "prepend", "## 2025-05-25 Update\n- Progress update...\n\n") + + # Update version number (single occurrence) + edit_note("api-project", "config-spec", "find_replace", "v0.13.0", find_text="v0.12.0") + + # Update version in multiple places with validation + edit_note("docs-project", "api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3) + + # Replace text that appears multiple times - validate count first + edit_note("team-docs", "docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5) + + # Replace implementation section + edit_note("specs", "api-spec", "replace_section", "New implementation approach...\n", section="## Implementation") + + # Replace subsection with more specific header + edit_note("docs", "docs/setup", "replace_section", "Updated install steps\n", section="### Installation") + + # Using different identifier formats (must be exact matches) + edit_note("work-project", "Meeting Notes", "append", "\n- Follow up on action items") # exact title + edit_note("work-project", "docs/meeting-notes", "append", "\n- Follow up tasks") # exact permalink + + # If uncertain about identifier, search first: + # search_notes("work-project", "meeting") # Find available notes + # edit_note("work-project", "docs/meeting-notes-2025", "append", "content") # Use exact result + + # Add new section to document + edit_note("planning", "project-plan", "replace_section", "TBD - needs research\n", section="## Future Work") + + # Update status across document (expecting exactly 2 occurrences) + edit_note("reports", "status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2) + +Raises: + HTTPError: If project doesn't exist or is inaccessible + ValueError: If operation is invalid or required parameters are missing + SecurityError: If identifier attempts path traversal + +Note: + Edit operations require exact identifier matches. If unsure, use read_note() or + search_notes() first to find the correct identifier. When the identifier looks + like a file path and the file exists on disk but is not indexed yet, edit_note + indexes that file automatically and retries the edit. The tool provides detailed + error messages with suggestions if operations fail. +``` + +*Source: `src/basic_memory/mcp/tools/edit_note.py`* + +--- + +### `move_note` + +Move a note or directory to a new location, updating database and maintaining links. + +Move a note or directory to a new location within the same project. + +Moves a note or directory from one location to another within the project, +updating all database references and maintaining semantic content. Uses stateless +architecture - project parameter optional with server resolution. + +Returns: + Success message with move details and project information. + For directories, includes count of files moved and any errors. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `identifier` | `str` | *(required)* | | +| `destination_path` | `str` | `''` | | +| `destination_folder` | `Annotated[Optional[str], Field(default=None, validation_alias=AliasChoices('destination_folder', 'dest_folder', 'to_folder'))]` | `None` | | +| `is_directory` | `bool` | `False` | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Move a single note to new folder (exact title match) + move_note("My Note", "work/notes/my-note.md") + + # Move by exact permalink + move_note("my-note-permalink", "archive/old-notes/my-note.md") + + # Move note to archive folder (filename preserved automatically) + move_note("my-note", destination_folder="archive") + + # Move with complex path structure + move_note("experiments/ml-results", "archive/2025/ml-experiments.md") + + # Explicit project specification + move_note("My Note", "work/notes/my-note.md", project="work-project") + + # Move entire directory + move_note("docs", "archive/docs", is_directory=True) + + # Move nested directory + move_note("projects/2024", "archive/projects/2024", is_directory=True) + + # If uncertain about identifier, search first: + # search_notes("my note") # Find available notes + # move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result + +Raises: + ToolError: If project doesn't exist, identifier is not found, or destination_path is invalid + +Note: + This operation moves notes within the specified project only. Moving notes + between different projects is not currently supported. + +The move operation: +- Updates the entity's file_path in the database +- Moves the physical file on the filesystem +- Optionally updates permalinks if configured +- Re-indexes the entity for search +- Maintains all observations and relations +``` + +*Source: `src/basic_memory/mcp/tools/move_note.py`* + +--- + +### `delete_note` + +Delete a note or directory by title, permalink, or path + +Delete a note or directory from the knowledge base. + +Permanently removes a note or directory from the specified project. For single notes, +they are identified by title or permalink. For directories, use is_directory=True and +provide the directory path. If the note/directory doesn't exist, the operation returns +False without error. If deletion fails, helpful error messages are provided. + +Project Resolution: +Server resolves projects in this order: Single Project Mode → project parameter → default project. +If project unknown, use list_memory_projects() or recent_activity() first. + +Returns: + True if note was successfully deleted, False if note was not found. + For directories, returns a formatted summary of deleted files. + On errors, returns a formatted string with helpful troubleshooting guidance. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `identifier` | `str` | *(required)* | | +| `is_directory` | `bool` | `False` | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Delete by title + delete_note("Meeting Notes: Project Planning") + + # Delete by permalink + delete_note("notes/project-planning") + + # Delete with explicit project + delete_note("experiments/ml-model-results", project="research") + + # Delete entire directory + delete_note("docs", is_directory=True) + + # Delete nested directory + delete_note("projects/2024", is_directory=True) + + # Common usage pattern + if delete_note("old-draft"): + print("Note deleted successfully") + else: + print("Note not found or already deleted") + +Raises: + HTTPError: If project doesn't exist or is inaccessible + SecurityError: If identifier attempts path traversal + +Warning: + This operation is permanent and cannot be undone. The note/directory files + will be removed from the filesystem and all references will be lost. + +Note: + If the note is not found, this function provides helpful error messages + with suggestions for finding the correct identifier, including search + commands and alternative formats to try. +``` + +*Source: `src/basic_memory/mcp/tools/delete_note.py`* + +--- + + +## Reading & Navigation + +### `read_content` + +Read a file's raw content by path or permalink + +Read a file's raw content by path or permalink. + +This tool provides direct access to file content in the knowledge base, +handling different file types appropriately. Uses stateless architecture - +project parameter optional with server resolution. + +Supported file types: +- Text files (markdown, code, etc.) are returned as plain text +- Images are automatically resized/optimized for display +- Other binary files are returned as base64 if below size limits + +Returns: + A dictionary with the file content and metadata: + - For text: {"type": "text", "text": "content", "content_type": "text/markdown", "encoding": "utf-8"} + - For images: {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "base64_data"}} + - For other files: {"type": "document", "source": {"type": "base64", "media_type": "content_type", "data": "base64_data"}} + - For errors: {"type": "error", "error": "error message"} + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `path` | `str` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | + +**Examples** + +```python +# Read a markdown file + result = await read_content("docs/project-specs.md") + + # Read an image + image_data = await read_content("assets/diagram.png") + + # Read using memory URL + content = await read_content("memory://docs/architecture") + + # Read configuration file + config = await read_content("config/settings.json") + + # Explicit project specification + result = await read_content("docs/project-specs.md", project="my-project") + +Raises: + HTTPError: If project doesn't exist or is inaccessible + SecurityError: If path attempts path traversal +``` + +*Source: `src/basic_memory/mcp/tools/read_content.py`* + +--- + +### `build_context` + +Build context from a memory:// URI to continue conversations naturally. + + Use this to follow up on previous discussions or explore related topics. + + Memory URL Format: + - Use paths like "folder/note" or "memory://folder/note" + - Pattern matching: "folder/*" matches all notes in folder + - Valid characters: letters, numbers, hyphens, underscores, forward slashes + - Avoid: double slashes (//), angle brackets (<>), quotes, pipes (|) + - Examples: "specs/search", "projects/basic-memory", "notes/*" + + Timeframes support natural language like: + - "2 days ago", "last week", "today", "3 months ago" + - Or standard formats like "7d", "24h" + + Format options: + - "json" (default): Structured JSON with internal fields excluded + - "text": Compact markdown text for LLM consumption + +Get context needed to continue a discussion within a specific project. + +This tool enables natural continuation of discussions by loading relevant context +from memory:// URIs. It uses pattern matching to find relevant content and builds +a rich context graph of related information. + +Project Resolution: +Server resolves projects using a unified priority chain (same in local and cloud modes): +Single Project Mode → project parameter → default project. +Uses default project automatically. Specify `project` parameter to target a different project. + +Returns: + dict (output_format="json"): Structured JSON with internal fields excluded + str (output_format="text"): Compact markdown representation + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `url` | `MemoryUrl` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `depth` | `str | int | None` | `1` | | +| `timeframe` | `Annotated[Optional[TimeFrame], Field(default='7d', validation_alias=AliasChoices('timeframe', 'since', 'time_range', 'lookback'))]` | `'7d'` | | +| `page` | `int` | `1` | | +| `page_size` | `int` | `10` | | +| `max_related` | `int` | `10` | | +| `output_format` | `Literal['json', 'text']` | `'json'` | | + +**Examples** + +```python +# Continue a specific discussion + build_context("my-project", "memory://specs/search") + + # Get deeper context about a component + build_context("work-docs", "memory://components/memory-service", depth=2) + + # Get text output for compact context + build_context("research", "memory://specs/search", output_format="text") + +Raises: + ToolError: If project doesn't exist or depth parameter is invalid +``` + +*Source: `src/basic_memory/mcp/tools/build_context.py`* + +--- + +### `recent_activity` + +Get recent activity for a project or across all projects. + + Timeframe supports natural language formats like: + - "2 days ago" + - "last week" + - "yesterday" + - "today" + - "3 weeks ago" + Or standard formats like "7d" + +Get recent activity for a specific project or across all projects. + +Project Resolution: +The server resolves projects in this order: +1. Single Project Mode - server constrained to one project, parameter ignored +2. Explicit project parameter - specify which project to query +3. Default project - server configured default if no project specified + +Discovery Mode: +When no specific project can be resolved, returns activity across all projects +to help discover available projects and their recent activity. + +Project Discovery (when project is unknown): +1. Call list_memory_projects() to see available projects +2. Or use this tool without project parameter to see cross-project activity +3. Ask the user which project to focus on +4. Remember their choice for the conversation + +Returns: + Human-readable summary of recent activity. When no specific project is + resolved, returns cross-project discovery information. When a specific + project is resolved, returns detailed activity for that project. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `type` | `Union[str], Field(default='', validation_alias=AliasChoices('type', 'types', 'kind'))]` | `''` | | +| `depth` | `int` | `1` | | +| `timeframe` | `TimeFrame` | `'7d'` | | +| `page` | `int` | `1` | | +| `page_size` | `int` | `10` | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Cross-project discovery mode + recent_activity() + recent_activity(timeframe="yesterday") + + # Project-specific activity + recent_activity(project="work-docs", type="entity", timeframe="yesterday") + recent_activity(project="research", type=["entity", "relation"], timeframe="today") + recent_activity(project="notes", type="entity", depth=2, timeframe="2 weeks ago") + +Raises: + ToolError: If project doesn't exist or type parameter contains invalid values + +Notes: + - Higher depth values (>3) may impact performance with large result sets + - For focused queries, consider using build_context with a specific URI + - Max timeframe is 1 year in the past +``` + +*Source: `src/basic_memory/mcp/tools/recent_activity.py`* + +--- + +### `list_directory` + +List directory contents with filtering and depth control. + +List directory contents from the knowledge base with optional filtering. + +This tool provides 'ls' functionality for browsing the knowledge base directory structure. +It can list immediate children or recursively explore subdirectories with depth control, +and supports glob pattern filtering for finding specific files. + +Returns: + Formatted listing of directory contents with file metadata + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `dir_name` | `str` | `'/'` | | +| `depth` | `int` | `1` | | +| `file_name_glob` | `Annotated[Optional[str], Field(default=None, validation_alias=AliasChoices('file_name_glob', 'glob', 'pattern', 'filter'))]` | `None` | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | + +**Examples** + +```python +# List root directory contents + list_directory() + + # List specific folder + list_directory(dir_name="/projects") + + # Find all markdown files + list_directory(file_name_glob="*.md") + + # Deep exploration of research folder + list_directory(dir_name="/research", depth=3) + + # Find meeting notes in projects folder + list_directory(dir_name="/projects", file_name_glob="*meeting*") + + # Explicit project specification + list_directory(project="work-docs", dir_name="/projects") + +Raises: + ToolError: If project doesn't exist or directory path is invalid +``` + +*Source: `src/basic_memory/mcp/tools/list_directory.py`* + +--- + + +## Search + +### `search_notes` + +Search across all content in the knowledge base with advanced syntax support. + +Search across all content in the knowledge base with comprehensive syntax support. + +This tool searches the knowledge base using full-text search, pattern matching, +or exact permalink lookup. It supports filtering by content type, entity type, +and date, with advanced boolean and phrase search capabilities. + +Project Resolution: +Server resolves projects in this order: Single Project Mode → project parameter → default project. +If project unknown, use list_memory_projects() or recent_activity() first. +Set search_all_projects=True to search every accessible project; this is opt-in because it +performs one search per project. + +## Search Syntax Examples + +### Basic Searches +- `search_notes("my-project", "keyword")` - Find any content containing "keyword" +- `search_notes("work-docs", "'exact phrase'")` - Search for exact phrase match + +### Advanced Boolean Searches +- `search_notes("my-project", "term1 term2")` - Strict implicit-AND first; retries with + relaxed OR terms only if strict search returns no results +- `search_notes("my-project", "term1 AND term2")` - Explicit AND search (both terms required) +- `search_notes("my-project", "term1 OR term2")` - Either term can be present +- `search_notes("my-project", "term1 NOT term2")` - Include term1 but exclude term2 +- `search_notes("my-project", "(project OR planning) AND notes")` - Grouped boolean logic + +### Content-Specific Searches +- `search_notes("research", "tag:example")` - Search within specific tags (if supported by content) +- `search_notes("work-project", "req", entity_types=["observation"], categories=["requirement"])` + - Return only observations whose category is exactly "requirement" +- `search_notes("team-docs", "author:username")` - Find content by author (if metadata available) + +**Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works +with any search type (text, hybrid, vector). You can also use the `tags` parameter +directly: `search_notes("project", "query", tags=["my-tag"])` + +### Search Type Examples +- `search_notes("my-project", "Meeting", search_type="title")` - Search only in titles +- `search_notes("work-docs", "docs/meeting-*", search_type="permalink")` - Pattern match permalinks + Note: Permalink patterns match the full path (e.g., "project/folder/chapter-13*", not just "chapter-13*"). +- `search_notes("research", "keyword")` - Default search (hybrid when semantic is enabled, + text when disabled) + +### Filtering Options +- `search_notes("my-project", "query", note_types=["note"])` - Search only notes +- `search_notes("work-docs", "query", note_types=["note", "person"])` - Multiple note types +- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type +- `search_notes("research", "query", entity_types=["observation"], categories=["requirement"])` + - Filter observations to an exact category +- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only +- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering +- `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags +- `search_notes("my-project", "query", status="in-progress")` - Filter by frontmatter status +- `search_notes("my-project", "query", metadata_filters={"priority": {"$in": ["high"]}})` + +### Structured Metadata Filters +Filters are exact matches on frontmatter metadata. Supported forms: +- Equality: `{"status": "in-progress"}` +- Array contains (all): `{"tags": ["security", "oauth"]}` +- Operators: + - `$in`: `{"priority": {"$in": ["high", "critical"]}}` + - `$gt`, `$gte`, `$lt`, `$lte`: `{"schema.confidence": {"$gt": 0.7}}` + - `$between`: `{"schema.confidence": {"$between": [0.3, 0.6]}}` +- Nested keys use dot notation (e.g., `"schema.confidence"`). + +### Filter-only Searches +Omit `query` (or pass None) when only using structured filters: +- `search_notes(metadata_filters={"type": "spec"}, project="my-project")` +- `search_notes(tags=["security"], project="my-project")` +- `search_notes(status="draft", project="my-project")` + +### Convenience Filters +`tags` and `status` are shorthand for metadata_filters. If the same key exists in +metadata_filters, that value wins. + +### Advanced Pattern Examples +- `search_notes("work-project", "project AND (meeting OR discussion)")` - Complex boolean logic +- `search_notes("research", ""exact phrase" AND keyword")` - Combine phrase and keyword search +- `search_notes("dev-notes", "bug NOT fixed")` - Exclude resolved issues +- `search_notes("archive", "docs/2024-*", search_type="permalink")` - Year-based permalink search + +Returns: + Formatted markdown text (output_format="text"), dict (output_format="json"), + or helpful error guidance string if search fails + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `query` | `Annotated[Optional[str], Field(default=None, validation_alias=AliasChoices('query', 'q', 'search', 'text'))]` | `None` | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `search_all_projects` | `bool` | `False` | | +| `page` | `int` | `1` | | +| `page_size` | `int` | `10` | | +| `search_type` | `str | None` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | +| `note_types` | `Annotated[List[str] | None, BeforeValidator(coerce_list), Field(default=None, validation_alias=AliasChoices('note_types', 'note_type', 'types')), "Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). Case-insensitive."]` | `None` | | +| `entity_types` | `Annotated[List[str] | None, BeforeValidator(coerce_list), Field(default=None, validation_alias=AliasChoices('entity_types', 'entity_type')), "Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or 'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like 'Chapter' here — use note_types instead."]` | `None` | | +| `categories` | `Annotated[List[str] | None, BeforeValidator(coerce_list), Field(default=None, validation_alias=AliasChoices('categories', 'category')), "Filter observation results to these exact categories (e.g. ['requirement']). Pair with entity_types=['observation'] to return only observations whose category matches exactly — not every row mentioning the word."]` | `None` | | +| `after_date` | `Annotated[Optional[str], Field(default=None, validation_alias=AliasChoices('after_date', 'since', 'after', 'from_date'))]` | `None` | | +| `metadata_filters` | `Dict[str | None, BeforeValidator(coerce_dict)]` | `None` | | +| `tags` | `Annotated[List[str] | None, BeforeValidator(strict_search_tags)]` | `None` | | +| `status` | `Optional[str]` | `None` | | +| `min_similarity` | `Annotated[Optional[float], Field(default=None, validation_alias=AliasChoices('min_similarity', 'threshold', 'similarity_threshold'))]` | `None` | | + +**Examples** + +```python +# Basic text search + results = await search_notes("project planning") + # Plain multi-term text uses strict matching first, then relaxed OR fallback if needed + + # Boolean AND search (both terms must be present) + results = await search_notes("project AND planning") + + # Boolean OR search (either term can be present) + results = await search_notes("project OR meeting") + + # Boolean NOT search (exclude terms) + results = await search_notes("project NOT meeting") + + # Boolean search with grouping + results = await search_notes("(project OR planning) AND notes") + + # Exact phrase search + results = await search_notes(""weekly standup meeting"") + + # Search with note type filter - type property in frontmatter + results = await search_notes( + "meeting notes", + note_types=["note"], + ) + + # Search with entity type filter + results = await search_notes( + "meeting notes", + entity_types=["observation"], + ) + + # Search for recent content + results = await search_notes( + "bug report", + after_date="1 week" + ) + + # Pattern matching on permalinks + results = await search_notes( + "docs/meeting-*", + search_type="permalink" + ) + + # Title-only search + results = await search_notes( + "Machine Learning", + search_type="title" + ) + + # Complex search with multiple filters + results = await search_notes( + "(bug OR issue) AND NOT resolved", + note_types=["note"], + after_date="2024-01-01" + ) + + # Explicit project specification + results = await search_notes("project planning", project="my-project") +``` + +*Source: `src/basic_memory/mcp/tools/search.py`* + +--- + +### `search` + +Search for content across the knowledge base + +ChatGPT/OpenAI MCP search adapter returning a single text content item. + +Returns: + List with one dict: `{ "type": "text", "text": "{...JSON...}" }` + where the JSON body contains `results`, `total_count`, and echo of `query`. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `query` | `str` | *(required)* | | + +*Source: `src/basic_memory/mcp/tools/chatgpt_tools.py`* + +--- + +### `fetch` + +Fetch the full contents of a search result document + +ChatGPT/OpenAI MCP fetch adapter returning a single text content item. + +Returns: + List with one dict: `{ "type": "text", "text": "{...JSON...}" }` + where the JSON body includes `id`, `title`, `text`, `url`, and metadata. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `id` | `str` | *(required)* | | + +*Source: `src/basic_memory/mcp/tools/chatgpt_tools.py`* + +--- + + +## Project & Workspace Management + +### `list_memory_projects` + +List all available projects with their status. + +List all available projects with their status. + +Shows projects from both local and cloud sources when cloud credentials +are available, merging by permalink to give a unified view. + +Each project entry includes an `external_id` (UUID). Pass that value as the +`project_id` parameter on other tools to address a specific project +unambiguously across cloud workspaces — useful when the same project name +exists in more than one workspace. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +*Source: `src/basic_memory/mcp/tools/project_management.py`* + +--- + +### `create_memory_project` + +Create a new Basic Memory project. + +Create a new Basic Memory project. + +Creates a new project with the specified name and path. The project directory +will be created if it doesn't exist. Optionally sets the new project as default. + +Returns: + Confirmation message with project details + +Example: + create_memory_project("my-research", "~/Documents/research") + create_memory_project("work-notes", "/home/user/work", set_default=True) + create_memory_project("team-notes", "/team/notes", workspace="team-paul") + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `project_name` | `str` | *(required)* | | +| `project_path` | `str` | *(required)* | | +| `set_default` | `bool` | `False` | | +| `workspace` | `str | None` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +*Source: `src/basic_memory/mcp/tools/project_management.py`* + +--- + +### `delete_project` + +Delete a Basic Memory project. + +Delete a Basic Memory project. + +Removes a project from the configuration and database. This does NOT delete +the actual files on disk - only removes the project from Basic Memory's +configuration and database records. + +Returns: + Confirmation message about project deletion + +Example: + delete_project("old-project") + delete_project("team-project", workspace="team-paul") + +Warning: + This action cannot be undone. The project will need to be re-added + to access its content through Basic Memory again. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `project_name` | `str` | *(required)* | | +| `workspace` | `str | None` | `None` | | + +*Source: `src/basic_memory/mcp/tools/project_management.py`* + +--- + +### `list_workspaces` + +List available cloud workspaces (tenant_id, type, role, and name). + +List workspaces available to the current cloud user. + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +*Source: `src/basic_memory/mcp/tools/workspaces.py`* + +--- + + +## Schema Tools + +### `schema_validate` + +Validate notes against their Picoschema definitions. + +Validate notes against their resolved schema. + +Validates a specific note (by identifier) or all notes of a given type. +Returns warnings/errors based on the schema's validation mode. + +Schemas are resolved in priority order: +1. Inline schema (dict in frontmatter) +2. Explicit reference (string in frontmatter) +3. Implicit by type (type field matches schema note's entity field) +4. No schema (no validation) + +Project Resolution: +Server resolves projects in this order: Single Project Mode -> project parameter -> default. +If project unknown, use list_memory_projects() first. + +Returns: + ValidationReport with per-note results, or error guidance string + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `note_type` | `Optional[str]` | `None` | | +| `identifier` | `Optional[str]` | `None` | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Validate all person notes + schema_validate(note_type="person") + + # Validate a specific note + schema_validate(identifier="people/paul-graham") + + # Validate in a specific project + schema_validate(note_type="person", project="my-research") +``` + +*Source: `src/basic_memory/mcp/tools/schema.py`* + +--- + +### `schema_infer` + +Analyze existing notes and suggest a Picoschema definition. + +Analyze existing notes and suggest a schema definition. + +Examines observation categories and relation types across all notes +of the given type. Returns frequency analysis and suggested Picoschema +YAML that can be saved as a schema note. + +Frequency thresholds: +- 95%+ present -> required field +- threshold+ present -> optional field +- Below threshold -> excluded (but noted) + +Project Resolution: +Server resolves projects in this order: Single Project Mode -> project parameter -> default. +If project unknown, use list_memory_projects() first. + +Returns: + InferenceReport with frequency data and suggested schema, or error string + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `note_type` | `str` | *(required)* | | +| `threshold` | `float` | `0.25` | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Infer schema for person notes + schema_infer("person") + + # Use a higher threshold (50% minimum) + schema_infer("meeting", threshold=0.5) + + # Infer in a specific project + schema_infer("person", project="my-research") +``` + +*Source: `src/basic_memory/mcp/tools/schema.py`* + +--- + +### `schema_diff` + +Detect drift between a schema definition and actual note usage. + +Detect drift between a schema definition and actual note usage. + +Compares the existing schema for a note type against how notes of +that type are actually structured. Identifies new fields that have +appeared, declared fields that are rarely used, and cardinality changes +(single-value vs array). + +Useful for evolving schemas as your knowledge base grows -- run +periodically to see if your schema still matches reality. + +Project Resolution: +Server resolves projects in this order: Single Project Mode -> project parameter -> default. +If project unknown, use list_memory_projects() first. + +Returns: + DriftReport with new fields, dropped fields, and cardinality changes, + or error guidance string + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `note_type` | `str` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | +| `output_format` | `Literal['text', 'json']` | `'text'` | | + +**Examples** + +```python +# Check drift for person schema + schema_diff("person") + + # Check drift in a specific project + schema_diff("person", project="my-research") +``` + +*Source: `src/basic_memory/mcp/tools/schema.py`* + +--- + + +## Visualization + +### `canvas` + +Create an Obsidian canvas file to visualize concepts and connections. + +Create an Obsidian canvas file with the provided nodes and edges. + +This tool creates a .canvas file compatible with Obsidian's Canvas feature, +allowing visualization of relationships between concepts or documents. + +Project Resolution: +Server resolves projects in this order: Single Project Mode → project parameter → default project. +If project unknown, use list_memory_projects() or recent_activity() first. + +For the full JSON Canvas 1.0 specification, see the 'spec://canvas' resource. + +Returns: + A summary of the created canvas file + +Important Notes: +- When referencing files, use the exact file path as shown in Obsidian + Example: "docs/Document Name.md" (not permalink format) +- For file nodes, the "file" attribute must reference an existing file +- Nodes require id, type, x, y, width, height properties +- Edges require id, fromNode, toNode properties +- Position nodes in a logical layout (x,y coordinates in pixels) +- Use color attributes ("1"-"6" or hex) for visual organization + +Basic Structure: +```json +{ + "nodes": [ + { + "id": "node1", + "type": "file", // Options: "file", "text", "link", "group" + "file": "docs/Document.md", + "x": 0, + "y": 0, + "width": 400, + "height": 300 + } + ], + "edges": [ + { + "id": "edge1", + "fromNode": "node1", + "toNode": "node2", + "label": "connects to" + } + ] +} +``` + +**Parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `nodes` | `List[Dict[str], BeforeValidator(coerce_list)]` | *(required)* | | +| `edges` | `List[Dict[str], BeforeValidator(coerce_list)]` | *(required)* | | +| `title` | `str` | *(required)* | | +| `directory` | `str` | *(required)* | | +| `project` | `Optional[str]` | `None` | | +| `project_id` | `Optional[str]` | `None` | | + +**Examples** + +```python +# Create canvas in default/current project + canvas(nodes=[...], edges=[...], title="My Canvas", directory="diagrams") + + # Create canvas with explicit project + canvas(nodes=[...], edges=[...], title="Process Flow", directory="visual/maps", project="work-project") + +Raises: + ToolError: If project doesn't exist or directory path is invalid +``` + +*Source: `src/basic_memory/mcp/tools/canvas.py`* + +--- + + +## Info & Utilities + +### `cloud_info` + +Return optional Basic Memory Cloud information and setup guidance. + +Return optional Basic Memory Cloud information and setup guidance. + +*Source: `src/basic_memory/mcp/tools/cloud_info.py`* + +--- + +### `release_notes` + +Return the latest product release notes for optional user review. + +Return the latest product release notes for optional user review. + +*Source: `src/basic_memory/mcp/tools/release_notes.py`* + +--- + diff --git a/scripts/generate_tool_docs.py b/scripts/generate_tool_docs.py new file mode 100644 index 00000000..1bdfd4aa --- /dev/null +++ b/scripts/generate_tool_docs.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +"""Generate MCP tool reference documentation from tool docstrings. + +Introspects all MCP tool definitions in src/basic_memory/mcp/tools/, +extracts names, descriptions, parameters, and docstrings, then emits +a single deterministic markdown reference to docs/mcp-tools.md. + +Usage: + uv run scripts/generate_tool_docs.py + + # Verify idempotency (diff should be empty): + uv run scripts/generate_tool_docs.py && uv run scripts/generate_tool_docs.py + git diff docs/mcp-tools.md +""" + +from __future__ import annotations + +import ast +import inspect +import re +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +ROOT = Path(__file__).resolve().parents[1] +TOOLS_DIR = ROOT / "src" / "basic_memory" / "mcp" / "tools" +OUT_FILE = ROOT / "docs" / "mcp-tools.md" + +# Only files that contain public MCP tools (skip helpers/internals). +TOOL_FILES = [ + "build_context.py", + "canvas.py", + "chatgpt_tools.py", + "cloud_info.py", + "delete_note.py", + "edit_note.py", + "list_directory.py", + "move_note.py", + "project_management.py", + "read_content.py", + "read_note.py", + "recent_activity.py", + "release_notes.py", + "schema.py", + "search.py", + "view_note.py", + "workspaces.py", + "write_note.py", +] + + +# --------------------------------------------------------------------------- +# AST-based extraction (no import side-effects) +# --------------------------------------------------------------------------- + + +def _clean_docstring(raw: str | None) -> str: + """Dedent and strip a raw docstring.""" + if not raw: + return "" + return inspect.cleandoc(raw) + + +def _get_default_repr(node: ast.expr | None) -> str: + """Return a short string representation for a default-value AST node.""" + if node is None: + return "" + try: + return ast.unparse(node) + except Exception: + return "..." + + +def _annotation_repr(node: ast.expr | None) -> str: + """Return a readable string for a type-annotation AST node.""" + if node is None: + return "" + try: + text = ast.unparse(node) + except Exception: + return "" + # Unwrap Annotated[X, ...] → X (keeps output readable) + text = re.sub(r"Annotated\[([^,\]]+),.*?\]", r"\1", text) + return text + + +_SENTINEL = object() # returned when decorator IS @mcp.tool but has no description string + + +def _mcp_tool_description(decorator: ast.expr) -> str | None | object: + """Extract the ``description=`` keyword from an ``@mcp.tool(...)`` decorator call. + + Returns: + - A string when the decorator carries ``description="..."`` + - ``_SENTINEL`` when the decorator IS an mcp.tool call but has no description + - ``None`` when the decorator is not an mcp.tool call at all + + This distinction lets callers treat mcp.tool-decorated functions without a + decorator description string as still valid tools (description comes from the + docstring in that case). + """ + if not isinstance(decorator, ast.Call): + return None + + func = decorator.func + # Accept both ``mcp.tool(...)`` and ``tool(...)`` + if not ( + (isinstance(func, ast.Attribute) and func.attr == "tool") + or (isinstance(func, ast.Name) and func.id == "tool") + ): + return None + + for kw in decorator.keywords: + if kw.arg == "description" and isinstance(kw.value, ast.Constant): + return kw.value.value + + # Also accept a positional string as the tool name (no description) + # e.g. @mcp.tool("list_memory_projects", annotations={...}) + # In this case, the description is not in the decorator; fall through to docstring. + return _SENTINEL + + +class ToolInfo: + """Holds extracted metadata for a single MCP tool.""" + + __slots__ = ( + "name", + "decorator_description", + "docstring", + "params", + "source_file", + ) + + def __init__( + self, + name: str, + decorator_description: str, + docstring: str, + params: list[dict[str, str]], + source_file: str, + ) -> None: + self.name = name + self.decorator_description = decorator_description + self.docstring = docstring + self.params = params + self.source_file = source_file + + +# Parameters that are MCP/framework plumbing — not useful to document. +_SKIP_PARAMS = frozenset({"context", "self", "cls"}) + + +def extract_tools_from_file(path: Path) -> list[ToolInfo]: + """Parse *path* with AST and return a list of ToolInfo for every @mcp.tool function.""" + source = path.read_text(encoding="utf-8") + try: + tree = ast.parse(source, filename=str(path)) + except SyntaxError as exc: + print(f"WARNING: could not parse {path}: {exc}", file=sys.stderr) + return [] + + tools: list[ToolInfo] = [] + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + # Only process functions that have an @mcp.tool (or @tool) decorator. + # decorator_desc is: + # - a non-empty string when found in the decorator + # - _SENTINEL when the decorator is mcp.tool but carries no description + # - None when no mcp.tool decorator is present + decorator_desc: str | object | None = None + for dec in node.decorator_list: + result = _mcp_tool_description(dec) + if result is not None: + decorator_desc = result + break + + if decorator_desc is None: + continue + + # When the decorator doesn't carry a description string, fall back to + # the first sentence of the docstring (populated below). + decorator_has_description = isinstance(decorator_desc, str) + + docstring = _clean_docstring(ast.get_docstring(node)) + + # --- Parameters --- + params: list[dict[str, str]] = [] + args = node.args + # Defaults are right-aligned against the arg list + n_defaults = len(args.defaults) + n_args = len(args.args) + defaults_padded = [None] * (n_args - n_defaults) + list(args.defaults) # type: ignore[list-item] + kw_defaults = args.kw_defaults # may contain None for "no default" + + for i, arg in enumerate(args.args): + if arg.arg in _SKIP_PARAMS: + continue + default = defaults_padded[i] + params.append( + { + "name": arg.arg, + "type": _annotation_repr(arg.annotation), + "default": _get_default_repr(default) if default is not None else "", + } + ) + + for i, arg in enumerate(args.kwonlyargs): + if arg.arg in _SKIP_PARAMS: + continue + default = kw_defaults[i] if i < len(kw_defaults) else None + params.append( + { + "name": arg.arg, + "type": _annotation_repr(arg.annotation), + "default": _get_default_repr(default) if default is not None else "", + } + ) + + # Build the short one-liner description: + # - prefer the explicit decorator description= + # - fall back to the first non-blank line of the docstring + if decorator_has_description: + short_desc = decorator_desc.strip() # type: ignore[union-attr] + else: + first_line = next( + (ln.strip() for ln in docstring.splitlines() if ln.strip()), "" + ) + short_desc = first_line + + tools.append( + ToolInfo( + name=node.name, + decorator_description=short_desc, + docstring=docstring, + params=params, + source_file=path.name, + ) + ) + + return tools + + +# --------------------------------------------------------------------------- +# Markdown rendering +# --------------------------------------------------------------------------- + + +def _params_table(params: list[dict[str, str]]) -> str: + """Render parameters as a markdown table.""" + if not params: + return "" + rows = ["| Parameter | Type | Default | Description |", "|-----------|------|---------|-------------|"] + for p in params: + name = f"`{p['name']}`" + typ = f"`{p['type']}`" if p["type"] else "" + default = f"`{p['default']}`" if p["default"] else "*(required)*" + rows.append(f"| {name} | {typ} | {default} | |") + return "\n".join(rows) + + +def _extract_examples_section(docstring: str) -> tuple[str, str]: + """Split docstring into (body_without_examples, examples_block). + + Looks for an 'Examples:' section (with or without leading '#') and + returns everything before it as the body, and everything from the + Examples header onward (minus the header itself) as the examples. + """ + # Match headings like "Examples:", "# Examples", "## Examples", etc. + pattern = re.compile(r"^(#{1,3}\s*)?Examples:?\s*$", re.MULTILINE | re.IGNORECASE) + match = pattern.search(docstring) + if not match: + return docstring.strip(), "" + body = docstring[: match.start()].strip() + examples_raw = docstring[match.end() :].strip() + return body, examples_raw + + +def _format_args_section(docstring: str) -> tuple[str, str]: + """Remove the 'Args:' block from a docstring and return (cleaned_body, args_text). + + The Args block is typically used to populate parameter descriptions; we + keep it as a raw block so callers can decide what to do with it. + """ + pattern = re.compile(r"^Args:\s*$", re.MULTILINE) + match = pattern.search(docstring) + if not match: + return docstring.strip(), "" + + before = docstring[: match.start()].strip() + after_start = match.end() + + # The Args block ends at the next top-level section (line that starts + # without indentation and ends with ':'), or at end-of-string. + next_section = re.search(r"\n(?=[A-Z][^\n:]*:$)", docstring[after_start:], re.MULTILINE) + if next_section: + args_text = docstring[after_start : after_start + next_section.start()].strip() + remainder = docstring[after_start + next_section.start() :].strip() + return (before + "\n\n" + remainder).strip(), args_text + else: + args_text = docstring[after_start:].strip() + return before.strip(), args_text + + +def render_tool_section(tool: ToolInfo) -> str: + """Return a markdown section for a single tool.""" + lines: list[str] = [] + lines.append(f"### `{tool.name}`") + lines.append("") + + # One-liner description from the decorator (most concise) + lines.append(tool.decorator_description) + lines.append("") + + # Full docstring body (strip Args: and Examples: sub-sections which get + # their own formatting below) + doc = tool.docstring + doc, args_text = _format_args_section(doc) + doc, examples_text = _extract_examples_section(doc) + + if doc: + lines.append(doc) + lines.append("") + + # Parameters table + if tool.params: + lines.append("**Parameters**") + lines.append("") + lines.append(_params_table(tool.params)) + lines.append("") + + # Examples block + if examples_text: + lines.append("**Examples**") + lines.append("") + # Wrap in a python code fence if not already fenced + if "```" not in examples_text: + lines.append("```python") + lines.append(examples_text) + lines.append("```") + else: + lines.append(examples_text) + lines.append("") + + lines.append(f"*Source: `src/basic_memory/mcp/tools/{tool.source_file}`*") + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Grouping +# --------------------------------------------------------------------------- + +# Stable, hand-curated grouping of tools into logical categories. +# Tools not listed here fall into "Other Tools". +TOOL_GROUPS: dict[str, list[str]] = { + "Note Management": [ + "write_note", + "read_note", + "view_note", + "edit_note", + "move_note", + "delete_note", + ], + "Reading & Navigation": [ + "read_content", + "build_context", + "recent_activity", + "list_directory", + ], + "Search": [ + "search_notes", + "search", + "fetch", + ], + "Project & Workspace Management": [ + "list_memory_projects", + "create_memory_project", + "delete_project", + "list_workspaces", + ], + "Schema Tools": [ + "schema_validate", + "schema_infer", + "schema_diff", + ], + "Visualization": [ + "canvas", + ], + "Info & Utilities": [ + "cloud_info", + "release_notes", + ], +} + + +def group_tools(tools: list[ToolInfo]) -> dict[str, list[ToolInfo]]: + """Return an ordered dict mapping group name → list of ToolInfo.""" + by_name: dict[str, ToolInfo] = {t.name: t for t in tools} + result: dict[str, list[ToolInfo]] = {} + + placed: set[str] = set() + for group, names in TOOL_GROUPS.items(): + members = [by_name[n] for n in names if n in by_name] + if members: + result[group] = members + placed.update(n for n in names if n in by_name) + + # Anything not in TOOL_GROUPS goes to "Other Tools", sorted alphabetically + other = sorted([t for t in tools if t.name not in placed], key=lambda t: t.name) + if other: + result["Other Tools"] = other + + return result + + +# --------------------------------------------------------------------------- +# Top-level document builder +# --------------------------------------------------------------------------- + +HEADER = """\ + + +# Basic Memory MCP Tool Reference + +Complete reference for all MCP tools exposed by the Basic Memory server. +Tools are grouped by function. Parameters marked *(required)* have no default value. + +> **Regenerating this file**: run `uv run scripts/generate_tool_docs.py` from the +> repository root. The output is deterministic; running it twice should produce +> an identical file (zero diff). + +## Table of Contents + +""" + + +def build_toc(groups: dict[str, list[ToolInfo]]) -> str: + lines: list[str] = [] + for group, members in groups.items(): + # GitHub-style anchor: lowercase, spaces → hyphens, drop punctuation + anchor = re.sub(r"[^\w\s-]", "", group.lower()) + anchor = re.sub(r"\s+", "-", anchor.strip()) + lines.append(f"- [{group}](#{anchor})") + for tool in members: + tool_anchor = tool.name.replace("_", "-") + lines.append(f" - [`{tool.name}`](#{tool_anchor})") + return "\n".join(lines) + + +def build_document(groups: dict[str, list[ToolInfo]]) -> str: + parts: list[str] = [HEADER] + parts.append(build_toc(groups)) + parts.append("\n\n---\n") + + for group, members in groups.items(): + parts.append(f"\n## {group}\n") + for tool in members: + parts.append(render_tool_section(tool)) + parts.append("---\n") + + # Trailing newline + return "\n".join(parts) + "\n" + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + all_tools: list[ToolInfo] = [] + for filename in TOOL_FILES: + path = TOOLS_DIR / filename + if not path.exists(): + print(f"WARNING: {path} does not exist — skipping", file=sys.stderr) + continue + file_tools = extract_tools_from_file(path) + all_tools.extend(file_tools) + + if not all_tools: + print("ERROR: no tools found — check TOOLS_DIR path", file=sys.stderr) + return 1 + + # Stable sort: group order is defined by TOOL_GROUPS; within groups, order + # is defined by TOOL_GROUPS list. Global sort here is just for determinism + # of "Other Tools". + groups = group_tools(all_tools) + document = build_document(groups) + + OUT_FILE.parent.mkdir(parents=True, exist_ok=True) + OUT_FILE.write_text(document, encoding="utf-8") + + total = sum(len(m) for m in groups.values()) + print(f"Generated {OUT_FILE.relative_to(ROOT)} ({total} tools, {len(OUT_FILE.read_text().splitlines())} lines)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())