diff --git a/.claude/commands/test-live.md b/.claude/commands/test-live.md index e0172f699..d834d0d45 100644 --- a/.claude/commands/test-live.md +++ b/.claude/commands/test-live.md @@ -45,8 +45,7 @@ When the user runs `/project:test-live`, execute comprehensive test plan: 16. **edit_note** (advanced modes) - Complex find/replace operations ### **Tier 4: Specialized (Rarely Test)** -17. **canvas** - Obsidian visualization (specialized use case) -18. **MCP Prompts** - Enhanced UX tools (ai_assistant_guide, continue_conversation) +17. **MCP Prompts** - Enhanced UX tools (ai_assistant_guide, continue_conversation) ## Stateless Architecture Testing @@ -308,13 +307,7 @@ Test essential MCP tools that form the foundation of Basic Memory: ### Phase 6: Specialized Tools Testing (Tier 4) -**16. canvas Tests (Specialized):** -- ✅ JSON Canvas generation -- ✅ Node and edge creation -- ✅ Obsidian compatibility -- ⚠️ Complex graph handling - -**17. MCP Prompts Tests (Specialized):** +**16. MCP Prompts Tests (Specialized):** - ✅ ai_assistant_guide output - ✅ continue_conversation functionality - ✅ Formatted search results diff --git a/AGENTS.md b/AGENTS.md index baf049218..5335c8077 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,9 +423,6 @@ See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `c - `create_memory_project(project_name, project_path, set_default)` - Create new Basic Memory projects - `delete_project(project_name)` - Delete a project from configuration - **Visualization:** - - `canvas(nodes, edges, title, directory)` - Generate Obsidian canvas files for knowledge graph visualization - **ChatGPT-Compatible Tools:** - `search(query)` - Search across knowledge base (OpenAI actions compatible) - `fetch(id)` - Fetch full content of a search result document diff --git a/README.md b/README.md index 8374c13d2..aa2b8bbc8 100644 --- a/README.md +++ b/README.md @@ -491,8 +491,7 @@ agents can pick the right one without trial-and-error: `delete_note`, `read_content`, `view_note` - **Search & discovery:** `search`, `search_notes`, `recent_activity`, `list_directory` -- **Knowledge graph:** `build_context` (navigates `memory://` URLs), - `canvas` (Obsidian canvas generation) +- **Knowledge graph:** `build_context` (navigates `memory://` URLs) - **Projects:** `list_memory_projects`, `create_memory_project`, `get_current_project`, `sync_status` - **Schema:** `schema_infer`, `schema_validate`, `schema_diff` diff --git a/docs/ai-assistant-guide-extended.md b/docs/ai-assistant-guide-extended.md index 3053111cf..bd507d7c9 100644 --- a/docs/ai-assistant-guide-extended.md +++ b/docs/ai-assistant-guide-extended.md @@ -2472,73 +2472,25 @@ except: ### Knowledge Graph Visualization -**Create visual representation using canvas**: +**Create visual representations with Obsidian canvas files**: -```python -# Gather entities to visualize -auth_context = await build_context( - url="memory://Authentication System", - depth=2, - project="main" -) - -# Create nodes -nodes = [ - { - "id": "auth-system", - "type": "file", - "file": "specs/authentication-system.md", - "x": 0, - "y": 0, - "width": 400, - "height": 300 - }, - { - "id": "user-db", - "type": "file", - "file": "services/user-database.md", - "x": 500, - "y": 0, - "width": 400, - "height": 300 - }, - { - "id": "login-api", - "type": "file", - "file": "api/login-api.md", - "x": 250, - "y": 400, - "width": 400, - "height": 300 - } -] - -# Create edges showing relations -edges = [ - { - "id": "edge-1", - "fromNode": "auth-system", - "toNode": "user-db", - "label": "requires" - }, - { - "id": "edge-2", - "fromNode": "auth-system", - "toNode": "login-api", - "label": "implemented_by" - } -] - -# Generate canvas -canvas = await canvas( - nodes=nodes, - edges=edges, - title="Authentication System Overview", - folder="diagrams", - project="main" -) +Gather entities with `build_context`, then write a +[JSON Canvas](https://jsoncanvas.org/) file (`.canvas`) into the project +directory. Basic Memory indexes `.canvas` files as JSON, and Obsidian opens +them for interactive exploration: -# Opens in Obsidian for interactive exploration +```json +{ + "nodes": [ + {"id": "auth-system", "type": "file", "file": "specs/authentication-system.md", "x": 0, "y": 0, "width": 400, "height": 300}, + {"id": "user-db", "type": "file", "file": "services/user-database.md", "x": 500, "y": 0, "width": 400, "height": 300}, + {"id": "login-api", "type": "file", "file": "api/login-api.md", "x": 250, "y": 400, "width": 400, "height": 300} + ], + "edges": [ + {"id": "edge-1", "fromNode": "auth-system", "toNode": "user-db", "label": "requires"}, + {"id": "edge-2", "fromNode": "auth-system", "toNode": "login-api", "label": "implemented_by"} + ] +} ``` ### Progressive Knowledge Building @@ -3090,28 +3042,6 @@ status = await sync_status(project="main") workspaces = await list_workspaces() ``` -### Visualization - -**canvas(nodes, edges, title, folder, project)** -- Create Obsidian canvas -- Parameters: - - `nodes` (required): List of node objects - - `edges` (required): List of edge objects - - `title` (required): Canvas title - - `folder` (required): Destination folder - - `project` (required unless default_project_mode): Target project -- Returns: Created canvas file -- Example: -```python -await canvas( - nodes=[{"id": "1", "type": "file", "file": "note.md", "x": 0, "y": 0}], - edges=[], - title="Graph View", - folder="diagrams", - project="main" -) -``` - --- ## Best Practices diff --git a/skills/memory-literary-analysis/SKILL.md b/skills/memory-literary-analysis/SKILL.md index 26d2036e5..2627cfd3a 100644 --- a/skills/memory-literary-analysis/SKILL.md +++ b/skills/memory-literary-analysis/SKILL.md @@ -23,7 +23,7 @@ Phase 1: Seed → stub notes for known major entities Phase 2: Process → chapter-by-chapter notes in batches Phase 3: Cross-ref → enrich arcs, add parallels, write analysis Phase 4: Validate → schema checks, drift detection, consistency -Phase 5: Visualize → canvas files for character webs, timelines +Phase 5: Visualize → Obsidian canvas files for character webs, timelines ``` ## Phase 0: Setup @@ -451,19 +451,22 @@ Spot-check bidirectional relations: if Chapter X `features [[Character]]`, does ## Phase 5: Visualization -Generate canvas files for visual exploration: - -```python -# Character relationship web -canvas(query="type:Character AND role:protagonist OR role:antagonist OR role:supporting") - -# Theme connections -canvas(query="type:Theme") - -# Chapter timeline with key events -canvas(query="type:Chapter", layout="timeline") +Write [JSON Canvas](https://jsoncanvas.org/) files (`.canvas`) into the project directory for visual exploration in Obsidian. Query the graph first (`search_notes`, `build_context`), then lay out the results as canvas nodes and edges: + +```json +{ + "nodes": [ + {"id": "ahab", "type": "file", "file": "characters/captain-ahab.md", "x": 0, "y": 0, "width": 400, "height": 300}, + {"id": "ishmael", "type": "file", "file": "characters/ishmael.md", "x": 500, "y": 0, "width": 400, "height": 300} + ], + "edges": [ + {"id": "e1", "fromNode": "ishmael", "toNode": "ahab", "label": "narrates"} + ] +} ``` +Useful canvases: character relationship web (protagonist/antagonist/supporting), theme connections, chapter timeline with key events. + ## Adapting to Other Genres This pipeline works for any literary text. Adjust schemas for genre: diff --git a/src/basic_memory/api/v2/routers/resource_router.py b/src/basic_memory/api/v2/routers/resource_router.py index 9c91ad96f..fbdebe2c8 100644 --- a/src/basic_memory/api/v2/routers/resource_router.py +++ b/src/basic_memory/api/v2/routers/resource_router.py @@ -1,15 +1,14 @@ -"""V2 Resource Router - ID-based resource content operations. +"""V2 Resource Router - ID-based resource content reads. -This router uses entity external_ids (UUIDs) for all operations, with file paths -in request bodies when needed. This is consistent with v2's external_id-first design. +This router uses entity external_ids (UUIDs) for all operations, consistent with +v2's external_id-first design. -Key differences from v1: -- Uses UUID external_ids in URL paths instead of integer IDs or file paths -- File paths are in request bodies for create/update operations -- More RESTful: POST for create, PUT for update, GET for read +The resource surface is read-only by design: markdown notes are written through +the knowledge router's DB-first accepted-write pipeline, and every other file +kind (binaries, uploads, imports, external edits) arrives file-first through the +storage-event indexing pipeline. No API endpoint writes resource files inline. """ -import uuid from pathlib import Path as PathLib from fastapi import APIRouter, HTTPException, Response, Path @@ -22,15 +21,8 @@ FileServiceV2ExternalDep, EntityRepositoryV2ExternalDep, NoteContentQueryServiceDep, - SearchServiceV2ExternalDep, SessionMakerDep, ) -from basic_memory.models.knowledge import Entity as EntityModel -from basic_memory.schemas.v2.resource import ( - CreateResourceRequest, - UpdateResourceRequest, - ResourceResponse, -) from basic_memory.utils import validate_project_path router = APIRouter(prefix="/resource", tags=["resources-v2"]) @@ -135,249 +127,3 @@ async def get_resource_content( content_type = file_service.content_type(entity_file_path) return Response(content=content, media_type=content_type) - - -@router.post("", response_model=ResourceResponse) -async def create_resource( - data: CreateResourceRequest, - config: ProjectConfigV2ExternalDep, - file_service: FileServiceV2ExternalDep, - entity_repository: EntityRepositoryV2ExternalDep, - search_service: SearchServiceV2ExternalDep, - session_maker: SessionMakerDep, - project_id: str = Path(..., description="Project external UUID"), -) -> ResourceResponse: - """Create a new resource file. - - Args: - project_id: Project external UUID from URL path - data: Create resource request with file_path and content - config: Project configuration - file_service: File service for writing files - entity_repository: Entity repository for creating entities - search_service: Search service for indexing - - Returns: - ResourceResponse with file information including entity_id and external_id - - Raises: - HTTPException: 400 for invalid file paths, 409 if file already exists - """ - with logfire.span( - "api.request.resource.create", - entrypoint="api", - domain="resource", - action="create", - ): - try: - # Validate path to prevent path traversal attacks - project_path = PathLib(config.home) - if not validate_project_path(data.file_path, project_path): - logger.warning( - f"Invalid file path attempted: {data.file_path} in project {config.name}" - ) - raise HTTPException( - status_code=400, - detail=f"Invalid file path: {data.file_path}. " - "Path must be relative and stay within project boundaries.", - ) - - async with db.scoped_session(session_maker) as session: - existing_entity = await entity_repository.get_by_file_path(session, data.file_path) - if existing_entity: - raise HTTPException( - status_code=409, - detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.external_id}. " - f"Use PUT /resource/{existing_entity.external_id} to update it.", - ) - - with logfire.span( - "api.resource.create.write_file", - domain="resource", - action="create", - phase="write_file", - ): - await file_service.ensure_directory(PathLib(data.file_path).parent) - checksum = await file_service.write_file(data.file_path, data.content) - - with logfire.span( - "api.resource.create.read_metadata", - domain="resource", - action="create", - phase="read_metadata", - ): - file_metadata = await file_service.get_file_metadata(data.file_path) - - file_name = PathLib(data.file_path).name - content_type = file_service.content_type(data.file_path) - note_type = "canvas" if data.file_path.endswith(".canvas") else "file" - - entity = EntityModel( - external_id=str(uuid.uuid4()), - title=file_name, - note_type=note_type, - content_type=content_type, - file_path=data.file_path, - checksum=checksum, - created_at=file_metadata.created_at, - updated_at=file_metadata.modified_at, - ) - with logfire.span( - "api.resource.create.upsert_entity", - domain="resource", - action="create", - phase="upsert_entity", - ): - async with db.scoped_session(session_maker) as session: - entity = await entity_repository.add(session, entity) - - with logfire.span( - "api.resource.create.search_index", - domain="resource", - action="create", - phase="search_index", - ): - await search_service.index_entity(entity) - - return ResourceResponse( - entity_id=entity.id, - external_id=entity.external_id, - file_path=data.file_path, - checksum=checksum, - size=file_metadata.size, - created_at=file_metadata.created_at.timestamp(), - modified_at=file_metadata.modified_at.timestamp(), - ) - except HTTPException: - raise - except Exception as e: # pragma: no cover - logger.error(f"Error creating resource {data.file_path}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}") - - -@router.put("/{entity_id}", response_model=ResourceResponse) -async def update_resource( - data: UpdateResourceRequest, - config: ProjectConfigV2ExternalDep, - file_service: FileServiceV2ExternalDep, - entity_repository: EntityRepositoryV2ExternalDep, - search_service: SearchServiceV2ExternalDep, - session_maker: SessionMakerDep, - project_id: str = Path(..., description="Project external UUID"), - entity_id: str = Path(..., description="Entity external UUID"), -) -> ResourceResponse: - """Update an existing resource by entity external_id. - - Can update content and optionally move the file to a new path. - - Args: - project_id: Project external UUID from URL path - entity_id: Entity external UUID of the resource to update - data: Update resource request with content and optional new file_path - config: Project configuration - file_service: File service for writing files - entity_repository: Entity repository for updating entities - search_service: Search service for indexing - - Returns: - ResourceResponse with updated file information - - Raises: - HTTPException: 404 if entity not found, 400 for invalid paths - """ - with logfire.span( - "api.request.resource.update", - entrypoint="api", - domain="resource", - action="update", - ): - try: - async with db.scoped_session(session_maker) as session: - entity = await entity_repository.get_by_external_id(session, entity_id) - if not entity: - raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") - - target_file_path = data.file_path if data.file_path else entity.file_path - - project_path = PathLib(config.home) - if not validate_project_path(target_file_path, project_path): - logger.warning( - f"Invalid file path attempted: {target_file_path} in project {config.name}" - ) - raise HTTPException( - status_code=400, - detail=f"Invalid file path: {target_file_path}. " - "Path must be relative and stay within project boundaries.", - ) - - with logfire.span( - "api.resource.update.write_file", - domain="resource", - action="update", - phase="write_file", - ): - if data.file_path and data.file_path != entity.file_path: - await file_service.ensure_directory(PathLib(target_file_path).parent) - if await file_service.exists(entity.file_path): - await file_service.delete_file(entity.file_path) - else: - await file_service.ensure_directory(PathLib(target_file_path).parent) - - checksum = await file_service.write_file(target_file_path, data.content) - - with logfire.span( - "api.resource.update.read_metadata", - domain="resource", - action="update", - phase="read_metadata", - ): - file_metadata = await file_service.get_file_metadata(target_file_path) - - file_name = PathLib(target_file_path).name - content_type = file_service.content_type(target_file_path) - note_type = "canvas" if target_file_path.endswith(".canvas") else "file" - - with logfire.span( - "api.resource.update.update_entity", - domain="resource", - action="update", - phase="update_entity", - ): - async with db.scoped_session(session_maker) as session: - updated_entity = await entity_repository.update( - session, - entity.id, - { - "title": file_name, - "note_type": note_type, - "content_type": content_type, - "file_path": target_file_path, - "checksum": checksum, - "updated_at": file_metadata.modified_at, - }, - ) - if updated_entity is None: - raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") - - with logfire.span( - "api.resource.update.search_index", - domain="resource", - action="update", - phase="search_index", - ): - await search_service.index_entity(updated_entity) - - return ResourceResponse( - entity_id=entity.id, - external_id=entity.external_id, - file_path=target_file_path, - checksum=checksum, - size=file_metadata.size, - created_at=file_metadata.created_at.timestamp(), - modified_at=file_metadata.modified_at.timestamp(), - ) - except HTTPException: - raise - except Exception as e: # pragma: no cover - logger.error(f"Error updating resource {entity_id}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to update resource: {str(e)}") diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 12211c3b3..b3ac083b4 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -20,7 +20,6 @@ from basic_memory.mcp.tools.cloud_info import cloud_info from basic_memory.mcp.tools.release_notes import release_notes from basic_memory.mcp.tools.search import search_notes -from basic_memory.mcp.tools.canvas import canvas from basic_memory.mcp.tools.list_directory import list_directory from basic_memory.mcp.tools.edit_note import edit_note from basic_memory.mcp.tools.move_note import move_note @@ -40,7 +39,6 @@ __all__ = [ "basic_memory_diagnostics", "build_context", - "canvas", "cloud_info", "create_memory_project", "delete_note", diff --git a/src/basic_memory/mcp/tools/canvas.py b/src/basic_memory/mcp/tools/canvas.py deleted file mode 100644 index acc7f186e..000000000 --- a/src/basic_memory/mcp/tools/canvas.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Canvas creation tool for Basic Memory MCP server. - -This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec. -""" - -import json -from typing import Annotated, Dict, List, Any, Optional - -from loguru import logger -from fastmcp import Context -from pydantic import AliasChoices, BeforeValidator, Field - -from basic_memory.mcp.project_context import get_project_client -from basic_memory.utils import coerce_list -from basic_memory.mcp.server import mcp -from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id - - -@mcp.tool( - title="Create Canvas", - description="Create an Obsidian canvas file to visualize concepts and connections.", - tags={"canvas", "notes"}, - annotations={ - "title": "Create Canvas", - "readOnlyHint": False, - # Falls back to PUT when the canvas already exists, replacing its content. - "destructiveHint": True, - "idempotentHint": True, - "openWorldHint": False, - }, -) -async def canvas( - nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)], - edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)], - title: str, - directory: Annotated[ - str, - Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")), - ], - project: Optional[str] = None, - project_id: Optional[str] = None, - context: Context | None = None, -) -> str: - """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. - - Args: - project: Project name to create canvas in. Optional - server will resolve using hierarchy. - If unknown, use list_memory_projects() to discover available projects. - project_id: Project external_id (UUID). Prefer this over `project` when known — - it routes to the exact project regardless of name collisions across cloud - workspaces. Takes precedence over `project`. Get from list_memory_projects(). - nodes: List of node objects following JSON Canvas 1.0 spec - edges: List of edge objects following JSON Canvas 1.0 spec - title: The title of the canvas (will be saved as title.canvas) - directory: Directory path relative to project root where the canvas should be saved. - Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps" - context: Optional FastMCP context for performance caching. - - 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" - } - ] - } - ``` - - Examples: - # 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 - """ - async with get_project_client(project, context=context, project_id=project_id) as ( - client, - active_project, - ): - # Ensure path has .canvas extension - file_title = title if title.endswith(".canvas") else f"{title}.canvas" - file_path = f"{directory}/{file_title}" - - # Create canvas data structure - canvas_data = {"nodes": nodes, "edges": edges} - - # Convert to JSON - canvas_json = json.dumps(canvas_data, indent=2) - - # Try to create the canvas file first (optimistic create) - logger.info(f"Creating canvas file: {file_path} in project {project}") - try: - response = await call_post( - client, - f"/v2/projects/{active_project.external_id}/resource", - json={"file_path": file_path, "content": canvas_json}, - ) - action = "Created" - except Exception as e: - # If creation failed due to conflict (already exists), try to update - if ( - "409" in str(e) - or "conflict" in str(e).lower() - or "already exists" in str(e).lower() - ): - logger.info(f"Canvas file exists, updating instead: {file_path}") - try: - entity_id = await resolve_entity_id( - client, active_project.external_id, file_path - ) - # For update, send content in JSON body - response = await call_put( - client, - f"/v2/projects/{active_project.external_id}/resource/{entity_id}", - json={"content": canvas_json}, - ) - action = "Updated" - except Exception as update_error: # pragma: no cover - # Re-raise the original error if update also fails - raise e from update_error # pragma: no cover - else: - # Re-raise if it's not a conflict error - raise # pragma: no cover - - # Parse response - result = response.json() - logger.debug(result) - - # Build summary - summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."] - - return "\n".join(summary) diff --git a/src/basic_memory/schemas/v2/__init__.py b/src/basic_memory/schemas/v2/__init__.py index bd737f44b..da11d0c7e 100644 --- a/src/basic_memory/schemas/v2/__init__.py +++ b/src/basic_memory/schemas/v2/__init__.py @@ -21,11 +21,6 @@ ProjectIndexResponse, ProjectIndexStartedResponse, ) -from basic_memory.schemas.v2.resource import ( - CreateResourceRequest, - UpdateResourceRequest, - ResourceResponse, -) __all__ = [ "EntityResolveRequest", @@ -43,7 +38,4 @@ "OrphanEntitiesResponse", "ProjectIndexResponse", "ProjectIndexStartedResponse", - "CreateResourceRequest", - "UpdateResourceRequest", - "ResourceResponse", ] diff --git a/src/basic_memory/schemas/v2/resource.py b/src/basic_memory/schemas/v2/resource.py deleted file mode 100644 index 9bfe075fc..000000000 --- a/src/basic_memory/schemas/v2/resource.py +++ /dev/null @@ -1,47 +0,0 @@ -"""V2 resource schemas for file content operations.""" - -from pydantic import BaseModel, Field - - -class CreateResourceRequest(BaseModel): - """Request to create a new resource file. - - File path is required for new resources since we need to know where - to create the file. - """ - - file_path: str = Field( - ..., - description="Path to create the file, relative to project root", - min_length=1, - max_length=500, - ) - content: str = Field(..., description="File content to write") - - -class UpdateResourceRequest(BaseModel): - """Request to update an existing resource by entity ID. - - Only content is required - the file path is already known from the entity. - Optionally can update the file_path to move the file. - """ - - content: str = Field(..., description="File content to write") - file_path: str | None = Field( - None, - description="Optional new file path to move the resource", - min_length=1, - max_length=500, - ) - - -class ResourceResponse(BaseModel): - """Response from resource operations.""" - - entity_id: int = Field(..., description="Internal entity ID of the resource") - external_id: str = Field(..., description="External UUID of the resource for API references") - file_path: str = Field(..., description="File path of the resource") - checksum: str = Field(..., description="File content checksum") - size: int = Field(..., description="File size in bytes") - created_at: float = Field(..., description="Creation timestamp") - modified_at: float = Field(..., description="Modification timestamp") diff --git a/test-int/mcp/test_param_aliases_integration.py b/test-int/mcp/test_param_aliases_integration.py index 1c1c06746..4c4760165 100644 --- a/test-int/mcp/test_param_aliases_integration.py +++ b/test-int/mcp/test_param_aliases_integration.py @@ -577,7 +577,6 @@ async def test_aliases_not_advertised_in_schema(mcp_server, app): ["url", "timeframe", "page", "page_size", "max_related"], ["uri", "memory_url", "since", "offset", "limit", "max_results", "limit_related"], ), - "canvas": (["directory"], ["folder", "dir", "path"]), } for tool_name, (must_have, must_not_have) in checks.items(): diff --git a/test-int/mcp/test_string_params_integration.py b/test-int/mcp/test_string_params_integration.py index c260cc053..b4b3d2db6 100644 --- a/test-int/mcp/test_string_params_integration.py +++ b/test-int/mcp/test_string_params_integration.py @@ -134,36 +134,3 @@ async def test_write_note_metadata_as_string(mcp_server, app, test_project): ) text = result.content[0].text assert "Created note" in text or "Updated note" in text - - -@pytest.mark.asyncio -async def test_canvas_nodes_edges_as_string(mcp_server, app, test_project): - """canvas should accept nodes and edges as JSON strings via MCP protocol.""" - import json - - nodes = [ - { - "id": "n1", - "type": "text", - "text": "Hello", - "x": 0, - "y": 0, - "width": 200, - "height": 100, - } - ] - edges = [{"id": "e1", "fromNode": "n1", "toNode": "n1", "label": "self"}] - - async with Client(mcp_server) as client: - result = await client.call_tool( - "canvas", - { - "project": test_project.name, - "title": "Coerce Canvas Test", - "directory": "test", - "nodes": json.dumps(nodes), - "edges": json.dumps(edges), - }, - ) - text = result.content[0].text - assert "Created" in text or "Updated" in text diff --git a/tests/api/v2/test_resource_router.py b/tests/api/v2/test_resource_router.py index a75a11225..61465df76 100644 --- a/tests/api/v2/test_resource_router.py +++ b/tests/api/v2/test_resource_router.py @@ -1,61 +1,23 @@ -"""Tests for V2 resource API routes (ID-based endpoints).""" +"""Tests for V2 resource API routes (ID-based endpoints). + +The v2 resource surface is read-only: markdown notes are written through the +knowledge router's DB-first pipeline, and every other file kind arrives +file-first through the storage-event indexing pipeline. These tests seed +entities directly (file on disk + entity row) instead of going through an API +write path. +""" + +from datetime import datetime, timezone +from pathlib import Path import pytest from httpx import AsyncClient from basic_memory.models import Project from basic_memory import db +from basic_memory.models.knowledge import Entity +from basic_memory.repository import EntityRepository from basic_memory.repository.note_content_repository import NoteContentRepository -from basic_memory.schemas.v2.resource import ResourceResponse - - -@pytest.mark.asyncio -async def test_create_resource( - client: AsyncClient, - test_project: Project, - v2_project_url: str, -): - """Test creating a new resource via v2 POST endpoint.""" - create_data = { - "file_path": "test-resources/test-file.md", - "content": "# Test Resource\n\nThis is test content.", - } - - response = await client.post( - f"{v2_project_url}/resource", - json=create_data, - ) - - assert response.status_code == 200 - result = ResourceResponse.model_validate(response.json()) - - # V2 must return entity_id - assert result.entity_id is not None - assert isinstance(result.entity_id, int) - assert result.file_path == "test-resources/test-file.md" - assert result.checksum is not None - - -@pytest.mark.asyncio -async def test_create_resource_duplicate_fails( - client: AsyncClient, - test_project: Project, - v2_project_url: str, -): - """Test that creating a resource at an existing path returns 409.""" - create_data = { - "file_path": "duplicate-test.md", - "content": "First version", - } - - # Create first time - should succeed - response = await client.post(f"{v2_project_url}/resource", json=create_data) - assert response.status_code == 200 - - # Try to create again - should fail with 409 - response = await client.post(f"{v2_project_url}/resource", json=create_data) - assert response.status_code == 409 - assert "already exists" in response.json()["detail"] @pytest.mark.asyncio @@ -63,25 +25,34 @@ async def test_get_resource_by_id( client: AsyncClient, test_project: Project, v2_project_url: str, + entity_repository: EntityRepository, + session_maker, ): - """Test getting resource content by external_id.""" - # First create a resource - test_content = "# Test Resource\n\nThis is test content." - create_data = { - "file_path": "test-get.md", - "content": test_content, - } - - create_response = await client.post(f"{v2_project_url}/resource", json=create_data) - assert create_response.status_code == 200 - created = ResourceResponse.model_validate(create_response.json()) + """Test getting file-backed resource content by external_id.""" + # Seed a non-markdown file so the read takes the file-read branch rather + # than the accepted note-content (read-repair) path. + test_content = "Plain text resource content." + file_path = "test-resources/test-get.txt" + disk_path = Path(test_project.path) / file_path + disk_path.parent.mkdir(parents=True, exist_ok=True) + disk_path.write_text(test_content) + + entity = Entity( + title="test-get.txt", + note_type="file", + content_type="text/plain", + file_path=file_path, + checksum="seeded", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + async with db.scoped_session(session_maker) as session: + entity = await entity_repository.add(session, entity) - # Now get it by external_id - response = await client.get(f"{v2_project_url}/resource/{created.external_id}") + response = await client.get(f"{v2_project_url}/resource/{entity.external_id}") assert response.status_code == 200 - # Normalize line endings for cross-platform compatibility - assert test_content.replace("\n", "") in response.text.replace("\r\n", "").replace("\n", "") + assert test_content in response.text @pytest.mark.asyncio @@ -138,178 +109,53 @@ async def test_get_resource_not_found( @pytest.mark.asyncio -async def test_update_resource( - client: AsyncClient, - test_project: Project, - v2_project_url: str, -): - """Test updating resource content by external_id.""" - # Create a resource - create_data = { - "file_path": "test-update.md", - "content": "Original content", - } - create_response = await client.post(f"{v2_project_url}/resource", json=create_data) - assert create_response.status_code == 200 - created = ResourceResponse.model_validate(create_response.json()) - - # Update it - update_data = { - "content": "Updated content", - } - response = await client.put( - f"{v2_project_url}/resource/{created.external_id}", - json=update_data, - ) - - assert response.status_code == 200 - result = ResourceResponse.model_validate(response.json()) - assert result.external_id == created.external_id - assert result.file_path == "test-update.md" - - # Verify content was updated - get_response = await client.get(f"{v2_project_url}/resource/{created.external_id}") - assert "Updated content" in get_response.text - - -@pytest.mark.asyncio -async def test_update_resource_and_move( - client: AsyncClient, - test_project: Project, - v2_project_url: str, -): - """Test updating resource content and moving it to a new path.""" - # Create a resource - create_data = { - "file_path": "original-location.md", - "content": "Original content", - } - create_response = await client.post(f"{v2_project_url}/resource", json=create_data) - assert create_response.status_code == 200 - created = ResourceResponse.model_validate(create_response.json()) - - # Update content and move file - update_data = { - "content": "Updated content in new location", - "file_path": "moved/new-location.md", - } - response = await client.put( - f"{v2_project_url}/resource/{created.external_id}", - json=update_data, - ) - - assert response.status_code == 200 - result = ResourceResponse.model_validate(response.json()) - assert result.external_id == created.external_id - assert result.file_path == "moved/new-location.md" - - # Verify content at new location - get_response = await client.get(f"{v2_project_url}/resource/{created.external_id}") - assert "Updated content in new location" in get_response.text - - -@pytest.mark.asyncio -async def test_update_resource_not_found( +async def test_resource_invalid_project_id( client: AsyncClient, - test_project: Project, - v2_project_url: str, ): - """Test updating a non-existent resource returns 404.""" - fake_uuid = "00000000-0000-0000-0000-000000000000" - update_data = { - "content": "New content", - } - response = await client.put( - f"{v2_project_url}/resource/{fake_uuid}", - json=update_data, - ) + """Test resource reads with invalid project external_id return 404.""" + fake_project_uuid = "00000000-0000-0000-0000-000000000000" + fake_entity_uuid = "00000000-0000-0000-0000-000000000001" + response = await client.get(f"/v2/projects/{fake_project_uuid}/resource/{fake_entity_uuid}") assert response.status_code == 404 @pytest.mark.asyncio -async def test_create_resource_invalid_path( - client: AsyncClient, - test_project: Project, - v2_project_url: str, +async def test_v2_resource_endpoints_use_project_id_not_name( + client: AsyncClient, test_project: Project ): - """Test creating a resource with path traversal attempt fails.""" - create_data = { - "file_path": "../../../etc/passwd", - "content": "malicious content", - } - - response = await client.post(f"{v2_project_url}/resource", json=create_data) + """Verify v2 resource endpoints require project external_id UUID, not name.""" + # Try using project name instead of external_id - should fail + fake_entity_uuid = "00000000-0000-0000-0000-000000000000" + response = await client.get(f"/v2/projects/{test_project.name}/resource/{fake_entity_uuid}") - assert response.status_code == 400 - assert "Invalid file path" in response.json()["detail"] + # Should get 404 because name is not a valid project external_id + assert response.status_code == 404 @pytest.mark.asyncio -async def test_update_resource_invalid_path( +async def test_resource_write_methods_removed( client: AsyncClient, test_project: Project, v2_project_url: str, ): - """Test updating a resource with path traversal attempt fails.""" - # Create a valid resource first - create_data = { - "file_path": "valid.md", - "content": "Valid content", - } - create_response = await client.post(f"{v2_project_url}/resource", json=create_data) - assert create_response.status_code == 200 - created = ResourceResponse.model_validate(create_response.json()) - - # Try to move it to an invalid path - update_data = { - "content": "Updated content", - "file_path": "../../../etc/passwd", - } - response = await client.put( - f"{v2_project_url}/resource/{created.external_id}", - json=update_data, - ) - - assert response.status_code == 400 - assert "Invalid file path" in response.json()["detail"] + """The resource surface is read-only: POST/PUT must not be routable. - -@pytest.mark.asyncio -async def test_resource_invalid_project_id( - client: AsyncClient, -): - """Test resource endpoints with invalid project external_id return 404.""" - fake_project_uuid = "00000000-0000-0000-0000-000000000000" + Guards the write invariant from the 2026-07 architecture review: no API + endpoint writes resource files inline (#1106). + """ fake_entity_uuid = "00000000-0000-0000-0000-000000000001" - # Test create - response = await client.post( - f"/v2/projects/{fake_project_uuid}/resource", + # No route exists at POST /resource anymore, so the path itself is gone. + post_response = await client.post( + f"{v2_project_url}/resource", json={"file_path": "test.md", "content": "test"}, ) - assert response.status_code == 404 - - # Test get - response = await client.get(f"/v2/projects/{fake_project_uuid}/resource/{fake_entity_uuid}") - assert response.status_code == 404 + assert post_response.status_code == 404 - # Test update - response = await client.put( - f"/v2/projects/{fake_project_uuid}/resource/{fake_entity_uuid}", + # PUT hits the GET route's path with a disallowed method. + put_response = await client.put( + f"{v2_project_url}/resource/{fake_entity_uuid}", json={"content": "test"}, ) - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_v2_resource_endpoints_use_project_id_not_name( - client: AsyncClient, test_project: Project -): - """Verify v2 resource endpoints require project external_id UUID, not name.""" - # Try using project name instead of external_id - should fail - fake_entity_uuid = "00000000-0000-0000-0000-000000000000" - response = await client.get(f"/v2/projects/{test_project.name}/resource/{fake_entity_uuid}") - - # Should get 404 because name is not a valid project external_id - assert response.status_code == 404 + assert put_response.status_code == 405 diff --git a/tests/mcp/test_resources.py b/tests/mcp/test_resources.py index 10785363c..93aae920c 100644 --- a/tests/mcp/test_resources.py +++ b/tests/mcp/test_resources.py @@ -6,7 +6,7 @@ @pytest.mark.asyncio async def test_ai_assistant_guide_exists(app): - """Test that the canvas spec resource exists and returns content.""" + """Test that the AI assistant guide resource exists and returns content.""" # Call the resource function guide = ai_assistant_guide() @@ -15,5 +15,4 @@ async def test_ai_assistant_guide_exists(app): assert isinstance(guide, str) assert len(guide) > 0 - # Verify it contains expected sections of the Canvas spec assert "# AI Assistant Guide" in guide diff --git a/tests/mcp/test_tool_canvas.py b/tests/mcp/test_tool_canvas.py deleted file mode 100644 index 7142f589c..000000000 --- a/tests/mcp/test_tool_canvas.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Tests for canvas tool that exercise the full stack with SQLite.""" - -import json -from pathlib import Path - -import pytest - -from basic_memory.mcp.tools import canvas - - -@pytest.mark.asyncio -async def test_create_canvas(app, project_config, test_project): - """Test creating a new canvas file. - - Should: - - Create canvas file with correct content - - Create entity in database - - Return successful status - """ - # Test data - nodes = [ - { - "id": "node1", - "type": "text", - "text": "Test Node", - "x": 100, - "y": 200, - "width": 400, - "height": 300, - } - ] - edges = [{"id": "edge1", "fromNode": "node1", "toNode": "node2", "label": "connects to"}] - title = "test-canvas" - folder = "visualizations" - - # Execute - result = await canvas( - project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder - ) - - # Verify result message - assert result - assert "Created: visualizations/test-canvas" in result - assert "The canvas is ready to open in Obsidian" in result - - # Verify file was created - file_path = Path(project_config.home) / folder / f"{title}.canvas" - assert file_path.exists() - - # Verify content is correct - content = json.loads(file_path.read_text(encoding="utf-8")) - assert content["nodes"] == nodes - assert content["edges"] == edges - - -@pytest.mark.asyncio -async def test_create_canvas_with_extension(app, project_config, test_project): - """Test creating a canvas file with .canvas extension already in the title.""" - # Test data - nodes = [ - { - "id": "node1", - "type": "text", - "text": "Extension Test", - "x": 100, - "y": 200, - "width": 400, - "height": 300, - } - ] - edges = [] - title = "extension-test.canvas" # Already has extension - folder = "visualizations" - - # Execute - result = await canvas( - project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder - ) - - # Verify - assert "Created: visualizations/extension-test.canvas" in result - - # Verify file exists with correct name (shouldn't have double extension) - file_path = Path(project_config.home) / folder / title - assert file_path.exists() - - # Verify content - content = json.loads(file_path.read_text(encoding="utf-8")) - assert content["nodes"] == nodes - - -@pytest.mark.asyncio -async def test_update_existing_canvas(app, project_config, test_project): - """Test updating an existing canvas file.""" - # First create a canvas - nodes = [ - { - "id": "initial", - "type": "text", - "text": "Initial content", - "x": 0, - "y": 0, - "width": 200, - "height": 100, - } - ] - edges = [] - title = "update-test" - folder = "visualizations" - - # Create initial canvas - await canvas(project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder) - - # Verify file exists - file_path = Path(project_config.home) / folder / f"{title}.canvas" - assert file_path.exists() - - # Now update with new content - updated_nodes = [ - { - "id": "updated", - "type": "text", - "text": "Updated content", - "x": 100, - "y": 100, - "width": 300, - "height": 200, - } - ] - updated_edges = [ - {"id": "new-edge", "fromNode": "updated", "toNode": "other", "label": "new connection"} - ] - - # Execute update - result = await canvas( - project=test_project.name, - nodes=updated_nodes, - edges=updated_edges, - title=title, - directory=folder, - ) - - # Verify result indicates update - assert "Updated: visualizations/update-test.canvas" in result - - # Verify content was updated - content = json.loads(file_path.read_text(encoding="utf-8")) - assert content["nodes"] == updated_nodes - assert content["edges"] == updated_edges - - -@pytest.mark.asyncio -async def test_create_canvas_with_nested_folders(app, project_config, test_project): - """Test creating a canvas in nested folders that don't exist yet.""" - # Test data - nodes = [ - { - "id": "test", - "type": "text", - "text": "Nested folder test", - "x": 0, - "y": 0, - "width": 200, - "height": 100, - } - ] - edges = [] - title = "nested-test" - folder = "visualizations/nested/folders" # Deep path - - # Execute - result = await canvas( - project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder - ) - - # Verify - assert "Created: visualizations/nested/folders/nested-test.canvas" in result - - # Verify folders and file were created - file_path = Path(project_config.home) / folder / f"{title}.canvas" - assert file_path.exists() - assert file_path.parent.exists() - - -@pytest.mark.asyncio -async def test_create_canvas_complex_content(app, project_config, test_project): - """Test creating a canvas with complex content structures.""" - # Test data - more complex structure with all node types - nodes = [ - { - "id": "text-node", - "type": "text", - "text": "# Heading\n\nThis is a test with *markdown* formatting", - "x": 100, - "y": 100, - "width": 400, - "height": 300, - "color": "4", # Using a preset color - }, - { - "id": "file-node", - "type": "file", - "file": "test/test-file.md", # Reference a file - "x": 600, - "y": 100, - "width": 400, - "height": 300, - "color": "#FF5500", # Using hex color - }, - { - "id": "link-node", - "type": "link", - "url": "https://example.com", - "x": 100, - "y": 500, - "width": 400, - "height": 200, - }, - { - "id": "group-node", - "type": "group", - "label": "Group Label", - "x": 600, - "y": 500, - "width": 600, - "height": 400, - }, - ] - - edges = [ - { - "id": "edge1", - "fromNode": "text-node", - "toNode": "file-node", - "label": "references", - "fromSide": "right", - "toSide": "left", - }, - { - "id": "edge2", - "fromNode": "link-node", - "toNode": "group-node", - "label": "belongs to", - "color": "6", - }, - ] - - title = "complex-test" - folder = "visualizations" - - # Create a test file that we're referencing - test_file_path = Path(project_config.home) / "test/test-file.md" - test_file_path.parent.mkdir(parents=True, exist_ok=True) - test_file_path.write_text("# Test File\nThis is referenced by the canvas") - - # Execute - result = await canvas( - project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder - ) - - # Verify - assert "Created: visualizations/complex-test.canvas" in result - - # Verify file was created - file_path = Path(project_config.home) / folder / f"{title}.canvas" - assert file_path.exists() - - # Verify content is correct with all complex structures - content = json.loads(file_path.read_text(encoding="utf-8")) - assert len(content["nodes"]) == 4 - assert len(content["edges"]) == 2 - - # Verify specific content elements are preserved - assert any(node["type"] == "text" and "#" in node["text"] for node in content["nodes"]) - assert any( - node["type"] == "file" and "test-file.md" in node["file"] for node in content["nodes"] - ) - assert any(node["type"] == "link" and "example.com" in node["url"] for node in content["nodes"]) - assert any( - node["type"] == "group" and "Group Label" == node["label"] for node in content["nodes"] - ) - - # Verify edge properties - assert any( - edge["fromSide"] == "right" and edge["toSide"] == "left" for edge in content["edges"] - ) - assert any(edge["label"] == "belongs to" and edge["color"] == "6" for edge in content["edges"]) diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 18d79e7f1..4197ceeaf 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -25,7 +25,6 @@ "max_related", "output_format", ], - "canvas": ["nodes", "edges", "title", "directory", "project", "project_id"], "cloud_info": [], "create_memory_project": [ "project_name", @@ -152,8 +151,6 @@ "search": {"readOnlyHint": True, "destructiveHint": False}, "search_notes": {"readOnlyHint": True, "destructiveHint": False}, "view_note": {"readOnlyHint": True, "destructiveHint": False}, - # canvas falls back to PUT when the file already exists, replacing its content. - "canvas": {"readOnlyHint": False, "destructiveHint": True}, # create_memory_project is purely additive: it creates a new project and errors # if the target already exists. "create_memory_project": {"readOnlyHint": False, "destructiveHint": False}, @@ -190,7 +187,6 @@ TOOL_FUNCTIONS: dict[str, object] = { "basic_memory_diagnostics": tools.basic_memory_diagnostics, "build_context": tools.build_context, - "canvas": tools.canvas, "cloud_info": tools.cloud_info, "create_memory_project": tools.create_memory_project, "delete_note": tools.delete_note,