Skip to content

Commit 181be17

Browse files
authored
Reusable mcp server (#39)
* add developer mode instruction to readme * Make a custom MCP wrapper around FastMCP add more settings, some improvements * upd test and readme * review fixes
1 parent 7aad8eb commit 181be17

File tree

7 files changed

+202
-145
lines changed

7 files changed

+202
-145
lines changed

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ It acts as a semantic memory layer on top of the Qdrant database.
2525
- Input:
2626
- `information` (string): Information to store
2727
- `metadata` (JSON): Optional metadata to store
28-
- `collection_name` (string): Name of the collection to store the information in, optional. If not provided,
29-
the default collection name will be used.
28+
- `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name.
29+
If there is a default collection name, this field is not enabled.
3030
- Returns: Confirmation message
3131
2. `qdrant-find`
3232
- Retrieve relevant information from the Qdrant database
3333
- Input:
3434
- `query` (string): Query to use for searching
35-
- `collection_name` (string): Name of the collection to store the information in, optional. If not provided,
36-
the default collection name will be used.
35+
- `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name.
36+
If there is a default collection name, this field is not enabled.
3737
- Returns: Information stored in the Qdrant database as separate messages
3838

3939
## Environment Variables
@@ -44,7 +44,7 @@ The configuration of the server is done using environment variables:
4444
|--------------------------|---------------------------------------------------------------------|-------------------------------------------------------------------|
4545
| `QDRANT_URL` | URL of the Qdrant server | None |
4646
| `QDRANT_API_KEY` | API key for the Qdrant server | None |
47-
| `COLLECTION_NAME` | Name of the default collection to use. | *Required* |
47+
| `COLLECTION_NAME` | Name of the default collection to use. | None |
4848
| `QDRANT_LOCAL_PATH` | Path to the local Qdrant database (alternative to `QDRANT_URL`) | None |
4949
| `EMBEDDING_PROVIDER` | Embedding provider to use (currently only "fastembed" is supported) | `fastembed` |
5050
| `EMBEDDING_MODEL` | Name of the embedding model to use | `sentence-transformers/all-MiniLM-L6-v2` |
@@ -245,6 +245,15 @@ Claude Code should be already able to:
245245
1. Use the `qdrant-store` tool to store code snippets with descriptions.
246246
2. Use the `qdrant-find` tool to search for relevant code snippets using natural language.
247247

248+
### Run MCP server in Development Mode
249+
250+
The MCP server can be run in development mode using the `mcp dev` command. This will start the server and open the MCP
251+
inspector in your browser.
252+
253+
```shell
254+
COLLECTION_NAME=mcp-dev mcp dev src/mcp_server_qdrant/server.py
255+
```
256+
248257
## Contributing
249258

250259
If you have suggestions for how mcp-server-qdrant could be improved, or want to report a bug, open an issue!

src/mcp_server_qdrant/mcp_server.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import json
2+
import logging
3+
from typing import List
4+
5+
from mcp.server.fastmcp import Context, FastMCP
6+
7+
from mcp_server_qdrant.embeddings.factory import create_embedding_provider
8+
from mcp_server_qdrant.qdrant import Entry, Metadata, QdrantConnector
9+
from mcp_server_qdrant.settings import (
10+
EmbeddingProviderSettings,
11+
QdrantSettings,
12+
ToolSettings,
13+
)
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
# FastMCP is an alternative interface for declaring the capabilities
19+
# of the server. Its API is based on FastAPI.
20+
class QdrantMCPServer(FastMCP):
21+
"""
22+
A MCP server for Qdrant.
23+
"""
24+
25+
def __init__(
26+
self,
27+
tool_settings: ToolSettings,
28+
qdrant_settings: QdrantSettings,
29+
embedding_provider_settings: EmbeddingProviderSettings,
30+
name: str = "mcp-server-qdrant",
31+
):
32+
self.tool_settings = tool_settings
33+
self.qdrant_settings = qdrant_settings
34+
self.embedding_provider_settings = embedding_provider_settings
35+
36+
self.embedding_provider = create_embedding_provider(embedding_provider_settings)
37+
self.qdrant_connector = QdrantConnector(
38+
qdrant_settings.location,
39+
qdrant_settings.api_key,
40+
qdrant_settings.collection_name,
41+
self.embedding_provider,
42+
qdrant_settings.local_path,
43+
)
44+
45+
super().__init__(name=name)
46+
47+
self.setup_tools()
48+
49+
def format_entry(self, entry: Entry) -> str:
50+
"""
51+
Feel free to override this method in your subclass to customize the format of the entry.
52+
"""
53+
entry_metadata = json.dumps(entry.metadata) if entry.metadata else ""
54+
return f"<entry><content>{entry.content}</content><metadata>{entry_metadata}</metadata></entry>"
55+
56+
def setup_tools(self):
57+
async def store(
58+
ctx: Context,
59+
information: str,
60+
collection_name: str,
61+
# The `metadata` parameter is defined as non-optional, but it can be None.
62+
# If we set it to be optional, some of the MCP clients, like Cursor, cannot
63+
# handle the optional parameter correctly.
64+
metadata: Metadata = None,
65+
) -> str:
66+
"""
67+
Store some information in Qdrant.
68+
:param ctx: The context for the request.
69+
:param information: The information to store.
70+
:param metadata: JSON metadata to store with the information, optional.
71+
:param collection_name: The name of the collection to store the information in, optional. If not provided,
72+
the default collection is used.
73+
:return: A message indicating that the information was stored.
74+
"""
75+
await ctx.debug(f"Storing information {information} in Qdrant")
76+
77+
entry = Entry(content=information, metadata=metadata)
78+
79+
await self.qdrant_connector.store(entry, collection_name=collection_name)
80+
if collection_name:
81+
return f"Remembered: {information} in collection {collection_name}"
82+
return f"Remembered: {information}"
83+
84+
async def store_with_default_collection(
85+
ctx: Context,
86+
information: str,
87+
metadata: Metadata = None,
88+
) -> str:
89+
return await store(
90+
ctx, information, metadata, self.qdrant_settings.collection_name
91+
)
92+
93+
async def find(
94+
ctx: Context,
95+
query: str,
96+
collection_name: str,
97+
) -> List[str]:
98+
"""
99+
Find memories in Qdrant.
100+
:param ctx: The context for the request.
101+
:param query: The query to use for the search.
102+
:param collection_name: The name of the collection to search in, optional. If not provided,
103+
the default collection is used.
104+
:param limit: The maximum number of entries to return, optional. Default is 10.
105+
:return: A list of entries found.
106+
"""
107+
await ctx.debug(f"Finding results for query {query}")
108+
if collection_name:
109+
await ctx.debug(
110+
f"Overriding the collection name with {collection_name}"
111+
)
112+
113+
entries = await self.qdrant_connector.search(
114+
query,
115+
collection_name=collection_name,
116+
limit=self.qdrant_settings.search_limit,
117+
)
118+
if not entries:
119+
return [f"No information found for the query '{query}'"]
120+
content = [
121+
f"Results for the query '{query}'",
122+
]
123+
for entry in entries:
124+
content.append(self.format_entry(entry))
125+
return content
126+
127+
async def find_with_default_collection(
128+
ctx: Context,
129+
query: str,
130+
) -> List[str]:
131+
return await find(ctx, query, self.qdrant_settings.collection_name)
132+
133+
# Register the tools depending on the configuration
134+
135+
if self.qdrant_settings.collection_name:
136+
self.add_tool(
137+
find_with_default_collection,
138+
name="qdrant-find",
139+
description=self.tool_settings.tool_find_description,
140+
)
141+
else:
142+
self.add_tool(
143+
find,
144+
name="qdrant-find",
145+
description=self.tool_settings.tool_find_description,
146+
)
147+
148+
if not self.qdrant_settings.read_only:
149+
# Those methods can modify the database
150+
151+
if self.qdrant_settings.collection_name:
152+
self.add_tool(
153+
store_with_default_collection,
154+
name="qdrant-store",
155+
description=self.tool_settings.tool_store_description,
156+
)
157+
else:
158+
self.add_tool(
159+
store,
160+
name="qdrant-store",
161+
description=self.tool_settings.tool_store_description,
162+
)

src/mcp_server_qdrant/qdrant.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ async def store(self, entry: Entry, *, collection_name: Optional[str] = None):
6666
await self._ensure_collection_exists(collection_name)
6767

6868
# Embed the document
69+
# ToDo: instead of embedding text explicitly, use `models.Document`,
70+
# it should unlock usage of server-side inference.
6971
embeddings = await self._embedding_provider.embed_documents([entry.content])
7072

7173
# Add to Qdrant
@@ -99,13 +101,17 @@ async def search(
99101
return []
100102

101103
# Embed the query
104+
# ToDo: instead of embedding text explicitly, use `models.Document`,
105+
# it should unlock usage of server-side inference.
106+
102107
query_vector = await self._embedding_provider.embed_query(query)
103108
vector_name = self._embedding_provider.get_vector_name()
104109

105110
# Search in Qdrant
106-
search_results = await self._client.search(
111+
search_results = await self._client.query_points(
107112
collection_name=collection_name,
108-
query_vector=models.NamedVector(name=vector_name, vector=query_vector),
113+
query=query_vector,
114+
using=vector_name,
109115
limit=limit,
110116
)
111117

@@ -114,7 +120,7 @@ async def search(
114120
content=result.payload["document"],
115121
metadata=result.payload.get("metadata"),
116122
)
117-
for result in search_results
123+
for result in search_results.points
118124
]
119125

120126
async def _ensure_collection_exists(self, collection_name: str):

src/mcp_server_qdrant/server.py

Lines changed: 6 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,12 @@
1-
import json
2-
import logging
3-
from contextlib import asynccontextmanager
4-
from typing import AsyncIterator, List, Optional
5-
6-
from mcp.server import Server
7-
from mcp.server.fastmcp import Context, FastMCP
8-
9-
from mcp_server_qdrant.embeddings.factory import create_embedding_provider
10-
from mcp_server_qdrant.qdrant import Entry, Metadata, QdrantConnector
1+
from mcp_server_qdrant.mcp_server import QdrantMCPServer
112
from mcp_server_qdrant.settings import (
123
EmbeddingProviderSettings,
134
QdrantSettings,
145
ToolSettings,
156
)
167

17-
logger = logging.getLogger(__name__)
18-
19-
20-
@asynccontextmanager
21-
async def server_lifespan(server: Server) -> AsyncIterator[dict]: # noqa
22-
"""
23-
Context manager to handle the lifespan of the server.
24-
This is used to configure the embedding provider and Qdrant connector.
25-
All the configuration is now loaded from the environment variables.
26-
Settings handle that for us.
27-
"""
28-
try:
29-
# Embedding provider is created with a factory function so we can add
30-
# some more providers in the future. Currently, only FastEmbed is supported.
31-
embedding_provider_settings = EmbeddingProviderSettings()
32-
embedding_provider = create_embedding_provider(embedding_provider_settings)
33-
logger.info(
34-
f"Using embedding provider {embedding_provider_settings.provider_type} with "
35-
f"model {embedding_provider_settings.model_name}"
36-
)
37-
38-
qdrant_configuration = QdrantSettings()
39-
qdrant_connector = QdrantConnector(
40-
qdrant_configuration.location,
41-
qdrant_configuration.api_key,
42-
qdrant_configuration.collection_name,
43-
embedding_provider,
44-
qdrant_configuration.local_path,
45-
)
46-
logger.info(
47-
f"Connecting to Qdrant at {qdrant_configuration.get_qdrant_location()}"
48-
)
49-
50-
yield {
51-
"embedding_provider": embedding_provider,
52-
"qdrant_connector": qdrant_connector,
53-
}
54-
except Exception as e:
55-
logger.error(e)
56-
raise e
57-
finally:
58-
pass
59-
60-
61-
# FastMCP is an alternative interface for declaring the capabilities
62-
# of the server. Its API is based on FastAPI.
63-
mcp = FastMCP("mcp-server-qdrant", lifespan=server_lifespan)
64-
65-
# Load the tool settings from the env variables, if they are set,
66-
# or use the default values otherwise.
67-
tool_settings = ToolSettings()
68-
69-
70-
@mcp.tool(name="qdrant-store", description=tool_settings.tool_store_description)
71-
async def store(
72-
ctx: Context,
73-
information: str,
74-
# The `metadata` parameter is defined as non-optional, but it can be None.
75-
# If we set it to be optional, some of the MCP clients, like Cursor, cannot
76-
# handle the optional parameter correctly.
77-
metadata: Metadata = None,
78-
collection_name: Optional[str] = None,
79-
) -> str:
80-
"""
81-
Store some information in Qdrant.
82-
:param ctx: The context for the request.
83-
:param information: The information to store.
84-
:param metadata: JSON metadata to store with the information, optional.
85-
:param collection_name: The name of the collection to store the information in, optional. If not provided,
86-
the default collection is used.
87-
:return: A message indicating that the information was stored.
88-
"""
89-
await ctx.debug(f"Storing information {information} in Qdrant")
90-
qdrant_connector: QdrantConnector = ctx.request_context.lifespan_context[
91-
"qdrant_connector"
92-
]
93-
entry = Entry(content=information, metadata=metadata)
94-
await qdrant_connector.store(entry, collection_name=collection_name)
95-
if collection_name:
96-
return f"Remembered: {information} in collection {collection_name}"
97-
return f"Remembered: {information}"
98-
99-
100-
@mcp.tool(name="qdrant-find", description=tool_settings.tool_find_description)
101-
async def find(
102-
ctx: Context,
103-
query: str,
104-
collection_name: Optional[str] = None,
105-
limit: int = 10,
106-
) -> List[str]:
107-
"""
108-
Find memories in Qdrant.
109-
:param ctx: The context for the request.
110-
:param query: The query to use for the search.
111-
:param collection_name: The name of the collection to search in, optional. If not provided,
112-
the default collection is used.
113-
:param limit: The maximum number of entries to return, optional. Default is 10.
114-
:return: A list of entries found.
115-
"""
116-
await ctx.debug(f"Finding results for query {query}")
117-
if collection_name:
118-
await ctx.debug(f"Overriding the collection name with {collection_name}")
119-
qdrant_connector: QdrantConnector = ctx.request_context.lifespan_context[
120-
"qdrant_connector"
121-
]
122-
entries = await qdrant_connector.search(
123-
query, collection_name=collection_name, limit=limit
124-
)
125-
if not entries:
126-
return [f"No information found for the query '{query}'"]
127-
content = [
128-
f"Results for the query '{query}'",
129-
]
130-
for entry in entries:
131-
# Format the metadata as a JSON string and produce XML-like output
132-
entry_metadata = json.dumps(entry.metadata) if entry.metadata else ""
133-
content.append(
134-
f"<entry><content>{entry.content}</content><metadata>{entry_metadata}</metadata></entry>"
135-
)
136-
return content
8+
mcp = QdrantMCPServer(
9+
tool_settings=ToolSettings(),
10+
qdrant_settings=QdrantSettings(),
11+
embedding_provider_settings=EmbeddingProviderSettings(),
12+
)

0 commit comments

Comments
 (0)