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