Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
39 changes: 39 additions & 0 deletions src/mcp_server_qdrant/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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,
)
47 changes: 47 additions & 0 deletions src/mcp_server_qdrant/qdrant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/mcp_server_qdrant/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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):
Expand Down
78 changes: 78 additions & 0 deletions tests/test_qdrant_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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