Skip to content
Draft
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
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,24 @@ Configuration is done via environment variables. The only command-line argument
| `QDRANT_API_KEY` | API key for the Qdrant server | None |
| `COLLECTION_NAME` | Name of the default collection to use. | 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 (`fastembed` or `openai`) | `fastembed` |
| `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) |
| `QDRANT_SEARCH_LIMIT` | Maximum number of results to return from search | `10` |
| `QDRANT_READ_ONLY` | Enable read-only mode (disables `qdrant-store` tool) | `false` |

The following variables only apply when `EMBEDDING_PROVIDER` is set to `openai`:

| Name | Description | Default Value |
|-----------------------------|---------------------------------------------------------------------------------|--------------------------------|
| `EMBEDDING_BASE_URL` | Base URL of the OpenAI-compatible API, including the `/v1` suffix | OpenAI's own API |
| `EMBEDDING_API_KEY` | API key for the embeddings API. Falls back to `OPENAI_API_KEY` | A placeholder, for local servers that do not check it |
| `EMBEDDING_VECTOR_SIZE` | Dimensionality of the embeddings. Detected from the API when not set | None |
| `EMBEDDING_VECTOR_NAME` | Name of the vector in the Qdrant collection | Derived from `EMBEDDING_MODEL` |
| `EMBEDDING_QUERY_PREFIX` | Prefix prepended to every query before embedding it | Empty |
| `EMBEDDING_DOCUMENT_PREFIX` | Prefix prepended to every document before embedding it | Empty |

### FastMCP Environment Variables

Since `mcp-server-qdrant` is based on FastMCP, it also supports all the FastMCP environment variables. The most
Expand Down Expand Up @@ -181,8 +192,24 @@ For local Qdrant mode:

This MCP server will automatically create a collection with the specified name if it doesn't exist.

By default, the server will use the `sentence-transformers/all-MiniLM-L6-v2` embedding model to encode memories.
For the time being, only [FastEmbed](https://qdrant.github.io/fastembed/) models are supported.
By default, the server will use the `sentence-transformers/all-MiniLM-L6-v2` embedding model to encode memories,
running locally through [FastEmbed](https://qdrant.github.io/fastembed/).

Alternatively, setting `EMBEDDING_PROVIDER=openai` calls out to any service exposing an OpenAI-compatible
`/v1/embeddings` endpoint. This covers OpenAI itself as well as locally hosted servers such as
[Ollama](https://ollama.com/), [vLLM](https://docs.vllm.ai/) or [LM Studio](https://lmstudio.ai/):

```bash
QDRANT_URL="http://localhost:6333" \
COLLECTION_NAME="my-collection" \
EMBEDDING_PROVIDER="openai" \
EMBEDDING_BASE_URL="http://localhost:11434/v1" \
EMBEDDING_MODEL="nomic-embed-text" \
uvx mcp-server-qdrant
```

Models that distinguish queries from documents can be configured with `EMBEDDING_QUERY_PREFIX` and
`EMBEDDING_DOCUMENT_PREFIX`.

## Support for other tools

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies = [
"qdrant-client>=1.12.0",
"pydantic>=2.10.6,<2.12.0",
"fastmcp==2.7.0",
"openai>=1.60.0",
]

[build-system]
Expand Down
12 changes: 12 additions & 0 deletions src/mcp_server_qdrant/embeddings/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,17 @@ def create_embedding_provider(settings: EmbeddingProviderSettings) -> EmbeddingP
from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider

return FastEmbedProvider(settings.model_name)
elif settings.provider_type == EmbeddingProviderType.OPENAI:
from mcp_server_qdrant.embeddings.openai import OpenAIProvider

return OpenAIProvider(
settings.model_name,
base_url=settings.base_url,
api_key=settings.api_key,
vector_size=settings.vector_size,
vector_name=settings.vector_name,
query_prefix=settings.query_prefix,
document_prefix=settings.document_prefix,
)
else:
raise ValueError(f"Unsupported embedding provider: {settings.provider_type}")
76 changes: 76 additions & 0 deletions src/mcp_server_qdrant/embeddings/openai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import os
import re

from openai import AsyncOpenAI, OpenAI

from mcp_server_qdrant.embeddings.base import EmbeddingProvider


class OpenAIProvider(EmbeddingProvider):
"""
OpenAI-compatible implementation of the embedding provider.
Works with the OpenAI API and with any server exposing an `/v1/embeddings` endpoint.
:param model_name: The name of the embedding model to use.
:param base_url: The base URL of the API, including the `/v1` suffix.
:param api_key: The API key to use. Falls back to `OPENAI_API_KEY`, then to a placeholder,
as local servers typically do not check it.
:param vector_size: The dimensionality of the embeddings. Detected from the API if not provided.
:param vector_name: The name of the vector in the Qdrant collection.
:param query_prefix: A string prepended to every query before embedding it.
:param document_prefix: A string prepended to every document before embedding it.
"""

def __init__(
self,
model_name: str,
base_url: str | None = None,
api_key: str | None = None,
vector_size: int | None = None,
vector_name: str | None = None,
query_prefix: str = "",
document_prefix: str = "",
):
self.model_name = model_name
self.base_url = base_url
self.api_key = api_key or os.environ.get("OPENAI_API_KEY") or "unset"
self.query_prefix = query_prefix
self.document_prefix = document_prefix
self._vector_size = vector_size
self._vector_name = vector_name
self._client = AsyncOpenAI(base_url=base_url, api_key=self.api_key)

async def embed_documents(self, documents: list[str]) -> list[list[float]]:
"""Embed a list of documents into vectors."""
response = await self._client.embeddings.create(
model=self.model_name,
input=[f"{self.document_prefix}{document}" for document in documents],
)
return [item.embedding for item in sorted(response.data, key=lambda d: d.index)]

async def embed_query(self, query: str) -> list[float]:
"""Embed a query into a vector."""
response = await self._client.embeddings.create(
model=self.model_name,
input=[f"{self.query_prefix}{query}"],
)
return response.data[0].embedding

def get_vector_name(self) -> str:
"""Return the name of the vector for the Qdrant collection."""
if self._vector_name is None:
model_name = self.model_name.split("/")[-1].lower()
self._vector_name = "openai-" + re.sub(
r"[^a-z0-9]+", "-", model_name
).strip("-")
return self._vector_name

def get_vector_size(self) -> int:
"""
Get the size of the vector for the Qdrant collection.
The API does not advertise it, so an unconfigured size is detected by embedding a probe document.
"""
if self._vector_size is None:
client = OpenAI(base_url=self.base_url, api_key=self.api_key)
response = client.embeddings.create(model=self.model_name, input=["probe"])
self._vector_size = len(response.data[0].embedding)
return self._vector_size
1 change: 1 addition & 0 deletions src/mcp_server_qdrant/embeddings/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@

class EmbeddingProviderType(Enum):
FASTEMBED = "fastembed"
OPENAI = "openai"
12 changes: 12 additions & 0 deletions src/mcp_server_qdrant/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ class EmbeddingProviderSettings(BaseSettings):
default="sentence-transformers/all-MiniLM-L6-v2",
validation_alias="EMBEDDING_MODEL",
)
base_url: str | None = Field(default=None, validation_alias="EMBEDDING_BASE_URL")
api_key: str | None = Field(default=None, validation_alias="EMBEDDING_API_KEY")
vector_size: int | None = Field(
default=None, validation_alias="EMBEDDING_VECTOR_SIZE"
)
vector_name: str | None = Field(
default=None, validation_alias="EMBEDDING_VECTOR_NAME"
)
query_prefix: str = Field(default="", validation_alias="EMBEDDING_QUERY_PREFIX")
document_prefix: str = Field(
default="", validation_alias="EMBEDDING_DOCUMENT_PREFIX"
)


class FilterableField(BaseModel):
Expand Down
109 changes: 109 additions & 0 deletions tests/test_openai_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from types import SimpleNamespace

import pytest

from mcp_server_qdrant.embeddings.openai import OpenAIProvider


class FakeEmbeddings:
"""Records the payloads it receives and replays canned embeddings."""

def __init__(self, vectors: list[list[float]]):
self._vectors = vectors
self.calls: list[dict] = []

async def create(self, *, model: str, input: list[str]):
self.calls.append({"model": model, "input": input})
return SimpleNamespace(
data=[
SimpleNamespace(index=index, embedding=vector)
for index, vector in enumerate(self._vectors[: len(input)])
]
)


def make_provider(vectors: list[list[float]], **kwargs) -> OpenAIProvider:
provider = OpenAIProvider("test-model", base_url="http://localhost/v1", **kwargs)
provider._client = SimpleNamespace(embeddings=FakeEmbeddings(vectors))
return provider


@pytest.mark.asyncio
class TestOpenAIProvider:
async def test_embed_documents(self):
"""Documents are embedded in a single request, preserving input order."""
provider = make_provider([[1.0, 0.0], [0.0, 1.0]])

embeddings = await provider.embed_documents(["first", "second"])

assert embeddings == [[1.0, 0.0], [0.0, 1.0]]
assert provider._client.embeddings.calls == [
{"model": "test-model", "input": ["first", "second"]}
]

async def test_embed_query(self):
"""A query is embedded on its own and returns a single vector."""
provider = make_provider([[1.0, 0.0]])

embedding = await provider.embed_query("a query")

assert embedding == [1.0, 0.0]
assert provider._client.embeddings.calls == [
{"model": "test-model", "input": ["a query"]}
]

async def test_out_of_order_response_is_sorted(self):
"""The API is only guaranteed to return an index, not a particular order."""
provider = make_provider([[1.0], [2.0]])
provider._client.embeddings.create = _reversed_create([[1.0], [2.0]])

assert await provider.embed_documents(["a", "b"]) == [[1.0], [2.0]]

async def test_prefixes_are_applied(self):
"""Asymmetric models need distinct query and document prefixes."""
provider = make_provider(
[[1.0]], query_prefix="Query: ", document_prefix="Passage: "
)

await provider.embed_documents(["doc"])
await provider.embed_query("qry")

assert [call["input"] for call in provider._client.embeddings.calls] == [
["Passage: doc"],
["Query: qry"],
]

async def test_no_prefixes_by_default(self):
"""Without configuration the text is sent through untouched."""
provider = make_provider([[1.0]])

await provider.embed_query("qry")

assert provider._client.embeddings.calls[0]["input"] == ["qry"]


class TestOpenAIProviderVectorMetadata:
def test_vector_name_is_derived_from_the_model(self):
provider = OpenAIProvider("Qwen/Qwen3-Embedding-8B")
assert provider.get_vector_name() == "openai-qwen3-embedding-8b"

def test_vector_name_can_be_overridden(self):
provider = OpenAIProvider("test-model", vector_name="custom")
assert provider.get_vector_name() == "custom"

def test_configured_vector_size_is_used_without_a_request(self):
"""A configured size must not trigger a probe request to the API."""
provider = OpenAIProvider("test-model", vector_size=1536)
assert provider.get_vector_size() == 1536


def _reversed_create(vectors: list[list[float]]):
async def create(*, model: str, input: list[str]):
return SimpleNamespace(
data=[
SimpleNamespace(index=index, embedding=vector)
for index, vector in reversed(list(enumerate(vectors[: len(input)])))
]
)

return create
33 changes: 33 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,39 @@ def test_custom_values(self, monkeypatch):
assert settings.provider_type == EmbeddingProviderType.FASTEMBED
assert settings.model_name == "custom_model"

def test_openai_provider_values(self, monkeypatch):
"""Test loading the OpenAI-compatible provider configuration."""
monkeypatch.setenv("EMBEDDING_PROVIDER", "openai")
monkeypatch.setenv("EMBEDDING_MODEL", "text-embedding-3-small")
monkeypatch.setenv("EMBEDDING_BASE_URL", "http://localhost:11434/v1")
monkeypatch.setenv("EMBEDDING_API_KEY", "test_api_key")
monkeypatch.setenv("EMBEDDING_VECTOR_SIZE", "1536")
monkeypatch.setenv("EMBEDDING_VECTOR_NAME", "custom_vector")
monkeypatch.setenv("EMBEDDING_QUERY_PREFIX", "Query: ")
monkeypatch.setenv("EMBEDDING_DOCUMENT_PREFIX", "Passage: ")

settings = EmbeddingProviderSettings()

assert settings.provider_type == EmbeddingProviderType.OPENAI
assert settings.model_name == "text-embedding-3-small"
assert settings.base_url == "http://localhost:11434/v1"
assert settings.api_key == "test_api_key"
assert settings.vector_size == 1536
assert settings.vector_name == "custom_vector"
assert settings.query_prefix == "Query: "
assert settings.document_prefix == "Passage: "

def test_openai_settings_are_optional(self, monkeypatch):
"""The OpenAI-specific settings must not affect the default provider."""
settings = EmbeddingProviderSettings()

assert settings.base_url is None
assert settings.api_key is None
assert settings.vector_size is None
assert settings.vector_name is None
assert settings.query_prefix == ""
assert settings.document_prefix == ""


class TestToolSettings:
def test_default_values(self):
Expand Down
Loading