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:
19
README.md
19
README.md
@@ -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!
|
||||||
|
|||||||
162
src/mcp_server_qdrant/mcp_server.py
Normal file
162
src/mcp_server_qdrant/mcp_server.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user