diff --git a/src/basic_memory/api/routers/knowledge_router.py b/src/basic_memory/api/routers/knowledge_router.py index f0f75d46..1c7ba3ca 100644 --- a/src/basic_memory/api/routers/knowledge_router.py +++ b/src/basic_memory/api/routers/knowledge_router.py @@ -9,7 +9,8 @@ from basic_memory.deps import ( EntityServiceDep, get_search_service, RelationServiceDep, - ObservationServiceDep, SearchServiceDep, + ObservationServiceDep, + SearchServiceDep, LinkResolverDep, ) from basic_memory.schemas import ( CreateEntityRequest, @@ -21,7 +22,6 @@ from basic_memory.schemas import ( DeleteObservationsRequest, DeleteRelationsRequest, DeleteEntitiesRequest, - UpdateEntityRequest, ) from basic_memory.schemas.base import PathId, Entity from basic_memory.services.exceptions import EntityNotFoundError @@ -30,33 +30,31 @@ router = APIRouter(prefix="/knowledge", tags=["knowledge"]) ## Create endpoints + @router.put("/entities/{permalink:path}", response_model=EntityResponse) async def create_or_update_entity( - permalink: PathId, - data: Entity, - response: Response, - background_tasks: BackgroundTasks, - entity_service: EntityServiceDep, - search_service: SearchServiceDep, + permalink: PathId, + data: Entity, + response: Response, + background_tasks: BackgroundTasks, + entity_service: EntityServiceDep, + search_service: SearchServiceDep, ) -> EntityResponse: """Create or update an entity. If entity exists, it will be updated, otherwise created.""" # Validate permalink matches if data.permalink != permalink: - raise HTTPException( - status_code=400, - detail="Entity permalink must match URL path" - ) + raise HTTPException(status_code=400, detail="Entity permalink must match URL path") # Try create_or_update operation entity, created = await entity_service.create_or_update_entity(data) response.status_code = 201 if created else 200 - + # Always reindex since content has changed await search_service.index_entity(entity, background_tasks=background_tasks) return EntityResponse.model_validate(entity) - + @router.post("/entities", response_model=EntityListResponse) async def create_entities( data: CreateEntityRequest, @@ -76,7 +74,6 @@ async def create_entities( ) - @router.post("/relations", response_model=EntityListResponse) async def create_relations( data: CreateRelationsRequest, @@ -154,6 +151,29 @@ async def get_entities( ## Delete endpoints +@router.delete("/entities/{identifier:path}", response_model=DeleteEntitiesResponse) +async def delete_entity( + identifier: str, + background_tasks: BackgroundTasks, + entity_service: EntityServiceDep, + link_resolver: LinkResolverDep, + search_service=Depends(get_search_service), +) -> DeleteEntitiesResponse: + """Delete a single entity and remove from search index.""" + + entity = await link_resolver.resolve_link(identifier) + if entity is None: + return DeleteEntitiesResponse(deleted=False) + + # Delete the entity + deleted = await entity_service.delete_entity(entity.permalink) + + # Remove from search index + background_tasks.add_task(search_service.delete_by_permalink, entity.permalink) + + return DeleteEntitiesResponse(deleted=deleted) + + @router.post("/entities/delete", response_model=DeleteEntitiesResponse) async def delete_entities( data: DeleteEntitiesRequest, diff --git a/src/basic_memory/mcp/tools/notes.py b/src/basic_memory/mcp/tools/notes.py index 34a6c60b..0f812380 100644 --- a/src/basic_memory/mcp/tools/notes.py +++ b/src/basic_memory/mcp/tools/notes.py @@ -10,12 +10,11 @@ from loguru import logger from basic_memory.mcp.server import mcp from basic_memory.mcp.async_client import client -from basic_memory.schemas import EntityResponse -from basic_memory.schemas.request import CreateEntityRequest +from basic_memory.schemas import EntityResponse, DeleteEntitiesResponse from basic_memory.schemas.base import Entity, Relation from basic_memory.schemas.request import CreateRelationsRequest -from basic_memory.mcp.tools.knowledge import create_entities, create_relations -from basic_memory.mcp.tools.utils import call_get, call_put +from basic_memory.mcp.tools.knowledge import create_relations +from basic_memory.mcp.tools.utils import call_get, call_put, call_delete @mcp.tool( @@ -55,18 +54,18 @@ async def write_note( # Create the entity request metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None entity = Entity( - title=title, - entity_type="note", - content_type="text/markdown", - content=content, - entity_metadata=metadata, - ) + title=title, + entity_type="note", + content_type="text/markdown", + content=content, + entity_metadata=metadata, + ) # Use existing knowledge tool logger.info(f"Creating {entity.permalink}") url = f"/knowledge/entities/{entity.permalink}" response = await call_put(client, url, json=entity.model_dump()) - result = EntityResponse.model_validate(response.json()) + result = EntityResponse.model_validate(response.json()) return result.permalink @@ -137,3 +136,25 @@ async def link_notes( ) response = await create_relations(request) return response.entities[0].permalink + + +@mcp.tool(description="Delete a note by title or permalink") +async def delete_note(identifier: str) -> bool: + """Delete a note from the knowledge base. + + Args: + identifier: Note title or permalink + + Returns: + True if note was deleted, False otherwise + + Examples: + # Delete by title + delete_note("Meeting Notes: Project Planning") + + # Delete by permalink + delete_note("notes/project-planning") + """ + response = await call_delete(client, f"/knowledge/entities/{identifier}") + result = DeleteEntitiesResponse.model_validate(response.json()) + return result.deleted diff --git a/src/basic_memory/mcp/tools/utils.py b/src/basic_memory/mcp/tools/utils.py index c674b882..38431d0c 100644 --- a/src/basic_memory/mcp/tools/utils.py +++ b/src/basic_memory/mcp/tools/utils.py @@ -29,7 +29,6 @@ async def call_get( timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, ) -> Response: - logger.debug(f"Calling GET '{url}' params: '{params}'") try: response = await client.get( @@ -123,3 +122,33 @@ async def call_post( except HTTPStatusError as e: logger.error(f"Error calling POST {url}: {e}") raise ToolError(f"Error calling tool: {e}") from e + + +async def call_delete( + client: AsyncClient, + url: URL | str, + *, + params: QueryParamTypes | None = None, + headers: HeaderTypes | None = None, + cookies: CookieTypes | None = None, + auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT, + follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, + timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, + extensions: RequestExtensions | None = None, +) -> Response: + try: + response = await client.delete( + url=url, + params=params, + headers=headers, + cookies=cookies, + auth=auth, + follow_redirects=follow_redirects, + timeout=timeout, + extensions=extensions, + ) + response.raise_for_status() + return response + except HTTPStatusError as e: + logger.error(f"Error calling DELETE {url}: {e}") + raise ToolError(f"Error calling tool: {e}") from e diff --git a/tests/api/test_knowledge_router.py b/tests/api/test_knowledge_router.py index 5e0f65bb..cc81ca0c 100644 --- a/tests/api/test_knowledge_router.py +++ b/tests/api/test_knowledge_router.py @@ -204,6 +204,55 @@ async def test_delete_entity(client: AsyncClient): response = await client.get(f"/knowledge/entities/{permalink}") assert response.status_code == 404 +@pytest.mark.asyncio +async def test_delete_single_entity(client: AsyncClient): + """Test DELETE /knowledge/entities with path ID.""" + # Create test entity + entity_data = {"title": "TestEntity", "entity_type": "test"} + await client.post("/knowledge/entities", json={"entities": [entity_data]}) + + # Test deletion + response = await client.delete( + "/knowledge/entities/test-entity" + ) + assert response.status_code == 200 + assert response.json() == {"deleted": True} + + # Verify entity is gone + permalink = quote("test/TestEntity") + response = await client.get(f"/knowledge/entities/{permalink}") + assert response.status_code == 404 + +@pytest.mark.asyncio +async def test_delete_single_entity_by_title(client: AsyncClient): + """Test DELETE /knowledge/entities with path ID.""" + # Create test entity + entity_data = {"title": "TestEntity", "entity_type": "test"} + await client.post("/knowledge/entities", json={"entities": [entity_data]}) + + # Test deletion + response = await client.delete( + "/knowledge/entities/TestEntity" + ) + assert response.status_code == 200 + assert response.json() == {"deleted": True} + + # Verify entity is gone + permalink = quote("test/TestEntity") + response = await client.get(f"/knowledge/entities/{permalink}") + assert response.status_code == 404 + +@pytest.mark.asyncio +async def test_delete_single_entity_not_found(client: AsyncClient): + """Test DELETE /knowledge/entities with path ID.""" + + # Test deletion + response = await client.delete( + "/knowledge/entities/test-not-found" + ) + assert response.status_code == 200 + assert response.json() == {"deleted": False} + @pytest.mark.asyncio async def test_delete_entity_bulk(client: AsyncClient): diff --git a/tests/mcp/test_tool_notes.py b/tests/mcp/test_tool_notes.py index e8b3f7d6..b3ee81fe 100644 --- a/tests/mcp/test_tool_notes.py +++ b/tests/mcp/test_tool_notes.py @@ -159,3 +159,35 @@ async def test_multiple_notes(app): for i, permalink in enumerate(permalinks): content = await notes.read_note(permalink) assert f"Content {i+1}" in content + + +@pytest.mark.asyncio +async def test_delete_note_existing(app): + """Test deleting a new note. + + Should: + - Create entity with correct type and content + - Return valid permalink + - Delete the note + """ + permalink = await notes.write_note( + title="Test Note", + content="# Test\nThis is a test note", + tags=["test", "documentation"] + ) + + assert permalink # Got a valid permalink + + deleted = await notes.delete_note(permalink) + assert deleted is True + +@pytest.mark.asyncio +async def test_delete_note_doesnt_exist(app): + """Test deleting a new note. + + Should: + - Delete the note + - verify returns false + """ + deleted = await notes.delete_note("doesnt-exist") + assert deleted is False