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
This commit is contained in:
Andrey Vasnetsov
2025-04-07 12:44:02 +02:00
committed by GitHub
parent 7aad8ebb3c
commit 181be17142
7 changed files with 202 additions and 145 deletions

View File

@@ -25,15 +25,15 @@ It acts as a semantic memory layer on top of the Qdrant database.
- Input: - Input:
- `information` (string): Information to store - `information` (string): Information to store
- `metadata` (JSON): Optional metadata to store - `metadata` (JSON): Optional metadata to store
- `collection_name` (string): Name of the collection to store the information in, optional. If not provided, - `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name.
the default collection name will be used. If there is a default collection name, this field is not enabled.
- Returns: Confirmation message - Returns: Confirmation message
2. `qdrant-find` 2. `qdrant-find`
- Retrieve relevant information from the Qdrant database - Retrieve relevant information from the Qdrant database
- Input: - Input:
- `query` (string): Query to use for searching - `query` (string): Query to use for searching
- `collection_name` (string): Name of the collection to store the information in, optional. If not provided, - `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name.
the default collection name will be used. If there is a default collection name, this field is not enabled.
- Returns: Information stored in the Qdrant database as separate messages - Returns: Information stored in the Qdrant database as separate messages
## Environment Variables ## Environment Variables
@@ -44,7 +44,7 @@ The configuration of the server is done using environment variables:
|--------------------------|---------------------------------------------------------------------|-------------------------------------------------------------------| |--------------------------|---------------------------------------------------------------------|-------------------------------------------------------------------|
| `QDRANT_URL` | URL of the Qdrant server | None | | `QDRANT_URL` | URL of the Qdrant server | None |
| `QDRANT_API_KEY` | API key for the Qdrant server | None | | `QDRANT_API_KEY` | API key for the Qdrant server | None |
| `COLLECTION_NAME` | Name of the default collection to use. | *Required* | | `COLLECTION_NAME` | Name of the default collection to use. | None |
| `QDRANT_LOCAL_PATH` | Path to the local Qdrant database (alternative to `QDRANT_URL`) | None | | `QDRANT_LOCAL_PATH` | Path to the local Qdrant database (alternative to `QDRANT_URL`) | None |
| `EMBEDDING_PROVIDER` | Embedding provider to use (currently only "fastembed" is supported) | `fastembed` | | `EMBEDDING_PROVIDER` | Embedding provider to use (currently only "fastembed" is supported) | `fastembed` |
| `EMBEDDING_MODEL` | Name of the embedding model to use | `sentence-transformers/all-MiniLM-L6-v2` | | `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:
1. Use the `qdrant-store` tool to store code snippets with descriptions. 1. Use the `qdrant-store` tool to store code snippets with descriptions.
2. Use the `qdrant-find` tool to search for relevant code snippets using natural language. 2. Use the `qdrant-find` tool to search for relevant code snippets using natural language.
### Run MCP server in Development Mode
The MCP server can be run in development mode using the `mcp dev` command. This will start the server and open the MCP
inspector in your browser.
```shell
COLLECTION_NAME=mcp-dev mcp dev src/mcp_server_qdrant/server.py
```
## Contributing ## Contributing
If you have suggestions for how mcp-server-qdrant could be improved, or want to report a bug, open an issue! If you have suggestions for how mcp-server-qdrant could be improved, or want to report a bug, open an issue!

View File

@@ -0,0 +1,162 @@
import json
import logging
from typing import List
from mcp.server.fastmcp import Context, FastMCP
from mcp_server_qdrant.embeddings.factory import create_embedding_provider
from mcp_server_qdrant.qdrant import Entry, Metadata, QdrantConnector
from mcp_server_qdrant.settings import (
EmbeddingProviderSettings,
QdrantSettings,
ToolSettings,
)
logger = logging.getLogger(__name__)
# FastMCP is an alternative interface for declaring the capabilities
# of the server. Its API is based on FastAPI.
class QdrantMCPServer(FastMCP):
"""
A MCP server for Qdrant.
"""
def __init__(
self,
tool_settings: ToolSettings,
qdrant_settings: QdrantSettings,
embedding_provider_settings: EmbeddingProviderSettings,
name: str = "mcp-server-qdrant",
):
self.tool_settings = tool_settings
self.qdrant_settings = qdrant_settings
self.embedding_provider_settings = embedding_provider_settings
self.embedding_provider = create_embedding_provider(embedding_provider_settings)
self.qdrant_connector = QdrantConnector(
qdrant_settings.location,
qdrant_settings.api_key,
qdrant_settings.collection_name,
self.embedding_provider,
qdrant_settings.local_path,
)
super().__init__(name=name)
self.setup_tools()
def format_entry(self, entry: Entry) -> str:
"""
Feel free to override this method in your subclass to customize the format of the entry.
"""
entry_metadata = json.dumps(entry.metadata) if entry.metadata else ""
return f"<entry><content>{entry.content}</content><metadata>{entry_metadata}</metadata></entry>"
def setup_tools(self):
async def store(
ctx: Context,
information: str,
collection_name: str,
# The `metadata` parameter is defined as non-optional, but it can be None.
# If we set it to be optional, some of the MCP clients, like Cursor, cannot
# handle the optional parameter correctly.
metadata: Metadata = None,
) -> str:
"""
Store some information in Qdrant.
:param ctx: The context for the request.
:param information: The information to store.
:param metadata: JSON metadata to store with the information, optional.
:param collection_name: The name of the collection to store the information in, optional. If not provided,
the default collection is used.
:return: A message indicating that the information was stored.
"""
await ctx.debug(f"Storing information {information} in Qdrant")
entry = Entry(content=information, metadata=metadata)
await self.qdrant_connector.store(entry, collection_name=collection_name)
if collection_name:
return f"Remembered: {information} in collection {collection_name}"
return f"Remembered: {information}"
async def store_with_default_collection(
ctx: Context,
information: str,
metadata: Metadata = None,
) -> str:
return await store(
ctx, information, metadata, self.qdrant_settings.collection_name
)
async def find(
ctx: Context,
query: str,
collection_name: str,
) -> List[str]:
"""
Find memories in Qdrant.
:param ctx: The context for the request.
:param query: The query to use for the search.
:param collection_name: The name of the collection to search in, optional. If not provided,
the default collection is used.
:param limit: The maximum number of entries to return, optional. Default is 10.
:return: A list of entries found.
"""
await ctx.debug(f"Finding results for query {query}")
if collection_name:
await ctx.debug(
f"Overriding the collection name with {collection_name}"
)
entries = await self.qdrant_connector.search(
query,
collection_name=collection_name,
limit=self.qdrant_settings.search_limit,
)
if not entries:
return [f"No information found for the query '{query}'"]
content = [
f"Results for the query '{query}'",
]
for entry in entries:
content.append(self.format_entry(entry))
return content
async def find_with_default_collection(
ctx: Context,
query: str,
) -> List[str]:
return await find(ctx, query, self.qdrant_settings.collection_name)
# Register the tools depending on the configuration
if self.qdrant_settings.collection_name:
self.add_tool(
find_with_default_collection,
name="qdrant-find",
description=self.tool_settings.tool_find_description,
)
else:
self.add_tool(
find,
name="qdrant-find",
description=self.tool_settings.tool_find_description,
)
if not self.qdrant_settings.read_only:
# Those methods can modify the database
if self.qdrant_settings.collection_name:
self.add_tool(
store_with_default_collection,
name="qdrant-store",
description=self.tool_settings.tool_store_description,
)
else:
self.add_tool(
store,
name="qdrant-store",
description=self.tool_settings.tool_store_description,
)

View File

@@ -66,6 +66,8 @@ class QdrantConnector:
await self._ensure_collection_exists(collection_name) await self._ensure_collection_exists(collection_name)
# Embed the document # Embed the document
# ToDo: instead of embedding text explicitly, use `models.Document`,
# it should unlock usage of server-side inference.
embeddings = await self._embedding_provider.embed_documents([entry.content]) embeddings = await self._embedding_provider.embed_documents([entry.content])
# Add to Qdrant # Add to Qdrant
@@ -99,13 +101,17 @@ class QdrantConnector:
return [] return []
# Embed the query # Embed the query
# ToDo: instead of embedding text explicitly, use `models.Document`,
# it should unlock usage of server-side inference.
query_vector = await self._embedding_provider.embed_query(query) query_vector = await self._embedding_provider.embed_query(query)
vector_name = self._embedding_provider.get_vector_name() vector_name = self._embedding_provider.get_vector_name()
# Search in Qdrant # Search in Qdrant
search_results = await self._client.search( search_results = await self._client.query_points(
collection_name=collection_name, collection_name=collection_name,
query_vector=models.NamedVector(name=vector_name, vector=query_vector), query=query_vector,
using=vector_name,
limit=limit, limit=limit,
) )
@@ -114,7 +120,7 @@ class QdrantConnector:
content=result.payload["document"], content=result.payload["document"],
metadata=result.payload.get("metadata"), metadata=result.payload.get("metadata"),
) )
for result in search_results for result in search_results.points
] ]
async def _ensure_collection_exists(self, collection_name: str): async def _ensure_collection_exists(self, collection_name: str):

View File

@@ -1,136 +1,12 @@
import json from mcp_server_qdrant.mcp_server import QdrantMCPServer
import logging
from contextlib import asynccontextmanager
from typing import AsyncIterator, List, Optional
from mcp.server import Server
from mcp.server.fastmcp import Context, FastMCP
from mcp_server_qdrant.embeddings.factory import create_embedding_provider
from mcp_server_qdrant.qdrant import Entry, Metadata, QdrantConnector
from mcp_server_qdrant.settings import ( from mcp_server_qdrant.settings import (
EmbeddingProviderSettings, EmbeddingProviderSettings,
QdrantSettings, QdrantSettings,
ToolSettings, ToolSettings,
) )
logger = logging.getLogger(__name__) mcp = QdrantMCPServer(
tool_settings=ToolSettings(),
qdrant_settings=QdrantSettings(),
@asynccontextmanager embedding_provider_settings=EmbeddingProviderSettings(),
async def server_lifespan(server: Server) -> AsyncIterator[dict]: # noqa )
"""
Context manager to handle the lifespan of the server.
This is used to configure the embedding provider and Qdrant connector.
All the configuration is now loaded from the environment variables.
Settings handle that for us.
"""
try:
# Embedding provider is created with a factory function so we can add
# some more providers in the future. Currently, only FastEmbed is supported.
embedding_provider_settings = EmbeddingProviderSettings()
embedding_provider = create_embedding_provider(embedding_provider_settings)
logger.info(
f"Using embedding provider {embedding_provider_settings.provider_type} with "
f"model {embedding_provider_settings.model_name}"
)
qdrant_configuration = QdrantSettings()
qdrant_connector = QdrantConnector(
qdrant_configuration.location,
qdrant_configuration.api_key,
qdrant_configuration.collection_name,
embedding_provider,
qdrant_configuration.local_path,
)
logger.info(
f"Connecting to Qdrant at {qdrant_configuration.get_qdrant_location()}"
)
yield {
"embedding_provider": embedding_provider,
"qdrant_connector": qdrant_connector,
}
except Exception as e:
logger.error(e)
raise e
finally:
pass
# FastMCP is an alternative interface for declaring the capabilities
# of the server. Its API is based on FastAPI.
mcp = FastMCP("mcp-server-qdrant", lifespan=server_lifespan)
# Load the tool settings from the env variables, if they are set,
# or use the default values otherwise.
tool_settings = ToolSettings()
@mcp.tool(name="qdrant-store", description=tool_settings.tool_store_description)
async def store(
ctx: Context,
information: str,
# The `metadata` parameter is defined as non-optional, but it can be None.
# If we set it to be optional, some of the MCP clients, like Cursor, cannot
# handle the optional parameter correctly.
metadata: Metadata = None,
collection_name: Optional[str] = None,
) -> str:
"""
Store some information in Qdrant.
:param ctx: The context for the request.
:param information: The information to store.
:param metadata: JSON metadata to store with the information, optional.
:param collection_name: The name of the collection to store the information in, optional. If not provided,
the default collection is used.
:return: A message indicating that the information was stored.
"""
await ctx.debug(f"Storing information {information} in Qdrant")
qdrant_connector: QdrantConnector = ctx.request_context.lifespan_context[
"qdrant_connector"
]
entry = Entry(content=information, metadata=metadata)
await qdrant_connector.store(entry, collection_name=collection_name)
if collection_name:
return f"Remembered: {information} in collection {collection_name}"
return f"Remembered: {information}"
@mcp.tool(name="qdrant-find", description=tool_settings.tool_find_description)
async def find(
ctx: Context,
query: str,
collection_name: Optional[str] = None,
limit: int = 10,
) -> List[str]:
"""
Find memories in Qdrant.
:param ctx: The context for the request.
:param query: The query to use for the search.
:param collection_name: The name of the collection to search in, optional. If not provided,
the default collection is used.
:param limit: The maximum number of entries to return, optional. Default is 10.
:return: A list of entries found.
"""
await ctx.debug(f"Finding results for query {query}")
if collection_name:
await ctx.debug(f"Overriding the collection name with {collection_name}")
qdrant_connector: QdrantConnector = ctx.request_context.lifespan_context[
"qdrant_connector"
]
entries = await qdrant_connector.search(
query, collection_name=collection_name, limit=limit
)
if not entries:
return [f"No information found for the query '{query}'"]
content = [
f"Results for the query '{query}'",
]
for entry in entries:
# Format the metadata as a JSON string and produce XML-like output
entry_metadata = json.dumps(entry.metadata) if entry.metadata else ""
content.append(
f"<entry><content>{entry.content}</content><metadata>{entry_metadata}</metadata></entry>"
)
return content

View File

@@ -53,10 +53,16 @@ class QdrantSettings(BaseSettings):
location: Optional[str] = Field(default=None, validation_alias="QDRANT_URL") location: Optional[str] = Field(default=None, validation_alias="QDRANT_URL")
api_key: Optional[str] = Field(default=None, validation_alias="QDRANT_API_KEY") api_key: Optional[str] = Field(default=None, validation_alias="QDRANT_API_KEY")
collection_name: str = Field(validation_alias="COLLECTION_NAME") collection_name: Optional[str] = Field(
default=None, validation_alias="COLLECTION_NAME"
)
local_path: Optional[str] = Field( local_path: Optional[str] = Field(
default=None, validation_alias="QDRANT_LOCAL_PATH" default=None, validation_alias="QDRANT_LOCAL_PATH"
) )
search_limit: Optional[int] = Field(
default=None, validation_alias="QDRANT_SEARCH_LIMIT"
)
read_only: bool = Field(default=False, validation_alias="QDRANT_READ_ONLY")
def get_qdrant_location(self) -> str: def get_qdrant_location(self) -> str:
""" """

View File

@@ -53,7 +53,7 @@ class TestFastEmbedProviderIntegration:
# The embeddings should be identical for the same input # The embeddings should be identical for the same input
np.testing.assert_array_almost_equal(np.array(embedding), np.array(embedding2)) np.testing.assert_array_almost_equal(np.array(embedding), np.array(embedding2))
def test_get_vector_name(self): async def test_get_vector_name(self):
"""Test that the vector name is generated correctly.""" """Test that the vector name is generated correctly."""
provider = FastEmbedProvider("sentence-transformers/all-MiniLM-L6-v2") provider = FastEmbedProvider("sentence-transformers/all-MiniLM-L6-v2")
vector_name = provider.get_vector_name() vector_name = provider.get_vector_name()

View File

@@ -1,8 +1,6 @@
import os import os
from unittest.mock import patch from unittest.mock import patch
import pytest
from mcp_server_qdrant.embeddings.types import EmbeddingProviderType from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
from mcp_server_qdrant.settings import ( from mcp_server_qdrant.settings import (
DEFAULT_TOOL_FIND_DESCRIPTION, DEFAULT_TOOL_FIND_DESCRIPTION,
@@ -16,9 +14,9 @@ from mcp_server_qdrant.settings import (
class TestQdrantSettings: class TestQdrantSettings:
def test_default_values(self): def test_default_values(self):
"""Test that required fields raise errors when not provided.""" """Test that required fields raise errors when not provided."""
with pytest.raises(ValueError):
# Should raise error because required fields are missing # Should not raise error because there are no required fields
QdrantSettings() QdrantSettings()
@patch.dict( @patch.dict(
os.environ, os.environ,