From c2c307f09ef6c473b1c63f1ad7bee6bd418e8886 Mon Sep 17 00:00:00 2001 From: sascha08-15 Date: Fri, 20 Mar 2026 23:36:09 +0100 Subject: [PATCH] Add qdrant-delete tool for memory management Add a delete tool that removes memories by semantic search, enabling cleanup of stale or contradictory entries. The tool finds the closest matching point(s) and deletes them, returning confirmation of what was removed. Changes: - QdrantConnector.delete() method using semantic search + point deletion - qdrant-delete tool registration in MCP server (write-mode only) - TOOL_DELETE_DESCRIPTION setting for customization - 5 integration tests covering deletion scenarios - README documentation for the new tool and env var Closes #74, closes #69 Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +++ src/mcp_server_qdrant/mcp_server.py | 39 +++++++++++++++ src/mcp_server_qdrant/qdrant.py | 47 +++++++++++++++++ src/mcp_server_qdrant/settings.py | 8 +++ tests/test_qdrant_integration.py | 78 +++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+) diff --git a/README.md b/README.md index 5bb05f9a..237a88e0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ It acts as a semantic memory layer on top of the Qdrant database. - `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name. If there is a default collection name, this field is not enabled. - Returns: Information stored in the Qdrant database as separate messages +3. `qdrant-delete` + - Delete a memory from the Qdrant database by semantic search + - Input: + - `query` (string): Semantic search query to find the memory to delete + - `collection_name` (string): Name of the collection to delete from. This field is required if there is no default collection name. + If there is a default collection name, this field is not enabled. + - Returns: Confirmation of deleted memory content ## Environment Variables @@ -48,6 +55,7 @@ The configuration of the server is done using environment variables: | `EMBEDDING_MODEL` | Name of the embedding model to use | `sentence-transformers/all-MiniLM-L6-v2` | | `TOOL_STORE_DESCRIPTION` | Custom description for the store tool | See default in [`settings.py`](src/mcp_server_qdrant/settings.py) | | `TOOL_FIND_DESCRIPTION` | Custom description for the find tool | See default in [`settings.py`](src/mcp_server_qdrant/settings.py) | +| `TOOL_DELETE_DESCRIPTION`| Custom description for the delete tool | See default in [`settings.py`](src/mcp_server_qdrant/settings.py) | Note: You cannot provide both `QDRANT_URL` and `QDRANT_LOCAL_PATH` at the same time. diff --git a/src/mcp_server_qdrant/mcp_server.py b/src/mcp_server_qdrant/mcp_server.py index 0617b9d8..e07e259f 100644 --- a/src/mcp_server_qdrant/mcp_server.py +++ b/src/mcp_server_qdrant/mcp_server.py @@ -164,8 +164,38 @@ async def find( content.append(self.format_entry(entry)) return content + async def delete( + ctx: Context, + query: Annotated[ + str, + Field( + description="Semantic search query to find the memory to delete" + ), + ], + collection_name: Annotated[ + str, Field(description="The collection to delete from") + ], + ) -> str: + """ + Delete a memory from Qdrant by semantic search. + :param ctx: The context for the request. + :param query: The query to use for finding the memory to delete. + :param collection_name: The name of the collection to delete from, optional. If not provided, + the default collection is used. + :return: A message indicating what was deleted. + """ + await ctx.debug(f"Deleting memory matching '{query}'") + deleted = await self.qdrant_connector.delete( + query, collection_name=collection_name + ) + if not deleted: + return f"No matching memory found for: {query}" + contents = [entry.content[:100] for entry in deleted] + return f"Deleted {len(deleted)} memory(ies): {contents}" + find_foo = find store_foo = store + delete_foo = delete filterable_conditions = ( self.qdrant_settings.filterable_fields_dict_with_conditions() @@ -183,6 +213,10 @@ async def find( store_foo = make_partial_function( store_foo, {"collection_name": self.qdrant_settings.collection_name} ) + delete_foo = make_partial_function( + delete_foo, + {"collection_name": self.qdrant_settings.collection_name}, + ) self.tool( find_foo, @@ -197,3 +231,8 @@ async def find( name="qdrant-store", description=self.tool_settings.tool_store_description, ) + self.tool( + delete_foo, + name="qdrant-delete", + description=self.tool_settings.tool_delete_description, + ) diff --git a/src/mcp_server_qdrant/qdrant.py b/src/mcp_server_qdrant/qdrant.py index 8d3e5aa8..c1b9b95e 100644 --- a/src/mcp_server_qdrant/qdrant.py +++ b/src/mcp_server_qdrant/qdrant.py @@ -137,6 +137,53 @@ async def search( for result in search_results.points ] + async def delete( + self, + query: str, + *, + collection_name: str | None = None, + limit: int = 1, + ) -> list[Entry]: + """ + Delete the closest matching point(s) by semantic search. + Returns the deleted entries for confirmation. + :param query: The query to use for finding the entries to delete. + :param collection_name: The name of the collection to delete from, optional. If not provided, + the default collection is used. + :param limit: The maximum number of entries to delete. + :return: A list of deleted entries. + """ + collection_name = collection_name or self._default_collection_name + if not await self._client.collection_exists(collection_name): + return [] + + query_vector = await self._embedding_provider.embed_query(query) + vector_name = self._embedding_provider.get_vector_name() + + results = await self._client.query_points( + collection_name=collection_name, + query=query_vector, + using=vector_name, + limit=limit, + ) + + if not results.points: + return [] + + point_ids = [r.id for r in results.points] + await self._client.delete( + collection_name=collection_name, + points_selector=models.PointIdsList(points=point_ids), + ) + + return [ + Entry( + content=r.payload["document"], + metadata=r.payload.get(METADATA_PATH), + ) + for r in results.points + ] + async def _ensure_collection_exists(self, collection_name: str): """ Ensure that the collection exists, creating it if necessary. diff --git a/src/mcp_server_qdrant/settings.py b/src/mcp_server_qdrant/settings.py index e48c10d1..a3408402 100644 --- a/src/mcp_server_qdrant/settings.py +++ b/src/mcp_server_qdrant/settings.py @@ -8,6 +8,10 @@ DEFAULT_TOOL_STORE_DESCRIPTION = ( "Keep the memory for later use, when you are asked to remember something." ) +DEFAULT_TOOL_DELETE_DESCRIPTION = ( + "Delete a memory from Qdrant by semantic search. " + "The closest matching memory will be removed." +) DEFAULT_TOOL_FIND_DESCRIPTION = ( "Look up memories in Qdrant. Use this tool when you need to: \n" " - Find memories by their content \n" @@ -31,6 +35,10 @@ class ToolSettings(BaseSettings): default=DEFAULT_TOOL_FIND_DESCRIPTION, validation_alias="TOOL_FIND_DESCRIPTION", ) + tool_delete_description: str = Field( + default=DEFAULT_TOOL_DELETE_DESCRIPTION, + validation_alias="TOOL_DELETE_DESCRIPTION", + ) class EmbeddingProviderSettings(BaseSettings): diff --git a/tests/test_qdrant_integration.py b/tests/test_qdrant_integration.py index 7efe6596..dee1e908 100644 --- a/tests/test_qdrant_integration.py +++ b/tests/test_qdrant_integration.py @@ -236,3 +236,81 @@ async def test_nonexistent_collection_search(qdrant_connector): # Verify results assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_delete_entry(qdrant_connector): + """Test storing an entry and then deleting it.""" + test_entry = Entry( + content="This memory should be deleted", + metadata={"temporary": True}, + ) + await qdrant_connector.store(test_entry) + + # Verify it exists + results = await qdrant_connector.search("memory should be deleted") + assert len(results) == 1 + + # Delete it + deleted = await qdrant_connector.delete("memory should be deleted") + assert len(deleted) == 1 + assert deleted[0].content == test_entry.content + + # Verify it's gone + results = await qdrant_connector.search("memory should be deleted") + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_delete_nonexistent_collection(qdrant_connector): + """Test deleting from a collection that doesn't exist.""" + nonexistent = f"nonexistent_{uuid.uuid4().hex}" + deleted = await qdrant_connector.delete( + "test query", collection_name=nonexistent + ) + assert len(deleted) == 0 + + +@pytest.mark.asyncio +async def test_delete_no_match_empty_collection(qdrant_connector): + """Test deleting from an empty collection.""" + deleted = await qdrant_connector.delete("nonexistent memory") + assert len(deleted) == 0 + + +@pytest.mark.asyncio +async def test_delete_preserves_other_entries(qdrant_connector): + """Test that delete only removes the closest match.""" + await qdrant_connector.store(Entry(content="Python is a programming language")) + await qdrant_connector.store(Entry(content="The Eiffel Tower is in Paris")) + + # Delete only the Python entry + deleted = await qdrant_connector.delete("Python programming") + assert len(deleted) == 1 + assert "Python" in deleted[0].content + + # Eiffel Tower entry should still exist + results = await qdrant_connector.search("Eiffel Tower Paris") + assert len(results) == 1 + assert "Eiffel" in results[0].content + + +@pytest.mark.asyncio +async def test_delete_custom_collection(qdrant_connector): + """Test deleting from a custom collection.""" + custom_collection = f"custom_{uuid.uuid4().hex}" + + await qdrant_connector.store( + Entry(content="Custom collection entry"), + collection_name=custom_collection, + ) + + deleted = await qdrant_connector.delete( + "custom collection", collection_name=custom_collection + ) + assert len(deleted) == 1 + + results = await qdrant_connector.search( + "custom collection", collection_name=custom_collection + ) + assert len(results) == 0