Compare commits

...

13 Commits

Author SHA1 Message Date
Mr. Kutin
8bcc45ee14 Update README: document hybrid search and project tagging features
Some checks failed
pre-commit / main (push) Has been cancelled
Run Tests / Python 3.10 (push) Has been cancelled
Run Tests / Python 3.11 (push) Has been cancelled
Run Tests / Python 3.12 (push) Has been cancelled
Run Tests / Python 3.13 (push) Has been cancelled
Rewrite README to highlight the two fork-specific features:
- BM25 hybrid search (dense + sparse vectors with RRF)
- Automatic project tagging with metadata.project index

Also update the environment variables table with all current options.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:58:39 +03:00
Mr. Kutin
e13a8981e7 Add project tagging to store tool and metadata.project index
Some checks failed
pre-commit / main (push) Has been cancelled
Run Tests / Python 3.10 (push) Has been cancelled
Run Tests / Python 3.11 (push) Has been cancelled
Run Tests / Python 3.12 (push) Has been cancelled
Run Tests / Python 3.13 (push) Has been cancelled
- Add explicit `project` parameter to qdrant-store tool (default: "global")
- Auto-inject project name into metadata for every stored record
- Create keyword payload index on metadata.project for efficient filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 10:43:05 +03:00
Mr. Kutin
e9f0a1fa4a Add BM25 hybrid search (dense + sparse vectors with RRF)
Some checks failed
pre-commit / main (push) Has been cancelled
Run Tests / Python 3.10 (push) Has been cancelled
Run Tests / Python 3.11 (push) Has been cancelled
Run Tests / Python 3.12 (push) Has been cancelled
Run Tests / Python 3.13 (push) Has been cancelled
- Add SparseTextEmbedding("Qdrant/bm25") to FastEmbedProvider for BM25 tokenization
- Add sparse vector config (IDF modifier) to collection creation
- Store both dense and sparse vectors per document
- Use Qdrant prefetch + Reciprocal Rank Fusion for hybrid search
- Add HYBRID_SEARCH env var (default: false) for backward compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 10:27:52 +03:00
estebany-qd
e4ec69b2da ci: Pin all gh actions to commit SHAs (#120)
Some checks failed
pre-commit / main (push) Has been cancelled
Run Tests / Python 3.10 (push) Has been cancelled
Run Tests / Python 3.11 (push) Has been cancelled
Run Tests / Python 3.12 (push) Has been cancelled
Run Tests / Python 3.13 (push) Has been cancelled
2026-03-30 23:46:24 -05:00
Till Bungert
860ab93a96 bump version to 0.8.1 (#99) 2025-12-10 11:21:54 +01:00
Andrés Restrepo
20825bca92 deps: pin pydantic <2.12.0 to avoid compatibility issues (#97)
pydantic 2.12.0+ has breaking changes with fastmcp 2.8.0. Pin to compatible version range.
2025-12-10 10:30:06 +01:00
Till Bungert
8d6f388543 fix: return None if no results where found (#83) 2025-08-19 13:45:01 +02:00
Andrey Vasnetsov
59fca57369 allow specifying custom embedding provider (#82) 2025-08-11 12:38:24 +02:00
George
5a7237389e new: bump to v0.8.0 (#73) 2025-06-27 13:33:57 +03:00
Kacper Łukawski
598ed6fa72 Update the README to reflect the new default for FASTMCP_HOST (#71) 2025-06-27 10:58:33 +02:00
George
3fdb4c4b1b new: update fastmcp to 2.7.0 (#65) 2025-06-13 16:52:48 +04:00
George
28bf298a32 new: update type hints (#64)
* new: update type hints

* fix: do not pass location and path to qdrant client, and do not accept them together

* new: update settings tests

* fix: revert removal of local path
2025-06-12 00:55:07 +04:00
Andrey Vasnetsov
b657656363 Configurable filters (#58)
* add configurable filters

* hello to hr department

* rollback debug code

* add arbitrary filter

* dont consider fields without conditions

* in and except condition

* proper annotation types for optional and list fields

* fix types import

* skip non-required fields

* fix: fix match except condition, fix boolean filter

* fix: apply ruff

* fix: make condition optional in filterable field

* fix: do not set default value for required fields (#63)

* fix: do not set default value for required fields

* fix: temp fix fastmcp to <2.8.0 cause of the breaking changes in the api

* fix: add missing changes to pyproject.toml

* fix: downgrade fastmcp even further to <2.7.0

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
Co-authored-by: George <panchuk.george@outlook.com>
2025-06-11 16:19:18 +02:00
15 changed files with 2607 additions and 1342 deletions

View File

@@ -9,8 +9,8 @@ jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1
with:
python-version: 3.x
- uses: pre-commit/action@v3.0.1
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1

View File

@@ -24,10 +24,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@e9aba2c848f5ebd159c070c61ea2c4e2b122355e # v2.3.4
with:
python-version: '3.10.x'

View File

@@ -16,10 +16,10 @@ jobs:
name: Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1
with:
python-version: ${{ matrix.python-version }}

520
README.md
View File

@@ -1,144 +1,170 @@
# mcp-server-qdrant: A Qdrant MCP server
# mcp-server-qdrant: Hybrid Search Fork
[![smithery badge](https://smithery.ai/badge/mcp-server-qdrant)](https://smithery.ai/protocol/mcp-server-qdrant)
> Forked from [qdrant/mcp-server-qdrant](https://github.com/qdrant/mcp-server-qdrant) — the official MCP server for Qdrant.
> The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) is an open protocol that enables
> seamless integration between LLM applications and external data sources and tools. Whether you're building an
> AI-powered IDE, enhancing a chat interface, or creating custom AI workflows, MCP provides a standardized way to
> connect LLMs with the context they need.
An [MCP](https://modelcontextprotocol.io/introduction) server for [Qdrant](https://qdrant.tech/) vector search engine that acts as a **semantic memory layer** for LLM applications.
This repository is an example of how to create a MCP server for [Qdrant](https://qdrant.tech/), a vector search engine.
This fork adds two features on top of the upstream:
## Overview
1. **Hybrid Search** — combines dense (semantic) and sparse (BM25 keyword) vectors using Reciprocal Rank Fusion for significantly better recall
2. **Project Tagging** — automatic `project` metadata on every stored memory, with a payload index for efficient filtering
An official Model Context Protocol server for keeping and retrieving memories in the Qdrant vector search engine.
It acts as a semantic memory layer on top of the Qdrant database.
Everything else remains fully compatible with the upstream.
## Components
---
### Tools
## What's Different in This Fork
1. `qdrant-store`
- Store some information in the Qdrant database
- Input:
- `information` (string): Information to store
- `metadata` (JSON): Optional metadata to store
- `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name.
If there is a default collection name, this field is not enabled.
- Returns: Confirmation message
2. `qdrant-find`
- Retrieve relevant information from the Qdrant database
- Input:
- `query` (string): Query to use for searching
- `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name.
If there is a default collection name, this field is not enabled.
- Returns: Information stored in the Qdrant database as separate messages
### Hybrid Search (Dense + BM25 Sparse with RRF)
The upstream server uses **dense vectors only** (semantic similarity). This works well for paraphrased queries but can miss results when the user searches for exact terms, names, or identifiers.
This fork adds **BM25 sparse vectors** alongside the dense ones. At query time, both vector spaces are searched independently and results are fused using **Reciprocal Rank Fusion (RRF)** — a proven technique that combines rankings without requiring score calibration.
**How it works:**
```
Store: document → [dense embedding] + [BM25 sparse embedding] → Qdrant
Search: query → prefetch(dense, top-k) + prefetch(BM25, top-k) → RRF fusion → final results
```
- Dense vectors capture **semantic meaning** (synonyms, paraphrases, context)
- BM25 sparse vectors excel at **exact keyword matching** (names, IDs, error codes)
- RRF fusion gives you the best of both worlds
**Enable it** with a single environment variable:
```bash
HYBRID_SEARCH=true
```
> [!NOTE]
> Hybrid search uses the `Qdrant/bm25` model from [FastEmbed](https://qdrant.github.io/fastembed/) for sparse embeddings. The model is downloaded automatically on first use (~50 MB). The IDF modifier is applied to upweight rare terms in the corpus.
> [!IMPORTANT]
> Enabling hybrid search on an existing collection requires re-creating it, as the sparse vector configuration must be set at collection creation time. Back up your data before switching.
### Project Tagging
The `qdrant-store` tool now accepts a `project` parameter (default: `"global"`). This value is automatically injected into the metadata of every stored record and indexed as a keyword field for efficient filtering.
This is useful when multiple projects share the same Qdrant collection — you can tag memories with the project name and filter by it later.
```
qdrant-store(information="...", project="my-project")
→ metadata: {"project": "my-project", ...}
```
A payload index on `metadata.project` is created automatically when the collection is first set up.
---
## Tools
### `qdrant-store`
Store information in the Qdrant database.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `information` | string | yes | Text to store |
| `project` | string | no | Project name to tag this memory with. Default: `"global"`. Use the project name (e.g. `"my-app"`) for project-specific knowledge, or `"global"` for cross-project knowledge. |
| `metadata` | JSON | no | Extra metadata stored alongside the information |
| `collection_name` | string | depends | Collection name. Required if no default is configured. |
### `qdrant-find`
Retrieve relevant information from the Qdrant database.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | yes | What to search for |
| `collection_name` | string | depends | Collection name. Required if no default is configured. |
## Environment Variables
The configuration of the server is done using environment variables:
| Name | Description | Default Value |
|--------------------------|---------------------------------------------------------------------|-------------------------------------------------------------------|
| Name | Description | Default |
|------|-------------|---------|
| `QDRANT_URL` | URL of the Qdrant server | None |
| `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_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) |
Note: You cannot provide both `QDRANT_URL` and `QDRANT_LOCAL_PATH` at the same time.
| `QDRANT_LOCAL_PATH` | Path to local Qdrant database (alternative to `QDRANT_URL`) | None |
| `COLLECTION_NAME` | Default collection name | None |
| `EMBEDDING_PROVIDER` | Embedding provider (currently only `fastembed`) | `fastembed` |
| `EMBEDDING_MODEL` | Embedding model name | `sentence-transformers/all-MiniLM-L6-v2` |
| **`HYBRID_SEARCH`** | **Enable hybrid search (dense + BM25 sparse with RRF)** | **`false`** |
| `QDRANT_SEARCH_LIMIT` | Maximum number of results per search | `10` |
| `QDRANT_READ_ONLY` | Disable write operations (store tool) | `false` |
| `QDRANT_ALLOW_ARBITRARY_FILTER` | Allow arbitrary filter objects in find queries | `false` |
| `TOOL_STORE_DESCRIPTION` | Custom description for the store tool | See [`settings.py`](src/mcp_server_qdrant/settings.py) |
| `TOOL_FIND_DESCRIPTION` | Custom description for the find tool | See [`settings.py`](src/mcp_server_qdrant/settings.py) |
> [!IMPORTANT]
> Command-line arguments are not supported anymore! Please use environment variables for all configuration.
> You cannot provide both `QDRANT_URL` and `QDRANT_LOCAL_PATH` at the same time.
### FastMCP Environment Variables
Since `mcp-server-qdrant` is based on FastMCP, it also supports all the FastMCP environment variables. The most
important ones are listed below:
Since `mcp-server-qdrant` is based on FastMCP, it also supports all FastMCP environment variables:
| Environment Variable | Description | Default Value |
|---------------------------------------|-----------------------------------------------------------|---------------|
| Name | Description | Default |
|------|-------------|---------|
| `FASTMCP_DEBUG` | Enable debug mode | `false` |
| `FASTMCP_LOG_LEVEL` | Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | `INFO` |
| `FASTMCP_HOST` | Host address to bind the server to | `0.0.0.0` |
| `FASTMCP_LOG_LEVEL` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | `INFO` |
| `FASTMCP_HOST` | Host address to bind to | `127.0.0.1` |
| `FASTMCP_PORT` | Port to run the server on | `8000` |
| `FASTMCP_WARN_ON_DUPLICATE_RESOURCES` | Show warnings for duplicate resources | `true` |
| `FASTMCP_WARN_ON_DUPLICATE_TOOLS` | Show warnings for duplicate tools | `true` |
| `FASTMCP_WARN_ON_DUPLICATE_PROMPTS` | Show warnings for duplicate prompts | `true` |
| `FASTMCP_DEPENDENCIES` | List of dependencies to install in the server environment | `[]` |
## Installation
### Using uvx
When using [`uvx`](https://docs.astral.sh/uv/guides/tools/#running-tools) no specific installation is needed to directly run *mcp-server-qdrant*.
No installation needed with [`uvx`](https://docs.astral.sh/uv/guides/tools/#running-tools):
```shell
QDRANT_URL="http://localhost:6333" \
COLLECTION_NAME="my-collection" \
EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" \
HYBRID_SEARCH=true \
uvx mcp-server-qdrant
```
#### Transport Protocols
The server supports different transport protocols that can be specified using the `--transport` flag:
```shell
# SSE transport (for remote clients)
QDRANT_URL="http://localhost:6333" \
COLLECTION_NAME="my-collection" \
HYBRID_SEARCH=true \
uvx mcp-server-qdrant --transport sse
```
Supported transport protocols:
- `stdio` (default): Standard input/output transport, might only be used by local MCP clients
- `sse`: Server-Sent Events transport, perfect for remote clients
- `streamable-http`: Streamable HTTP transport, perfect for remote clients, more recent than SSE
The default transport is `stdio` if not specified.
When SSE transport is used, the server will listen on the specified port and wait for incoming connections. The default
port is 8000, however it can be changed using the `FASTMCP_PORT` environment variable.
```shell
QDRANT_URL="http://localhost:6333" \
COLLECTION_NAME="my-collection" \
FASTMCP_PORT=1234 \
uvx mcp-server-qdrant --transport sse
```
Supported transports:
- `stdio` (default) — for local MCP clients
- `sse` — Server-Sent Events, for remote clients
- `streamable-http` — streamable HTTP, newer alternative to SSE
### Using Docker
A Dockerfile is available for building and running the MCP server:
```bash
# Build the container
docker build -t mcp-server-qdrant .
# Run the container
docker run -p 8000:8000 \
-e FASTMCP_HOST="0.0.0.0" \
-e QDRANT_URL="http://your-qdrant-server:6333" \
-e QDRANT_API_KEY="your-api-key" \
-e COLLECTION_NAME="your-collection" \
-e HYBRID_SEARCH=true \
mcp-server-qdrant
```
### Installing via Smithery
To install Qdrant MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/protocol/mcp-server-qdrant):
```bash
npx @smithery/cli install mcp-server-qdrant --client claude
```
### Manual configuration of Claude Desktop
## Usage with MCP Clients
To use this server with the Claude Desktop app, add the following configuration to the "mcpServers" section of your
`claude_desktop_config.json`:
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
@@ -146,171 +172,64 @@ To use this server with the Claude Desktop app, add the following configuration
"command": "uvx",
"args": ["mcp-server-qdrant"],
"env": {
"QDRANT_URL": "https://xyz-example.eu-central.aws.cloud.qdrant.io:6333",
"QDRANT_URL": "https://your-qdrant-instance:6333",
"QDRANT_API_KEY": "your_api_key",
"COLLECTION_NAME": "your-collection-name",
"EMBEDDING_MODEL": "sentence-transformers/all-MiniLM-L6-v2"
"COLLECTION_NAME": "your-collection",
"EMBEDDING_MODEL": "sentence-transformers/all-MiniLM-L6-v2",
"HYBRID_SEARCH": "true"
}
}
}
```
For local Qdrant mode:
```json
{
"qdrant": {
"command": "uvx",
"args": ["mcp-server-qdrant"],
"env": {
"QDRANT_LOCAL_PATH": "/path/to/qdrant/database",
"COLLECTION_NAME": "your-collection-name",
"EMBEDDING_MODEL": "sentence-transformers/all-MiniLM-L6-v2"
}
}
}
```
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.
## Support for other tools
This MCP server can be used with any MCP-compatible client. For example, you can use it with
[Cursor](https://docs.cursor.com/context/model-context-protocol) and [VS Code](https://code.visualstudio.com/docs), which provide built-in support for the Model Context
Protocol.
### Using with Cursor/Windsurf
You can configure this MCP server to work as a code search tool for Cursor or Windsurf by customizing the tool
descriptions:
```bash
QDRANT_URL="http://localhost:6333" \
COLLECTION_NAME="code-snippets" \
TOOL_STORE_DESCRIPTION="Store reusable code snippets for later retrieval. \
The 'information' parameter should contain a natural language description of what the code does, \
while the actual code should be included in the 'metadata' parameter as a 'code' property. \
The value of 'metadata' is a Python dictionary with strings as keys. \
Use this whenever you generate some code snippet." \
TOOL_FIND_DESCRIPTION="Search for relevant code snippets based on natural language descriptions. \
The 'query' parameter should describe what you're looking for, \
and the tool will return the most relevant code snippets. \
Use this when you need to find existing code snippets for reuse or reference." \
uvx mcp-server-qdrant --transport sse # Enable SSE transport
```
In Cursor/Windsurf, you can then configure the MCP server in your settings by pointing to this running server using
SSE transport protocol. The description on how to add an MCP server to Cursor can be found in the [Cursor
documentation](https://docs.cursor.com/context/model-context-protocol#adding-an-mcp-server-to-cursor). If you are
running Cursor/Windsurf locally, you can use the following URL:
```
http://localhost:8000/sse
```
> [!TIP]
> We suggest SSE transport as a preferred way to connect Cursor/Windsurf to the MCP server, as it can support remote
> connections. That makes it easy to share the server with your team or use it in a cloud environment.
This configuration transforms the Qdrant MCP server into a specialized code search tool that can:
1. Store code snippets, documentation, and implementation details
2. Retrieve relevant code examples based on semantic search
3. Help developers find specific implementations or usage patterns
You can populate the database by storing natural language descriptions of code snippets (in the `information` parameter)
along with the actual code (in the `metadata.code` property), and then search for them using natural language queries
that describe what you're looking for.
> [!NOTE]
> The tool descriptions provided above are examples and may need to be customized for your specific use case. Consider
> adjusting the descriptions to better match your team's workflow and the specific types of code snippets you want to
> store and retrieve.
**If you have successfully installed the `mcp-server-qdrant`, but still can't get it to work with Cursor, please
consider creating the [Cursor rules](https://docs.cursor.com/context/rules-for-ai) so the MCP tools are always used when
the agent produces a new code snippet.** You can restrict the rules to only work for certain file types, to avoid using
the MCP server for the documentation or other types of content.
### Using with Claude Code
You can enhance Claude Code's capabilities by connecting it to this MCP server, enabling semantic search over your
existing codebase.
#### Setting up mcp-server-qdrant
1. Add the MCP server to Claude Code:
### Claude Code
```shell
# Add mcp-server-qdrant configured for code search
claude mcp add code-search \
claude mcp add qdrant-memory \
-e QDRANT_URL="http://localhost:6333" \
-e COLLECTION_NAME="code-repository" \
-e EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" \
-e TOOL_STORE_DESCRIPTION="Store code snippets with descriptions. The 'information' parameter should contain a natural language description of what the code does, while the actual code should be included in the 'metadata' parameter as a 'code' property." \
-e TOOL_FIND_DESCRIPTION="Search for relevant code snippets using natural language. The 'query' parameter should describe the functionality you're looking for." \
-e COLLECTION_NAME="my-memory" \
-e HYBRID_SEARCH="true" \
-- uvx mcp-server-qdrant
```
2. Verify the server was added:
Verify:
```shell
claude mcp list
```
#### Using Semantic Code Search in Claude Code
### Cursor / Windsurf
Tool descriptions, specified in `TOOL_STORE_DESCRIPTION` and `TOOL_FIND_DESCRIPTION`, guide Claude Code on how to use
the MCP server. The ones provided above are examples and may need to be customized for your specific use case. However,
Claude Code should be already able to:
Run the server with SSE transport and custom tool descriptions for code search:
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.
### 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 fastmcp dev src/mcp_server_qdrant/server.py
```bash
QDRANT_URL="http://localhost:6333" \
COLLECTION_NAME="code-snippets" \
HYBRID_SEARCH=true \
TOOL_STORE_DESCRIPTION="Store reusable code snippets for later retrieval. \
The 'information' parameter should contain a natural language description of what the code does, \
while the actual code should be included in the 'metadata' parameter as a 'code' property." \
TOOL_FIND_DESCRIPTION="Search for relevant code snippets based on natural language descriptions." \
uvx mcp-server-qdrant --transport sse
```
### Using with VS Code
Then point Cursor/Windsurf to `http://localhost:8000/sse`.
### VS Code
For one-click installation, click one of the install buttons below:
[![Install with UVX in VS Code](https://img.shields.io/badge/VS_Code-UVX-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D) [![Install with UVX in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-UVX-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D&quality=insiders)
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Docker-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-p%22%2C%228000%3A8000%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22QDRANT_URL%22%2C%22-e%22%2C%22QDRANT_API_KEY%22%2C%22-e%22%2C%22COLLECTION_NAME%22%2C%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Docker-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-p%22%2C%228000%3A8000%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22QDRANT_URL%22%2C%22-e%22%2C%22QDRANT_API_KEY%22%2C%22-e%22%2C%22COLLECTION_NAME%22%2C%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D&quality=insiders)
#### Manual Installation
Add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
Or add manually to VS Code settings (`Ctrl+Shift+P``Preferences: Open User Settings (JSON)`):
```json
{
"mcp": {
"inputs": [
{
"type": "promptString",
"id": "qdrantUrl",
"description": "Qdrant URL"
},
{
"type": "promptString",
"id": "qdrantApiKey",
"description": "Qdrant API Key",
"password": true
},
{
"type": "promptString",
"id": "collectionName",
"description": "Collection Name"
}
{"type": "promptString", "id": "qdrantUrl", "description": "Qdrant URL"},
{"type": "promptString", "id": "qdrantApiKey", "description": "Qdrant API Key", "password": true},
{"type": "promptString", "id": "collectionName", "description": "Collection Name"}
],
"servers": {
"qdrant": {
@@ -319,7 +238,8 @@ Add the following JSON block to your User Settings (JSON) file in VS Code. You c
"env": {
"QDRANT_URL": "${input:qdrantUrl}",
"QDRANT_API_KEY": "${input:qdrantApiKey}",
"COLLECTION_NAME": "${input:collectionName}"
"COLLECTION_NAME": "${input:collectionName}",
"HYBRID_SEARCH": "true"
}
}
}
@@ -327,154 +247,40 @@ Add the following JSON block to your User Settings (JSON) file in VS Code. You c
}
```
Or if you prefer using Docker, add this configuration instead:
## Development
```json
{
"mcp": {
"inputs": [
{
"type": "promptString",
"id": "qdrantUrl",
"description": "Qdrant URL"
},
{
"type": "promptString",
"id": "qdrantApiKey",
"description": "Qdrant API Key",
"password": true
},
{
"type": "promptString",
"id": "collectionName",
"description": "Collection Name"
}
],
"servers": {
"qdrant": {
"command": "docker",
"args": [
"run",
"-p", "8000:8000",
"-i",
"--rm",
"-e", "QDRANT_URL",
"-e", "QDRANT_API_KEY",
"-e", "COLLECTION_NAME",
"mcp-server-qdrant"
],
"env": {
"QDRANT_URL": "${input:qdrantUrl}",
"QDRANT_API_KEY": "${input:qdrantApiKey}",
"COLLECTION_NAME": "${input:collectionName}"
}
}
}
}
}
```
Alternatively, you can create a `.vscode/mcp.json` file in your workspace with the following content:
```json
{
"inputs": [
{
"type": "promptString",
"id": "qdrantUrl",
"description": "Qdrant URL"
},
{
"type": "promptString",
"id": "qdrantApiKey",
"description": "Qdrant API Key",
"password": true
},
{
"type": "promptString",
"id": "collectionName",
"description": "Collection Name"
}
],
"servers": {
"qdrant": {
"command": "uvx",
"args": ["mcp-server-qdrant"],
"env": {
"QDRANT_URL": "${input:qdrantUrl}",
"QDRANT_API_KEY": "${input:qdrantApiKey}",
"COLLECTION_NAME": "${input:collectionName}"
}
}
}
}
```
For workspace configuration with Docker, use this in `.vscode/mcp.json`:
```json
{
"inputs": [
{
"type": "promptString",
"id": "qdrantUrl",
"description": "Qdrant URL"
},
{
"type": "promptString",
"id": "qdrantApiKey",
"description": "Qdrant API Key",
"password": true
},
{
"type": "promptString",
"id": "collectionName",
"description": "Collection Name"
}
],
"servers": {
"qdrant": {
"command": "docker",
"args": [
"run",
"-p", "8000:8000",
"-i",
"--rm",
"-e", "QDRANT_URL",
"-e", "QDRANT_API_KEY",
"-e", "COLLECTION_NAME",
"mcp-server-qdrant"
],
"env": {
"QDRANT_URL": "${input:qdrantUrl}",
"QDRANT_API_KEY": "${input:qdrantApiKey}",
"COLLECTION_NAME": "${input:collectionName}"
}
}
}
}
```
## Contributing
If you have suggestions for how mcp-server-qdrant could be improved, or want to report a bug, open an issue!
We'd love all and any contributions.
### Testing `mcp-server-qdrant` locally
The [MCP inspector](https://github.com/modelcontextprotocol/inspector) is a developer tool for testing and debugging MCP
servers. It runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in
your browser to use the inspector.
Run in development mode with the MCP inspector:
```shell
QDRANT_URL=":memory:" COLLECTION_NAME="test" \
COLLECTION_NAME=mcp-dev HYBRID_SEARCH=true \
fastmcp dev src/mcp_server_qdrant/server.py
```
Once started, open your browser to http://localhost:5173 to access the inspector interface.
Open http://localhost:5173 to access the inspector.
## How Hybrid Search Works Under the Hood
When `HYBRID_SEARCH=true`:
**Storing:**
1. The document is embedded with the dense model (e.g. `all-MiniLM-L6-v2`) → semantic vector
2. The document is also embedded with `Qdrant/bm25` → sparse vector (term frequencies with IDF)
3. Both vectors are stored in the same Qdrant point
**Searching:**
1. The query is embedded with both models
2. Two independent prefetch queries run in parallel:
- Dense vector search (cosine similarity)
- BM25 sparse vector search (dot product with IDF weighting)
3. Results are fused using **Reciprocal Rank Fusion**: `score = 1/(k + rank_dense) + 1/(k + rank_sparse)`
4. Top-k fused results are returned
This approach is battle-tested in information retrieval and consistently outperforms either method alone, especially for queries that mix natural language with specific terms.
## Acknowledgments
This is a fork of [qdrant/mcp-server-qdrant](https://github.com/qdrant/mcp-server-qdrant). All credit for the original implementation goes to the Qdrant team.
## License
This MCP server is licensed under the Apache License 2.0. This means you are free to use, modify, and distribute the
software, subject to the terms and conditions of the Apache License 2.0. For more details, please see the LICENSE file
in the project repository.
Apache License 2.0 — see [LICENSE](LICENSE) for details.

View File

@@ -1,6 +1,6 @@
[project]
name = "mcp-server-qdrant"
version = "0.7.1"
version = "0.8.1"
description = "MCP server for retrieving context from a Qdrant vector database"
readme = "README.md"
requires-python = ">=3.10"
@@ -8,8 +8,8 @@ license = "Apache-2.0"
dependencies = [
"fastembed>=0.6.0",
"qdrant-client>=1.12.0",
"pydantic>=2.10.6",
"fastmcp>=2.5.1",
"pydantic>=2.10.6,<2.12.0",
"fastmcp==2.7.0",
]
[build-system]
@@ -18,6 +18,7 @@ build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = [
"ipdb>=0.13.13",
"isort>=6.0.1",
"mypy>=1.9.0",
"pre-commit>=4.1.0",

View File

@@ -0,0 +1,194 @@
from typing import Any
from qdrant_client import models
from mcp_server_qdrant.qdrant import ArbitraryFilter
from mcp_server_qdrant.settings import METADATA_PATH, FilterableField
def make_filter(
filterable_fields: dict[str, FilterableField], values: dict[str, Any]
) -> ArbitraryFilter:
must_conditions = []
must_not_conditions = []
for raw_field_name, field_value in values.items():
if raw_field_name not in filterable_fields:
raise ValueError(f"Field {raw_field_name} is not a filterable field")
field = filterable_fields[raw_field_name]
if field_value is None:
if field.required:
raise ValueError(f"Field {raw_field_name} is required")
else:
continue
field_name = f"{METADATA_PATH}.{raw_field_name}"
if field.field_type == "keyword":
if field.condition == "==":
must_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchValue(value=field_value)
)
)
elif field.condition == "!=":
must_not_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchValue(value=field_value)
)
)
elif field.condition == "any":
must_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchAny(any=field_value)
)
)
elif field.condition == "except":
must_conditions.append(
models.FieldCondition(
key=field_name,
match=models.MatchExcept(**{"except": field_value}),
)
)
elif field.condition is not None:
raise ValueError(
f"Invalid condition {field.condition} for keyword field {field_name}"
)
elif field.field_type == "integer":
if field.condition == "==":
must_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchValue(value=field_value)
)
)
elif field.condition == "!=":
must_not_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchValue(value=field_value)
)
)
elif field.condition == ">":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(gt=field_value)
)
)
elif field.condition == ">=":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(gte=field_value)
)
)
elif field.condition == "<":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(lt=field_value)
)
)
elif field.condition == "<=":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(lte=field_value)
)
)
elif field.condition == "any":
must_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchAny(any=field_value)
)
)
elif field.condition == "except":
must_conditions.append(
models.FieldCondition(
key=field_name,
match=models.MatchExcept(**{"except": field_value}),
)
)
elif field.condition is not None:
raise ValueError(
f"Invalid condition {field.condition} for integer field {field_name}"
)
elif field.field_type == "float":
# For float values, we only support range comparisons
if field.condition == ">":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(gt=field_value)
)
)
elif field.condition == ">=":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(gte=field_value)
)
)
elif field.condition == "<":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(lt=field_value)
)
)
elif field.condition == "<=":
must_conditions.append(
models.FieldCondition(
key=field_name, range=models.Range(lte=field_value)
)
)
elif field.condition is not None:
raise ValueError(
f"Invalid condition {field.condition} for float field {field_name}. "
"Only range comparisons (>, >=, <, <=) are supported for float values."
)
elif field.field_type == "boolean":
if field.condition == "==":
must_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchValue(value=field_value)
)
)
elif field.condition == "!=":
must_not_conditions.append(
models.FieldCondition(
key=field_name, match=models.MatchValue(value=field_value)
)
)
elif field.condition is not None:
raise ValueError(
f"Invalid condition {field.condition} for boolean field {field_name}"
)
else:
raise ValueError(
f"Unsupported field type {field.field_type} for field {field_name}"
)
return models.Filter(
must=must_conditions, must_not=must_not_conditions
).model_dump()
def make_indexes(
filterable_fields: dict[str, FilterableField],
) -> dict[str, models.PayloadSchemaType]:
indexes = {}
for field_name, field in filterable_fields.items():
if field.field_type == "keyword":
indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.KEYWORD
elif field.field_type == "integer":
indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.INTEGER
elif field.field_type == "float":
indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.FLOAT
elif field.field_type == "boolean":
indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.BOOL
else:
raise ValueError(
f"Unsupported field type {field.field_type} for field {field_name}"
)
return indexes

View File

@@ -0,0 +1,150 @@
import inspect
from functools import wraps
from typing import Annotated, Callable, Optional
from pydantic import Field
from mcp_server_qdrant.common.filters import make_filter
from mcp_server_qdrant.settings import FilterableField
def wrap_filters(
original_func: Callable, filterable_fields: dict[str, FilterableField]
) -> Callable:
"""
Wraps the original_func function: replaces `filter` parameter with multiple parameters defined by `filterable_fields`.
"""
sig = inspect.signature(original_func)
@wraps(original_func)
def wrapper(*args, **kwargs):
# Start with fixed values
filter_values = {}
for field_name in filterable_fields:
if field_name in kwargs:
filter_values[field_name] = kwargs.pop(field_name)
query_filter = make_filter(filterable_fields, filter_values)
return original_func(**kwargs, query_filter=query_filter)
# Replace `query_filter` signature with parameters from `filterable_fields`
param_names = []
for param_name in sig.parameters:
if param_name == "query_filter":
continue
param_names.append(param_name)
new_params = [sig.parameters[param_name] for param_name in param_names]
required_new_params = []
optional_new_params = []
# Create a new signature parameters from `filterable_fields`
for field in filterable_fields.values():
field_name = field.name
field_type: type
if field.field_type == "keyword":
field_type = str
elif field.field_type == "integer":
field_type = int
elif field.field_type == "float":
field_type = float
elif field.field_type == "boolean":
field_type = bool
else:
raise ValueError(f"Unsupported field type: {field.field_type}")
if field.condition in {"any", "except"}:
if field_type not in {str, int}:
raise ValueError(
f'Only "keyword" and "integer" types are supported for "{field.condition}" condition'
)
field_type = list[field_type] # type: ignore
if field.required:
annotation = Annotated[field_type, Field(description=field.description)] # type: ignore
parameter = inspect.Parameter(
name=field_name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=annotation,
)
required_new_params.append(parameter)
else:
annotation = Annotated[ # type: ignore
Optional[field_type], Field(description=field.description)
]
parameter = inspect.Parameter(
name=field_name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=None,
annotation=annotation,
)
optional_new_params.append(parameter)
new_params.extend(required_new_params)
new_params.extend(optional_new_params)
# Set the new __signature__ for introspection
new_signature = sig.replace(parameters=new_params)
wrapper.__signature__ = new_signature # type: ignore
# Set the new __annotations__ for introspection
new_annotations = {}
for param in new_signature.parameters.values():
if param.annotation != inspect.Parameter.empty:
new_annotations[param.name] = param.annotation
# Add return type annotation if it exists
if new_signature.return_annotation != inspect.Parameter.empty:
new_annotations["return"] = new_signature.return_annotation
wrapper.__annotations__ = new_annotations
return wrapper
if __name__ == "__main__":
from pydantic._internal._typing_extra import get_function_type_hints
from qdrant_client import models
def find(
query: Annotated[str, Field(description="What to search for")],
collection_name: Annotated[
str, Field(description="The collection to search in")
],
query_filter: Optional[models.Filter] = None,
) -> list[str]:
print("query", query)
print("collection_name", collection_name)
print("query_filter", query_filter)
return ["mypy rules"]
wrapped_find = wrap_filters(
find,
{
"color": FilterableField(
name="color",
description="The color of the object",
field_type="keyword",
condition="==",
),
"size": FilterableField(
name="size",
description="The size of the object",
field_type="keyword",
condition="==",
required=True,
),
},
)
wrapped_find(query="dress", collection_name="test", color="red")
print("get_function_type_hints(find)", get_function_type_hints(find))
print(
"get_function_type_hints(wrapped_find)", get_function_type_hints(wrapped_find)
)

View File

@@ -1,17 +1,25 @@
from abc import ABC, abstractmethod
from typing import List
from dataclasses import dataclass
@dataclass
class SparseVector:
"""A sparse vector representation with indices and values."""
indices: list[int]
values: list[float]
class EmbeddingProvider(ABC):
"""Abstract base class for embedding providers."""
@abstractmethod
async def embed_documents(self, documents: List[str]) -> List[List[float]]:
async def embed_documents(self, documents: list[str]) -> list[list[float]]:
"""Embed a list of documents into vectors."""
pass
@abstractmethod
async def embed_query(self, query: str) -> List[float]:
async def embed_query(self, query: str) -> list[float]:
"""Embed a query into a vector."""
pass
@@ -24,3 +32,15 @@ class EmbeddingProvider(ABC):
def get_vector_size(self) -> int:
"""Get the size of the vector for the Qdrant collection."""
pass
def supports_sparse(self) -> bool:
"""Whether this provider supports sparse (BM25) embeddings."""
return False
async def embed_documents_sparse(self, documents: list[str]) -> list[SparseVector]:
"""Embed documents into sparse vectors. Override if supports_sparse() is True."""
raise NotImplementedError
async def embed_query_sparse(self, query: str) -> SparseVector:
"""Embed a query into a sparse vector. Override if supports_sparse() is True."""
raise NotImplementedError

View File

@@ -3,15 +3,18 @@ from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
from mcp_server_qdrant.settings import EmbeddingProviderSettings
def create_embedding_provider(settings: EmbeddingProviderSettings) -> EmbeddingProvider:
def create_embedding_provider(
settings: EmbeddingProviderSettings, enable_sparse: bool = False
) -> EmbeddingProvider:
"""
Create an embedding provider based on the specified type.
:param settings: The settings for the embedding provider.
:param enable_sparse: Whether to enable sparse (BM25) embeddings.
:return: An instance of the specified embedding provider.
"""
if settings.provider_type == EmbeddingProviderType.FASTEMBED:
from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider
return FastEmbedProvider(settings.model_name)
return FastEmbedProvider(settings.model_name, enable_sparse=enable_sparse)
else:
raise ValueError(f"Unsupported embedding provider: {settings.provider_type}")

View File

@@ -1,40 +1,70 @@
import asyncio
from typing import List
from fastembed import TextEmbedding
from fastembed import SparseTextEmbedding, TextEmbedding
from fastembed.common.model_description import DenseModelDescription
from mcp_server_qdrant.embeddings.base import EmbeddingProvider
from mcp_server_qdrant.embeddings.base import EmbeddingProvider, SparseVector
class FastEmbedProvider(EmbeddingProvider):
"""
FastEmbed implementation of the embedding provider.
:param model_name: The name of the FastEmbed model to use.
:param enable_sparse: Whether to enable BM25 sparse embeddings for hybrid search.
"""
def __init__(self, model_name: str):
def __init__(self, model_name: str, enable_sparse: bool = False):
self.model_name = model_name
self.embedding_model = TextEmbedding(model_name)
self._enable_sparse = enable_sparse
self._sparse_model = None
if enable_sparse:
self._sparse_model = SparseTextEmbedding("Qdrant/bm25")
async def embed_documents(self, documents: List[str]) -> List[List[float]]:
def supports_sparse(self) -> bool:
return self._enable_sparse and self._sparse_model is not None
async def embed_documents(self, documents: list[str]) -> list[list[float]]:
"""Embed a list of documents into vectors."""
# Run in a thread pool since FastEmbed is synchronous
loop = asyncio.get_event_loop()
embeddings = await loop.run_in_executor(
None, lambda: list(self.embedding_model.passage_embed(documents))
)
return [embedding.tolist() for embedding in embeddings]
async def embed_query(self, query: str) -> List[float]:
async def embed_query(self, query: str) -> list[float]:
"""Embed a query into a vector."""
# Run in a thread pool since FastEmbed is synchronous
loop = asyncio.get_event_loop()
embeddings = await loop.run_in_executor(
None, lambda: list(self.embedding_model.query_embed([query]))
)
return embeddings[0].tolist()
async def embed_documents_sparse(self, documents: list[str]) -> list[SparseVector]:
"""Embed documents into BM25 sparse vectors."""
loop = asyncio.get_event_loop()
results = await loop.run_in_executor(
None, lambda: list(self._sparse_model.passage_embed(documents))
)
return [
SparseVector(
indices=r.indices.tolist(),
values=r.values.tolist(),
)
for r in results
]
async def embed_query_sparse(self, query: str) -> SparseVector:
"""Embed a query into a BM25 sparse vector."""
loop = asyncio.get_event_loop()
results = await loop.run_in_executor(
None, lambda: list(self._sparse_model.query_embed([query]))
)
return SparseVector(
indices=results[0].indices.tolist(),
values=results[0].values.tolist(),
)
def get_vector_name(self) -> str:
"""
Return the name of the vector for the Qdrant collection.

View File

@@ -1,12 +1,17 @@
import json
import logging
from typing import Any, List, Optional
from typing import Annotated, Any, Optional
from fastmcp import Context, FastMCP
from pydantic import Field
from qdrant_client import models
from mcp_server_qdrant.common.filters import make_indexes
from mcp_server_qdrant.common.func_tools import make_partial_function
from mcp_server_qdrant.common.wrap_filters import wrap_filters
from mcp_server_qdrant.embeddings.base import EmbeddingProvider
from mcp_server_qdrant.embeddings.factory import create_embedding_provider
from mcp_server_qdrant.qdrant import Entry, Metadata, QdrantConnector
from mcp_server_qdrant.qdrant import ArbitraryFilter, Entry, Metadata, QdrantConnector
from mcp_server_qdrant.settings import (
EmbeddingProviderSettings,
QdrantSettings,
@@ -27,22 +32,48 @@ class QdrantMCPServer(FastMCP):
self,
tool_settings: ToolSettings,
qdrant_settings: QdrantSettings,
embedding_provider_settings: EmbeddingProviderSettings,
embedding_provider_settings: Optional[EmbeddingProviderSettings] = None,
embedding_provider: Optional[EmbeddingProvider] = None,
name: str = "mcp-server-qdrant",
instructions: str | None = None,
**settings: Any,
):
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)
if embedding_provider_settings and embedding_provider:
raise ValueError(
"Cannot provide both embedding_provider_settings and embedding_provider"
)
if not embedding_provider_settings and not embedding_provider:
raise ValueError(
"Must provide either embedding_provider_settings or embedding_provider"
)
self.embedding_provider_settings: Optional[EmbeddingProviderSettings] = None
self.embedding_provider: Optional[EmbeddingProvider] = None
if embedding_provider_settings:
self.embedding_provider_settings = embedding_provider_settings
self.embedding_provider = create_embedding_provider(
embedding_provider_settings,
enable_sparse=qdrant_settings.hybrid_search,
)
else:
self.embedding_provider_settings = None
self.embedding_provider = embedding_provider
assert self.embedding_provider is not None, "Embedding provider is required"
self.qdrant_connector = QdrantConnector(
qdrant_settings.location,
qdrant_settings.api_key,
qdrant_settings.collection_name,
self.embedding_provider,
qdrant_settings.local_path,
make_indexes(qdrant_settings.filterable_fields_dict()),
hybrid_search=qdrant_settings.hybrid_search,
)
super().__init__(name=name, instructions=instructions, **settings)
@@ -63,23 +94,42 @@ class QdrantMCPServer(FastMCP):
async def store(
ctx: Context,
information: str,
collection_name: str,
information: Annotated[str, Field(description="Text to store")],
collection_name: Annotated[
str, Field(description="The collection to store the information in")
],
project: Annotated[
str,
Field(
description="Project name, e.g. devops, stereo-hysteria, voice-assistant. "
"Use 'global' for cross-project knowledge (servers, network, user preferences)."
),
] = "global",
# 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: Optional[Metadata] = None, # type: ignore
metadata: Annotated[
Metadata | None,
Field(
description="Extra metadata stored along with memorised information. Any json is accepted."
),
] = None,
) -> str:
"""
Store some information in Qdrant.
:param ctx: The context for the request.
:param information: The information to store.
:param project: The project name to tag this memory with.
: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")
await ctx.debug(f"Storing information {information} in Qdrant (project={project})")
if metadata is None:
metadata = {}
metadata["project"] = project
entry = Entry(content=information, metadata=metadata)
@@ -90,30 +140,37 @@ class QdrantMCPServer(FastMCP):
async def find(
ctx: Context,
query: str,
collection_name: str,
) -> List[str]:
query: Annotated[str, Field(description="What to search for")],
collection_name: Annotated[
str, Field(description="The collection to search in")
],
query_filter: ArbitraryFilter | None = None,
) -> list[str] | None:
"""
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.
:return: A list of entries found.
:param query_filter: The filter to apply to the query.
:return: A list of entries found or None.
"""
# Log query_filter
await ctx.debug(f"Query filter: {query_filter}")
query_filter = models.Filter(**query_filter) if query_filter else None
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,
query_filter=query_filter,
)
if not entries:
return [f"No information found for the query '{query}'"]
return None
content = [
f"Results for the query '{query}'",
]
@@ -124,6 +181,15 @@ class QdrantMCPServer(FastMCP):
find_foo = find
store_foo = store
filterable_conditions = (
self.qdrant_settings.filterable_fields_dict_with_conditions()
)
if len(filterable_conditions) > 0:
find_foo = wrap_filters(find_foo, filterable_conditions)
elif not self.qdrant_settings.allow_arbitrary_filter:
find_foo = make_partial_function(find_foo, {"query_filter": None})
if self.qdrant_settings.collection_name:
find_foo = make_partial_function(
find_foo, {"collection_name": self.qdrant_settings.collection_name}
@@ -132,7 +198,7 @@ class QdrantMCPServer(FastMCP):
store_foo, {"collection_name": self.qdrant_settings.collection_name}
)
self.add_tool(
self.tool(
find_foo,
name="qdrant-find",
description=self.tool_settings.tool_find_description,
@@ -140,7 +206,7 @@ class QdrantMCPServer(FastMCP):
if not self.qdrant_settings.read_only:
# Those methods can modify the database
self.add_tool(
self.tool(
store_foo,
name="qdrant-store",
description=self.tool_settings.tool_store_description,

View File

@@ -1,15 +1,17 @@
import logging
import uuid
from typing import Any, Dict, Optional
from typing import Any
from pydantic import BaseModel
from qdrant_client import AsyncQdrantClient, models
from mcp_server_qdrant.embeddings.base import EmbeddingProvider
from mcp_server_qdrant.settings import METADATA_PATH
logger = logging.getLogger(__name__)
Metadata = Dict[str, Any]
Metadata = dict[str, Any]
ArbitraryFilter = dict[str, Any]
class Entry(BaseModel):
@@ -18,7 +20,10 @@ class Entry(BaseModel):
"""
content: str
metadata: Optional[Metadata] = None
metadata: Metadata | None = None
SPARSE_VECTOR_NAME = "bm25"
class QdrantConnector:
@@ -30,23 +35,30 @@ class QdrantConnector:
the collection name to be provided.
:param embedding_provider: The embedding provider to use.
:param qdrant_local_path: The path to the storage directory for the Qdrant client, if local mode is used.
:param hybrid_search: Whether to enable hybrid search (dense + BM25 sparse vectors with RRF).
"""
def __init__(
self,
qdrant_url: Optional[str],
qdrant_api_key: Optional[str],
collection_name: Optional[str],
qdrant_url: str | None,
qdrant_api_key: str | None,
collection_name: str | None,
embedding_provider: EmbeddingProvider,
qdrant_local_path: Optional[str] = None,
qdrant_local_path: str | None = None,
field_indexes: dict[str, models.PayloadSchemaType] | None = None,
hybrid_search: bool = False,
):
self._qdrant_url = qdrant_url.rstrip("/") if qdrant_url else None
self._qdrant_api_key = qdrant_api_key
self._default_collection_name = collection_name
self._embedding_provider = embedding_provider
self._hybrid_search = hybrid_search and embedding_provider.supports_sparse()
self._client = AsyncQdrantClient(
location=qdrant_url, api_key=qdrant_api_key, path=qdrant_local_path
)
self._field_indexes = field_indexes
if self._hybrid_search:
logger.info("Hybrid search enabled (dense + BM25 sparse vectors with RRF)")
async def get_collection_names(self) -> list[str]:
"""
@@ -56,7 +68,7 @@ class QdrantConnector:
response = await self._client.get_collections()
return [collection.name for collection in response.collections]
async def store(self, entry: Entry, *, collection_name: Optional[str] = None):
async def store(self, entry: Entry, *, collection_name: str | None = None):
"""
Store some information in the Qdrant collection, along with the specified metadata.
:param entry: The entry to store in the Qdrant collection.
@@ -68,26 +80,42 @@ class QdrantConnector:
await self._ensure_collection_exists(collection_name)
# 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])
# Add to Qdrant
# Build vector dict
vector_name = self._embedding_provider.get_vector_name()
payload = {"document": entry.content, "metadata": entry.metadata}
vector_data: dict = {vector_name: embeddings[0]}
# Add sparse vector if hybrid search is enabled
if self._hybrid_search:
sparse_embeddings = await self._embedding_provider.embed_documents_sparse(
[entry.content]
)
sparse = sparse_embeddings[0]
vector_data[SPARSE_VECTOR_NAME] = models.SparseVector(
indices=sparse.indices, values=sparse.values
)
# Add to Qdrant
payload = {"document": entry.content, METADATA_PATH: entry.metadata}
await self._client.upsert(
collection_name=collection_name,
points=[
models.PointStruct(
id=uuid.uuid4().hex,
vector={vector_name: embeddings[0]},
vector=vector_data,
payload=payload,
)
],
)
async def search(
self, query: str, *, collection_name: Optional[str] = None, limit: int = 10
self,
query: str,
*,
collection_name: str | None = None,
limit: int = 10,
query_filter: models.Filter | None = None,
) -> list[Entry]:
"""
Find points in the Qdrant collection. If there are no entries found, an empty list is returned.
@@ -95,6 +123,8 @@ class QdrantConnector:
: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.
:param query_filter: The filter to apply to the query, if any.
:return: A list of entries found.
"""
collection_name = collection_name or self._default_collection_name
@@ -102,19 +132,42 @@ class QdrantConnector:
if not collection_exists:
return []
# 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)
vector_name = self._embedding_provider.get_vector_name()
# Search in Qdrant
# Hybrid search: prefetch dense + sparse, fuse with RRF
if self._hybrid_search:
sparse_vector = await self._embedding_provider.embed_query_sparse(query)
search_results = await self._client.query_points(
collection_name=collection_name,
prefetch=[
models.Prefetch(
query=query_vector,
using=vector_name,
limit=limit,
filter=query_filter,
),
models.Prefetch(
query=models.SparseVector(
indices=sparse_vector.indices,
values=sparse_vector.values,
),
using=SPARSE_VECTOR_NAME,
limit=limit,
filter=query_filter,
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=limit,
)
else:
# Dense-only search (original behavior)
search_results = await self._client.query_points(
collection_name=collection_name,
query=query_vector,
using=vector_name,
limit=limit,
query_filter=query_filter,
)
return [
@@ -137,6 +190,16 @@ class QdrantConnector:
# Use the vector name as defined in the embedding provider
vector_name = self._embedding_provider.get_vector_name()
# Sparse vectors config for hybrid search (BM25)
sparse_config = None
if self._hybrid_search:
sparse_config = {
SPARSE_VECTOR_NAME: models.SparseVectorParams(
modifier=models.Modifier.IDF,
)
}
await self._client.create_collection(
collection_name=collection_name,
vectors_config={
@@ -145,4 +208,21 @@ class QdrantConnector:
distance=models.Distance.COSINE,
)
},
sparse_vectors_config=sparse_config,
)
# Always index metadata.project for efficient filtering
await self._client.create_payload_index(
collection_name=collection_name,
field_name="metadata.project",
field_schema=models.PayloadSchemaType.KEYWORD,
)
# Create payload indexes if configured
if self._field_indexes:
for field_name, field_type in self._field_indexes.items():
await self._client.create_payload_index(
collection_name=collection_name,
field_name=field_name,
field_schema=field_type,
)

View File

@@ -1,6 +1,6 @@
from typing import Optional
from typing import Literal
from pydantic import Field
from pydantic import BaseModel, Field, model_validator
from pydantic_settings import BaseSettings
from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
@@ -15,6 +15,8 @@ DEFAULT_TOOL_FIND_DESCRIPTION = (
" - Get some personal information about the user"
)
METADATA_PATH = "metadata"
class ToolSettings(BaseSettings):
"""
@@ -46,18 +48,69 @@ class EmbeddingProviderSettings(BaseSettings):
)
class FilterableField(BaseModel):
name: str = Field(description="The name of the field payload field to filter on")
description: str = Field(
description="A description for the field used in the tool description"
)
field_type: Literal["keyword", "integer", "float", "boolean"] = Field(
description="The type of the field"
)
condition: Literal["==", "!=", ">", ">=", "<", "<=", "any", "except"] | None = (
Field(
default=None,
description=(
"The condition to use for the filter. If not provided, the field will be indexed, but no "
"filter argument will be exposed to MCP tool."
),
)
)
required: bool = Field(
default=False,
description="Whether the field is required for the filter.",
)
class QdrantSettings(BaseSettings):
"""
Configuration for the Qdrant connector.
"""
location: Optional[str] = Field(default=None, validation_alias="QDRANT_URL")
api_key: Optional[str] = Field(default=None, validation_alias="QDRANT_API_KEY")
collection_name: Optional[str] = Field(
location: str | None = Field(default=None, validation_alias="QDRANT_URL")
api_key: str | None = Field(default=None, validation_alias="QDRANT_API_KEY")
hybrid_search: bool = Field(default=False, validation_alias="HYBRID_SEARCH")
collection_name: str | None = Field(
default=None, validation_alias="COLLECTION_NAME"
)
local_path: Optional[str] = Field(
default=None, validation_alias="QDRANT_LOCAL_PATH"
)
local_path: str | None = Field(default=None, validation_alias="QDRANT_LOCAL_PATH")
search_limit: int = Field(default=10, validation_alias="QDRANT_SEARCH_LIMIT")
read_only: bool = Field(default=False, validation_alias="QDRANT_READ_ONLY")
filterable_fields: list[FilterableField] | None = Field(default=None)
allow_arbitrary_filter: bool = Field(
default=False, validation_alias="QDRANT_ALLOW_ARBITRARY_FILTER"
)
def filterable_fields_dict(self) -> dict[str, FilterableField]:
if self.filterable_fields is None:
return {}
return {field.name: field for field in self.filterable_fields}
def filterable_fields_dict_with_conditions(self) -> dict[str, FilterableField]:
if self.filterable_fields is None:
return {}
return {
field.name: field
for field in self.filterable_fields
if field.condition is not None
}
@model_validator(mode="after")
def check_local_path_conflict(self) -> "QdrantSettings":
if self.local_path:
if self.location is not None or self.api_key is not None:
raise ValueError(
"If 'local_path' is set, 'location' and 'api_key' must be None."
)
return self

View File

@@ -1,5 +1,4 @@
import os
from unittest.mock import patch
import pytest
from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
from mcp_server_qdrant.settings import (
@@ -18,34 +17,51 @@ class TestQdrantSettings:
# Should not raise error because there are no required fields
QdrantSettings()
@patch.dict(
os.environ,
{"QDRANT_URL": "http://localhost:6333", "COLLECTION_NAME": "test_collection"},
)
def test_minimal_config(self):
def test_minimal_config(self, monkeypatch):
"""Test loading minimal configuration from environment variables."""
monkeypatch.setenv("QDRANT_URL", "http://localhost:6333")
monkeypatch.setenv("COLLECTION_NAME", "test_collection")
settings = QdrantSettings()
assert settings.location == "http://localhost:6333"
assert settings.collection_name == "test_collection"
assert settings.api_key is None
assert settings.local_path is None
@patch.dict(
os.environ,
{
"QDRANT_URL": "http://qdrant.example.com:6333",
"QDRANT_API_KEY": "test_api_key",
"COLLECTION_NAME": "my_memories",
"QDRANT_LOCAL_PATH": "/tmp/qdrant",
},
)
def test_full_config(self):
def test_full_config(self, monkeypatch):
"""Test loading full configuration from environment variables."""
monkeypatch.setenv("QDRANT_URL", "http://qdrant.example.com:6333")
monkeypatch.setenv("QDRANT_API_KEY", "test_api_key")
monkeypatch.setenv("COLLECTION_NAME", "my_memories")
monkeypatch.setenv("QDRANT_SEARCH_LIMIT", "15")
monkeypatch.setenv("QDRANT_READ_ONLY", "1")
settings = QdrantSettings()
assert settings.location == "http://qdrant.example.com:6333"
assert settings.api_key == "test_api_key"
assert settings.collection_name == "my_memories"
assert settings.local_path == "/tmp/qdrant"
assert settings.search_limit == 15
assert settings.read_only is True
def test_local_path_config(self, monkeypatch):
"""Test loading local path configuration from environment variables."""
monkeypatch.setenv("QDRANT_LOCAL_PATH", "/path/to/local/qdrant")
settings = QdrantSettings()
assert settings.local_path == "/path/to/local/qdrant"
def test_local_path_is_exclusive_with_url(self, monkeypatch):
"""Test that local path cannot be set if Qdrant URL is provided."""
monkeypatch.setenv("QDRANT_URL", "http://localhost:6333")
monkeypatch.setenv("QDRANT_LOCAL_PATH", "/path/to/local/qdrant")
with pytest.raises(ValueError):
QdrantSettings()
monkeypatch.delenv("QDRANT_URL", raising=False)
monkeypatch.setenv("QDRANT_API_KEY", "test_api_key")
with pytest.raises(ValueError):
QdrantSettings()
class TestEmbeddingProviderSettings:
@@ -55,12 +71,9 @@ class TestEmbeddingProviderSettings:
assert settings.provider_type == EmbeddingProviderType.FASTEMBED
assert settings.model_name == "sentence-transformers/all-MiniLM-L6-v2"
@patch.dict(
os.environ,
{"EMBEDDING_MODEL": "custom_model"},
)
def test_custom_values(self):
def test_custom_values(self, monkeypatch):
"""Test loading custom values from environment variables."""
monkeypatch.setenv("EMBEDDING_MODEL", "custom_model")
settings = EmbeddingProviderSettings()
assert settings.provider_type == EmbeddingProviderType.FASTEMBED
assert settings.model_name == "custom_model"
@@ -73,35 +86,24 @@ class TestToolSettings:
assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION
assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION
@patch.dict(
os.environ,
{"TOOL_STORE_DESCRIPTION": "Custom store description"},
)
def test_custom_store_description(self):
def test_custom_store_description(self, monkeypatch):
"""Test loading custom store description from environment variable."""
monkeypatch.setenv("TOOL_STORE_DESCRIPTION", "Custom store description")
settings = ToolSettings()
assert settings.tool_store_description == "Custom store description"
assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION
@patch.dict(
os.environ,
{"TOOL_FIND_DESCRIPTION": "Custom find description"},
)
def test_custom_find_description(self):
def test_custom_find_description(self, monkeypatch):
"""Test loading custom find description from environment variable."""
monkeypatch.setenv("TOOL_FIND_DESCRIPTION", "Custom find description")
settings = ToolSettings()
assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION
assert settings.tool_find_description == "Custom find description"
@patch.dict(
os.environ,
{
"TOOL_STORE_DESCRIPTION": "Custom store description",
"TOOL_FIND_DESCRIPTION": "Custom find description",
},
)
def test_all_custom_values(self):
def test_all_custom_values(self, monkeypatch):
"""Test loading all custom values from environment variables."""
monkeypatch.setenv("TOOL_STORE_DESCRIPTION", "Custom store description")
monkeypatch.setenv("TOOL_FIND_DESCRIPTION", "Custom find description")
settings = ToolSettings()
assert settings.tool_store_description == "Custom store description"
assert settings.tool_find_description == "Custom find description"

2588
uv.lock generated

File diff suppressed because it is too large Load Diff