Skip to content

Commit bd155b1

Browse files
Add metadata support and integration tests for QdrantConnector (#25)
1 parent b9f773e commit bd155b1

File tree

7 files changed

+223
-27
lines changed

7 files changed

+223
-27
lines changed

README.md

+8-7
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@ It acts as a semantic memory layer on top of the Qdrant database.
1919

2020
### Tools
2121

22-
1. `qdrant-store-memory`
23-
- Store a memory in the Qdrant database
22+
1. `qdrant-store`
23+
- Store some information in the Qdrant database
2424
- Input:
25-
- `information` (string): Memory to store
25+
- `information` (string): Information to store
26+
- `metadata` (JSON): Optional metadata to store
2627
- Returns: Confirmation message
27-
2. `qdrant-find-memories`
28-
- Retrieve a memory from the Qdrant database
28+
2. `qdrant-find`
29+
- Retrieve relevant information from the Qdrant database
2930
- Input:
30-
- `query` (string): Query to retrieve a memory
31-
- Returns: Memories stored in the Qdrant database as separate messages
31+
- `query` (string): Query to use for searching
32+
- Returns: Information stored in the Qdrant database as separate messages
3233

3334
## Installation in Claude Desktop
3435

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"mcp[cli]>=1.3.0",
1010
"fastembed>=0.6.0",
1111
"qdrant-client>=1.12.0",
12+
"pydantic>=2.10.6",
1213
]
1314

1415
[build-system]

src/mcp_server_qdrant/embeddings/factory.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from mcp_server_qdrant.embeddings import EmbeddingProvider
1+
from mcp_server_qdrant.embeddings.base import EmbeddingProvider
22
from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
33
from mcp_server_qdrant.settings import EmbeddingProviderSettings
44

src/mcp_server_qdrant/qdrant.py

+31-9
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
1+
import logging
12
import uuid
2-
from typing import Optional
3+
from typing import Any, Dict, Optional
34

5+
from pydantic import BaseModel
46
from qdrant_client import AsyncQdrantClient, models
57

6-
from .embeddings.base import EmbeddingProvider
8+
from mcp_server_qdrant.embeddings.base import EmbeddingProvider
9+
10+
logger = logging.getLogger(__name__)
11+
12+
Metadata = Dict[str, Any]
13+
14+
15+
class Entry(BaseModel):
16+
"""
17+
A single entry in the Qdrant collection.
18+
"""
19+
20+
content: str
21+
metadata: Optional[Metadata] = None
722

823

924
class QdrantConnector:
@@ -53,30 +68,31 @@ async def _ensure_collection_exists(self):
5368
},
5469
)
5570

56-
async def store(self, information: str):
71+
async def store(self, entry: Entry):
5772
"""
58-
Store some information in the Qdrant collection.
59-
:param information: The information to store.
73+
Store some information in the Qdrant collection, along with the specified metadata.
74+
:param entry: The entry to store in the Qdrant collection.
6075
"""
6176
await self._ensure_collection_exists()
6277

6378
# Embed the document
64-
embeddings = await self._embedding_provider.embed_documents([information])
79+
embeddings = await self._embedding_provider.embed_documents([entry.content])
6580

6681
# Add to Qdrant
6782
vector_name = self._embedding_provider.get_vector_name()
83+
payload = {"document": entry.content, "metadata": entry.metadata}
6884
await self._client.upsert(
6985
collection_name=self._collection_name,
7086
points=[
7187
models.PointStruct(
7288
id=uuid.uuid4().hex,
7389
vector={vector_name: embeddings[0]},
74-
payload={"document": information},
90+
payload=payload,
7591
)
7692
],
7793
)
7894

79-
async def search(self, query: str) -> list[str]:
95+
async def search(self, query: str) -> list[Entry]:
8096
"""
8197
Find points in the Qdrant collection. If there are no entries found, an empty list is returned.
8298
:param query: The query to use for the search.
@@ -97,4 +113,10 @@ async def search(self, query: str) -> list[str]:
97113
limit=10,
98114
)
99115

100-
return [result.payload["document"] for result in search_results]
116+
return [
117+
Entry(
118+
content=result.payload["document"],
119+
metadata=result.payload.get("metadata"),
120+
)
121+
for result in search_results
122+
]

src/mcp_server_qdrant/server.py

+19-10
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1+
import json
12
import logging
23
from contextlib import asynccontextmanager
3-
from typing import AsyncIterator, List
4+
from typing import AsyncIterator, List, Optional
45

56
from mcp.server import Server
67
from mcp.server.fastmcp import Context, FastMCP
78

89
from mcp_server_qdrant.embeddings.factory import create_embedding_provider
9-
from mcp_server_qdrant.qdrant import QdrantConnector
10+
from mcp_server_qdrant.qdrant import Entry, Metadata, QdrantConnector
1011
from mcp_server_qdrant.settings import EmbeddingProviderSettings, QdrantSettings
1112

1213
logger = logging.getLogger(__name__)
@@ -57,40 +58,44 @@ async def server_lifespan(server: Server) -> AsyncIterator[dict]: # noqa
5758

5859

5960
@mcp.tool(
60-
name="qdrant-store-memory",
61+
name="qdrant-store",
6162
description=(
6263
"Keep the memory for later use, when you are asked to remember something."
6364
),
6465
)
65-
async def store(information: str, ctx: Context) -> str:
66+
async def store(
67+
ctx: Context, information: str, metadata: Optional[Metadata] = None
68+
) -> str:
6669
"""
6770
Store a memory in Qdrant.
68-
:param information: The information to store.
6971
:param ctx: The context for the request.
72+
:param information: The information to store.
73+
:param metadata: JSON metadata to store with the information, optional.
7074
:return: A message indicating that the information was stored.
7175
"""
7276
await ctx.debug(f"Storing information {information} in Qdrant")
7377
qdrant_connector: QdrantConnector = ctx.request_context.lifespan_context[
7478
"qdrant_connector"
7579
]
76-
await qdrant_connector.store(information)
80+
entry = Entry(content=information, metadata=metadata)
81+
await qdrant_connector.store(entry)
7782
return f"Remembered: {information}"
7883

7984

8085
@mcp.tool(
81-
name="qdrant-find-memories",
86+
name="qdrant-find",
8287
description=(
8388
"Look up memories in Qdrant. Use this tool when you need to: \n"
8489
" - Find memories by their content \n"
8590
" - Access memories for further analysis \n"
8691
" - Get some personal information about the user"
8792
),
8893
)
89-
async def find(query: str, ctx: Context) -> List[str]:
94+
async def find(ctx: Context, query: str) -> List[str]:
9095
"""
9196
Find memories in Qdrant.
92-
:param query: The query to use for the search.
9397
:param ctx: The context for the request.
98+
:param query: The query to use for the search.
9499
:return: A list of entries found.
95100
"""
96101
await ctx.debug(f"Finding points for query {query}")
@@ -104,5 +109,9 @@ async def find(query: str, ctx: Context) -> List[str]:
104109
f"Memories for the query '{query}'",
105110
]
106111
for entry in entries:
107-
content.append(f"<entry>{entry}</entry>")
112+
# Format the metadata as a JSON string and produce XML-like output
113+
entry_metadata = json.dumps(entry.metadata) if entry.metadata else ""
114+
content.append(
115+
f"<entry><content>{entry.content}</content><metadata>{entry_metadata}</metadata></entry>"
116+
)
108117
return content

tests/test_qdrant_integration.py

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import uuid
2+
3+
import pytest
4+
5+
from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider
6+
from mcp_server_qdrant.qdrant import Entry, QdrantConnector
7+
8+
9+
@pytest.fixture
10+
async def embedding_provider():
11+
"""Fixture to provide a FastEmbed embedding provider."""
12+
return FastEmbedProvider(model_name="sentence-transformers/all-MiniLM-L6-v2")
13+
14+
15+
@pytest.fixture
16+
async def qdrant_connector(embedding_provider):
17+
"""Fixture to provide a QdrantConnector with in-memory Qdrant client."""
18+
# Use a random collection name to avoid conflicts between tests
19+
collection_name = f"test_collection_{uuid.uuid4().hex}"
20+
21+
# Create connector with in-memory Qdrant
22+
connector = QdrantConnector(
23+
qdrant_url=":memory:",
24+
qdrant_api_key=None,
25+
collection_name=collection_name,
26+
embedding_provider=embedding_provider,
27+
)
28+
29+
yield connector
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_store_and_search(qdrant_connector):
34+
"""Test storing an entry and then searching for it."""
35+
# Store a test entry
36+
test_entry = Entry(
37+
content="The quick brown fox jumps over the lazy dog",
38+
metadata={"source": "test", "importance": "high"},
39+
)
40+
await qdrant_connector.store(test_entry)
41+
42+
# Search for the entry
43+
results = await qdrant_connector.search("fox jumps")
44+
45+
# Verify results
46+
assert len(results) == 1
47+
assert results[0].content == test_entry.content
48+
assert results[0].metadata == test_entry.metadata
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_search_empty_collection(qdrant_connector):
53+
"""Test searching in an empty collection."""
54+
# Search in an empty collection
55+
results = await qdrant_connector.search("test query")
56+
57+
# Verify results
58+
assert len(results) == 0
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_multiple_entries(qdrant_connector):
63+
"""Test storing and searching multiple entries."""
64+
# Store multiple entries
65+
entries = [
66+
Entry(
67+
content="Python is a programming language",
68+
metadata={"topic": "programming"},
69+
),
70+
Entry(content="The Eiffel Tower is in Paris", metadata={"topic": "landmarks"}),
71+
Entry(content="Machine learning is a subset of AI", metadata={"topic": "AI"}),
72+
]
73+
74+
for entry in entries:
75+
await qdrant_connector.store(entry)
76+
77+
# Search for programming-related entries
78+
programming_results = await qdrant_connector.search("Python programming")
79+
assert len(programming_results) > 0
80+
assert any("Python" in result.content for result in programming_results)
81+
82+
# Search for landmark-related entries
83+
landmark_results = await qdrant_connector.search("Eiffel Tower Paris")
84+
assert len(landmark_results) > 0
85+
assert any("Eiffel" in result.content for result in landmark_results)
86+
87+
# Search for AI-related entries
88+
ai_results = await qdrant_connector.search(
89+
"artificial intelligence machine learning"
90+
)
91+
assert len(ai_results) > 0
92+
assert any("machine learning" in result.content.lower() for result in ai_results)
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_ensure_collection_exists(qdrant_connector):
97+
"""Test that the collection is created if it doesn't exist."""
98+
# The collection shouldn't exist yet
99+
assert not await qdrant_connector._client.collection_exists(
100+
qdrant_connector._collection_name
101+
)
102+
103+
# Storing an entry should create the collection
104+
test_entry = Entry(content="Test content")
105+
await qdrant_connector.store(test_entry)
106+
107+
# Now the collection should exist
108+
assert await qdrant_connector._client.collection_exists(
109+
qdrant_connector._collection_name
110+
)
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_metadata_handling(qdrant_connector):
115+
"""Test that metadata is properly stored and retrieved."""
116+
# Store entries with different metadata
117+
metadata1 = {"source": "book", "author": "Jane Doe", "year": 2023}
118+
metadata2 = {"source": "article", "tags": ["science", "research"]}
119+
120+
await qdrant_connector.store(
121+
Entry(content="Content with structured metadata", metadata=metadata1)
122+
)
123+
await qdrant_connector.store(
124+
Entry(content="Content with list in metadata", metadata=metadata2)
125+
)
126+
127+
# Search and verify metadata is preserved
128+
results = await qdrant_connector.search("metadata")
129+
130+
assert len(results) == 2
131+
132+
# Check that both metadata objects are present in the results
133+
found_metadata1 = False
134+
found_metadata2 = False
135+
136+
for result in results:
137+
if result.metadata.get("source") == "book":
138+
assert result.metadata.get("author") == "Jane Doe"
139+
assert result.metadata.get("year") == 2023
140+
found_metadata1 = True
141+
elif result.metadata.get("source") == "article":
142+
assert "science" in result.metadata.get("tags", [])
143+
assert "research" in result.metadata.get("tags", [])
144+
found_metadata2 = True
145+
146+
assert found_metadata1
147+
assert found_metadata2
148+
149+
150+
@pytest.mark.asyncio
151+
async def test_entry_without_metadata(qdrant_connector):
152+
"""Test storing and retrieving entries without metadata."""
153+
# Store an entry without metadata
154+
await qdrant_connector.store(Entry(content="Entry without metadata"))
155+
156+
# Search and verify
157+
results = await qdrant_connector.search("without metadata")
158+
159+
assert len(results) == 1
160+
assert results[0].content == "Entry without metadata"
161+
assert results[0].metadata is None

uv.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)