Compare commits
13 Commits
244139beb5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bcc45ee14 | ||
|
|
e13a8981e7 | ||
|
|
e9f0a1fa4a | ||
|
|
e4ec69b2da | ||
|
|
860ab93a96 | ||
|
|
20825bca92 | ||
|
|
8d6f388543 | ||
|
|
59fca57369 | ||
|
|
5a7237389e | ||
|
|
598ed6fa72 | ||
|
|
3fdb4c4b1b | ||
|
|
28bf298a32 | ||
|
|
b657656363 |
6
.github/workflows/pre-commit.yaml
vendored
6
.github/workflows/pre-commit.yaml
vendored
@@ -9,8 +9,8 @@ jobs:
|
|||||||
main:
|
main:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.x
|
||||||
- uses: pre-commit/action@v3.0.1
|
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||||
|
|||||||
4
.github/workflows/pypi-publish.yaml
vendored
4
.github/workflows/pypi-publish.yaml
vendored
@@ -24,10 +24,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@e9aba2c848f5ebd159c070c61ea2c4e2b122355e # v2.3.4
|
||||||
with:
|
with:
|
||||||
python-version: '3.10.x'
|
python-version: '3.10.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/pytest.yaml
vendored
4
.github/workflows/pytest.yaml
vendored
@@ -16,10 +16,10 @@ jobs:
|
|||||||
name: Python ${{ matrix.python-version }}
|
name: Python ${{ matrix.python-version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
|||||||
520
README.md
520
README.md
@@ -1,144 +1,170 @@
|
|||||||
# mcp-server-qdrant: A Qdrant MCP server
|
# mcp-server-qdrant: Hybrid Search Fork
|
||||||
|
|
||||||
[](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
|
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.
|
||||||
> 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.
|
|
||||||
|
|
||||||
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.
|
Everything else remains fully compatible with the upstream.
|
||||||
It acts as a semantic memory layer on top of the Qdrant database.
|
|
||||||
|
|
||||||
## Components
|
---
|
||||||
|
|
||||||
### Tools
|
## What's Different in This Fork
|
||||||
|
|
||||||
1. `qdrant-store`
|
### Hybrid Search (Dense + BM25 Sparse with RRF)
|
||||||
- Store some information in the Qdrant database
|
|
||||||
- Input:
|
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.
|
||||||
- `information` (string): Information to store
|
|
||||||
- `metadata` (JSON): Optional metadata to store
|
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.
|
||||||
- `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.
|
**How it works:**
|
||||||
- Returns: Confirmation message
|
|
||||||
2. `qdrant-find`
|
```
|
||||||
- Retrieve relevant information from the Qdrant database
|
Store: document → [dense embedding] + [BM25 sparse embedding] → Qdrant
|
||||||
- Input:
|
Search: query → prefetch(dense, top-k) + prefetch(BM25, top-k) → RRF fusion → final results
|
||||||
- `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.
|
- Dense vectors capture **semantic meaning** (synonyms, paraphrases, context)
|
||||||
- Returns: Information stored in the Qdrant database as separate messages
|
- 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
|
## Environment Variables
|
||||||
|
|
||||||
The configuration of the server is done using environment variables:
|
| Name | Description | Default |
|
||||||
|
|------|-------------|---------|
|
||||||
| Name | Description | Default Value |
|
|
||||||
|--------------------------|---------------------------------------------------------------------|-------------------------------------------------------------------|
|
|
||||||
| `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. | None |
|
| `QDRANT_LOCAL_PATH` | Path to local Qdrant database (alternative to `QDRANT_URL`) | None |
|
||||||
| `QDRANT_LOCAL_PATH` | Path to the local Qdrant database (alternative to `QDRANT_URL`) | None |
|
| `COLLECTION_NAME` | Default collection name | None |
|
||||||
| `EMBEDDING_PROVIDER` | Embedding provider to use (currently only "fastembed" is supported) | `fastembed` |
|
| `EMBEDDING_PROVIDER` | Embedding provider (currently only `fastembed`) | `fastembed` |
|
||||||
| `EMBEDDING_MODEL` | Name of the embedding model to use | `sentence-transformers/all-MiniLM-L6-v2` |
|
| `EMBEDDING_MODEL` | Embedding model name | `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) |
|
| **`HYBRID_SEARCH`** | **Enable hybrid search (dense + BM25 sparse with RRF)** | **`false`** |
|
||||||
| `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 per search | `10` |
|
||||||
|
| `QDRANT_READ_ONLY` | Disable write operations (store tool) | `false` |
|
||||||
Note: You cannot provide both `QDRANT_URL` and `QDRANT_LOCAL_PATH` at the same time.
|
| `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]
|
> [!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
|
### FastMCP Environment Variables
|
||||||
|
|
||||||
Since `mcp-server-qdrant` is based on FastMCP, it also supports all the FastMCP environment variables. The most
|
Since `mcp-server-qdrant` is based on FastMCP, it also supports all FastMCP environment variables:
|
||||||
important ones are listed below:
|
|
||||||
|
|
||||||
| Environment Variable | Description | Default Value |
|
| Name | Description | Default |
|
||||||
|---------------------------------------|-----------------------------------------------------------|---------------|
|
|------|-------------|---------|
|
||||||
| `FASTMCP_DEBUG` | Enable debug mode | `false` |
|
| `FASTMCP_DEBUG` | Enable debug mode | `false` |
|
||||||
| `FASTMCP_LOG_LEVEL` | Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | `INFO` |
|
| `FASTMCP_LOG_LEVEL` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | `INFO` |
|
||||||
| `FASTMCP_HOST` | Host address to bind the server to | `0.0.0.0` |
|
| `FASTMCP_HOST` | Host address to bind to | `127.0.0.1` |
|
||||||
| `FASTMCP_PORT` | Port to run the server on | `8000` |
|
| `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
|
## Installation
|
||||||
|
|
||||||
### Using uvx
|
### 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
|
```shell
|
||||||
QDRANT_URL="http://localhost:6333" \
|
QDRANT_URL="http://localhost:6333" \
|
||||||
COLLECTION_NAME="my-collection" \
|
COLLECTION_NAME="my-collection" \
|
||||||
EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" \
|
HYBRID_SEARCH=true \
|
||||||
uvx mcp-server-qdrant
|
uvx mcp-server-qdrant
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Transport Protocols
|
#### Transport Protocols
|
||||||
|
|
||||||
The server supports different transport protocols that can be specified using the `--transport` flag:
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
|
# SSE transport (for remote clients)
|
||||||
QDRANT_URL="http://localhost:6333" \
|
QDRANT_URL="http://localhost:6333" \
|
||||||
COLLECTION_NAME="my-collection" \
|
COLLECTION_NAME="my-collection" \
|
||||||
|
HYBRID_SEARCH=true \
|
||||||
uvx mcp-server-qdrant --transport sse
|
uvx mcp-server-qdrant --transport sse
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported transport protocols:
|
Supported transports:
|
||||||
|
- `stdio` (default) — for local MCP clients
|
||||||
- `stdio` (default): Standard input/output transport, might only be used by local MCP clients
|
- `sse` — Server-Sent Events, for remote clients
|
||||||
- `sse`: Server-Sent Events transport, perfect for remote clients
|
- `streamable-http` — streamable HTTP, newer alternative to SSE
|
||||||
- `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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Docker
|
### Using Docker
|
||||||
|
|
||||||
A Dockerfile is available for building and running the MCP server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the container
|
|
||||||
docker build -t mcp-server-qdrant .
|
docker build -t mcp-server-qdrant .
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -p 8000:8000 \
|
docker run -p 8000:8000 \
|
||||||
|
-e FASTMCP_HOST="0.0.0.0" \
|
||||||
-e QDRANT_URL="http://your-qdrant-server:6333" \
|
-e QDRANT_URL="http://your-qdrant-server:6333" \
|
||||||
-e QDRANT_API_KEY="your-api-key" \
|
-e QDRANT_API_KEY="your-api-key" \
|
||||||
-e COLLECTION_NAME="your-collection" \
|
-e COLLECTION_NAME="your-collection" \
|
||||||
|
-e HYBRID_SEARCH=true \
|
||||||
mcp-server-qdrant
|
mcp-server-qdrant
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installing via Smithery
|
### Installing via Smithery
|
||||||
|
|
||||||
To install Qdrant MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/protocol/mcp-server-qdrant):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @smithery/cli install mcp-server-qdrant --client claude
|
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
|
||||||
`claude_desktop_config.json`:
|
|
||||||
|
Add to `claude_desktop_config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -146,171 +172,64 @@ To use this server with the Claude Desktop app, add the following configuration
|
|||||||
"command": "uvx",
|
"command": "uvx",
|
||||||
"args": ["mcp-server-qdrant"],
|
"args": ["mcp-server-qdrant"],
|
||||||
"env": {
|
"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",
|
"QDRANT_API_KEY": "your_api_key",
|
||||||
"COLLECTION_NAME": "your-collection-name",
|
"COLLECTION_NAME": "your-collection",
|
||||||
"EMBEDDING_MODEL": "sentence-transformers/all-MiniLM-L6-v2"
|
"EMBEDDING_MODEL": "sentence-transformers/all-MiniLM-L6-v2",
|
||||||
|
"HYBRID_SEARCH": "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For local Qdrant mode:
|
### Claude Code
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Add mcp-server-qdrant configured for code search
|
claude mcp add qdrant-memory \
|
||||||
claude mcp add code-search \
|
|
||||||
-e QDRANT_URL="http://localhost:6333" \
|
-e QDRANT_URL="http://localhost:6333" \
|
||||||
-e COLLECTION_NAME="code-repository" \
|
-e COLLECTION_NAME="my-memory" \
|
||||||
-e EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" \
|
-e HYBRID_SEARCH="true" \
|
||||||
-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." \
|
|
||||||
-- uvx mcp-server-qdrant
|
-- uvx mcp-server-qdrant
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Verify the server was added:
|
Verify:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
claude mcp list
|
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
|
Run the server with SSE transport and custom tool descriptions for code search:
|
||||||
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:
|
|
||||||
|
|
||||||
1. Use the `qdrant-store` tool to store code snippets with descriptions.
|
```bash
|
||||||
2. Use the `qdrant-find` tool to search for relevant code snippets using natural language.
|
QDRANT_URL="http://localhost:6333" \
|
||||||
|
COLLECTION_NAME="code-snippets" \
|
||||||
### Run MCP server in Development Mode
|
HYBRID_SEARCH=true \
|
||||||
|
TOOL_STORE_DESCRIPTION="Store reusable code snippets for later retrieval. \
|
||||||
The MCP server can be run in development mode using the `mcp dev` command. This will start the server and open the MCP
|
The 'information' parameter should contain a natural language description of what the code does, \
|
||||||
inspector in your browser.
|
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." \
|
||||||
```shell
|
uvx mcp-server-qdrant --transport sse
|
||||||
COLLECTION_NAME=mcp-dev fastmcp dev src/mcp_server_qdrant/server.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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:
|
For one-click installation, click one of the install buttons below:
|
||||||
|
|
||||||
[](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) [](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)
|
[](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) [](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)
|
||||||
|
|
||||||
[](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) [](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)
|
Or add manually to VS Code settings (`Ctrl+Shift+P` → `Preferences: Open User Settings (JSON)`):
|
||||||
|
|
||||||
#### 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)`.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{"type": "promptString", "id": "qdrantUrl", "description": "Qdrant URL"},
|
||||||
"type": "promptString",
|
{"type": "promptString", "id": "qdrantApiKey", "description": "Qdrant API Key", "password": true},
|
||||||
"id": "qdrantUrl",
|
{"type": "promptString", "id": "collectionName", "description": "Collection Name"}
|
||||||
"description": "Qdrant URL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "promptString",
|
|
||||||
"id": "qdrantApiKey",
|
|
||||||
"description": "Qdrant API Key",
|
|
||||||
"password": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "promptString",
|
|
||||||
"id": "collectionName",
|
|
||||||
"description": "Collection Name"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"servers": {
|
"servers": {
|
||||||
"qdrant": {
|
"qdrant": {
|
||||||
@@ -319,7 +238,8 @@ Add the following JSON block to your User Settings (JSON) file in VS Code. You c
|
|||||||
"env": {
|
"env": {
|
||||||
"QDRANT_URL": "${input:qdrantUrl}",
|
"QDRANT_URL": "${input:qdrantUrl}",
|
||||||
"QDRANT_API_KEY": "${input:qdrantApiKey}",
|
"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
|
Run in development mode with the MCP inspector:
|
||||||
{
|
|
||||||
"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.
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
QDRANT_URL=":memory:" COLLECTION_NAME="test" \
|
COLLECTION_NAME=mcp-dev HYBRID_SEARCH=true \
|
||||||
fastmcp dev src/mcp_server_qdrant/server.py
|
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
|
## License
|
||||||
|
|
||||||
This MCP server is licensed under the Apache License 2.0. This means you are free to use, modify, and distribute the
|
Apache License 2.0 — see [LICENSE](LICENSE) for details.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-server-qdrant"
|
name = "mcp-server-qdrant"
|
||||||
version = "0.7.1"
|
version = "0.8.1"
|
||||||
description = "MCP server for retrieving context from a Qdrant vector database"
|
description = "MCP server for retrieving context from a Qdrant vector database"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -8,8 +8,8 @@ license = "Apache-2.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastembed>=0.6.0",
|
"fastembed>=0.6.0",
|
||||||
"qdrant-client>=1.12.0",
|
"qdrant-client>=1.12.0",
|
||||||
"pydantic>=2.10.6",
|
"pydantic>=2.10.6,<2.12.0",
|
||||||
"fastmcp>=2.5.1",
|
"fastmcp==2.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
@@ -18,6 +18,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
dev-dependencies = [
|
dev-dependencies = [
|
||||||
|
"ipdb>=0.13.13",
|
||||||
"isort>=6.0.1",
|
"isort>=6.0.1",
|
||||||
"mypy>=1.9.0",
|
"mypy>=1.9.0",
|
||||||
"pre-commit>=4.1.0",
|
"pre-commit>=4.1.0",
|
||||||
|
|||||||
194
src/mcp_server_qdrant/common/filters.py
Normal file
194
src/mcp_server_qdrant/common/filters.py
Normal 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
|
||||||
150
src/mcp_server_qdrant/common/wrap_filters.py
Normal file
150
src/mcp_server_qdrant/common/wrap_filters.py
Normal 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)
|
||||||
|
)
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
from abc import ABC, abstractmethod
|
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):
|
class EmbeddingProvider(ABC):
|
||||||
"""Abstract base class for embedding providers."""
|
"""Abstract base class for embedding providers."""
|
||||||
|
|
||||||
@abstractmethod
|
@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."""
|
"""Embed a list of documents into vectors."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@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."""
|
"""Embed a query into a vector."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -24,3 +32,15 @@ class EmbeddingProvider(ABC):
|
|||||||
def get_vector_size(self) -> int:
|
def get_vector_size(self) -> int:
|
||||||
"""Get the size of the vector for the Qdrant collection."""
|
"""Get the size of the vector for the Qdrant collection."""
|
||||||
pass
|
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
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
|
|||||||
from mcp_server_qdrant.settings import EmbeddingProviderSettings
|
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.
|
Create an embedding provider based on the specified type.
|
||||||
:param settings: The settings for the embedding provider.
|
: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.
|
:return: An instance of the specified embedding provider.
|
||||||
"""
|
"""
|
||||||
if settings.provider_type == EmbeddingProviderType.FASTEMBED:
|
if settings.provider_type == EmbeddingProviderType.FASTEMBED:
|
||||||
from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider
|
from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider
|
||||||
|
|
||||||
return FastEmbedProvider(settings.model_name)
|
return FastEmbedProvider(settings.model_name, enable_sparse=enable_sparse)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported embedding provider: {settings.provider_type}")
|
raise ValueError(f"Unsupported embedding provider: {settings.provider_type}")
|
||||||
|
|||||||
@@ -1,40 +1,70 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastembed import TextEmbedding
|
from fastembed import SparseTextEmbedding, TextEmbedding
|
||||||
from fastembed.common.model_description import DenseModelDescription
|
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):
|
class FastEmbedProvider(EmbeddingProvider):
|
||||||
"""
|
"""
|
||||||
FastEmbed implementation of the embedding provider.
|
FastEmbed implementation of the embedding provider.
|
||||||
:param model_name: The name of the FastEmbed model to use.
|
: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.model_name = model_name
|
||||||
self.embedding_model = TextEmbedding(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."""
|
"""Embed a list of documents into vectors."""
|
||||||
# Run in a thread pool since FastEmbed is synchronous
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
embeddings = await loop.run_in_executor(
|
embeddings = await loop.run_in_executor(
|
||||||
None, lambda: list(self.embedding_model.passage_embed(documents))
|
None, lambda: list(self.embedding_model.passage_embed(documents))
|
||||||
)
|
)
|
||||||
return [embedding.tolist() for embedding in embeddings]
|
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."""
|
"""Embed a query into a vector."""
|
||||||
# Run in a thread pool since FastEmbed is synchronous
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
embeddings = await loop.run_in_executor(
|
embeddings = await loop.run_in_executor(
|
||||||
None, lambda: list(self.embedding_model.query_embed([query]))
|
None, lambda: list(self.embedding_model.query_embed([query]))
|
||||||
)
|
)
|
||||||
return embeddings[0].tolist()
|
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:
|
def get_vector_name(self) -> str:
|
||||||
"""
|
"""
|
||||||
Return the name of the vector for the Qdrant collection.
|
Return the name of the vector for the Qdrant collection.
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, List, Optional
|
from typing import Annotated, Any, Optional
|
||||||
|
|
||||||
from fastmcp import Context, FastMCP
|
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.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.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 (
|
from mcp_server_qdrant.settings import (
|
||||||
EmbeddingProviderSettings,
|
EmbeddingProviderSettings,
|
||||||
QdrantSettings,
|
QdrantSettings,
|
||||||
@@ -27,22 +32,48 @@ class QdrantMCPServer(FastMCP):
|
|||||||
self,
|
self,
|
||||||
tool_settings: ToolSettings,
|
tool_settings: ToolSettings,
|
||||||
qdrant_settings: QdrantSettings,
|
qdrant_settings: QdrantSettings,
|
||||||
embedding_provider_settings: EmbeddingProviderSettings,
|
embedding_provider_settings: Optional[EmbeddingProviderSettings] = None,
|
||||||
|
embedding_provider: Optional[EmbeddingProvider] = None,
|
||||||
name: str = "mcp-server-qdrant",
|
name: str = "mcp-server-qdrant",
|
||||||
instructions: str | None = None,
|
instructions: str | None = None,
|
||||||
**settings: Any,
|
**settings: Any,
|
||||||
):
|
):
|
||||||
self.tool_settings = tool_settings
|
self.tool_settings = tool_settings
|
||||||
self.qdrant_settings = qdrant_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(
|
self.qdrant_connector = QdrantConnector(
|
||||||
qdrant_settings.location,
|
qdrant_settings.location,
|
||||||
qdrant_settings.api_key,
|
qdrant_settings.api_key,
|
||||||
qdrant_settings.collection_name,
|
qdrant_settings.collection_name,
|
||||||
self.embedding_provider,
|
self.embedding_provider,
|
||||||
qdrant_settings.local_path,
|
qdrant_settings.local_path,
|
||||||
|
make_indexes(qdrant_settings.filterable_fields_dict()),
|
||||||
|
hybrid_search=qdrant_settings.hybrid_search,
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(name=name, instructions=instructions, **settings)
|
super().__init__(name=name, instructions=instructions, **settings)
|
||||||
@@ -63,23 +94,42 @@ class QdrantMCPServer(FastMCP):
|
|||||||
|
|
||||||
async def store(
|
async def store(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
information: str,
|
information: Annotated[str, Field(description="Text to store")],
|
||||||
collection_name: str,
|
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.
|
# 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
|
# If we set it to be optional, some of the MCP clients, like Cursor, cannot
|
||||||
# handle the optional parameter correctly.
|
# 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:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Store some information in Qdrant.
|
Store some information in Qdrant.
|
||||||
:param ctx: The context for the request.
|
:param ctx: The context for the request.
|
||||||
:param information: The information to store.
|
: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 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,
|
:param collection_name: The name of the collection to store the information in, optional. If not provided,
|
||||||
the default collection is used.
|
the default collection is used.
|
||||||
:return: A message indicating that the information was stored.
|
: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)
|
entry = Entry(content=information, metadata=metadata)
|
||||||
|
|
||||||
@@ -90,30 +140,37 @@ class QdrantMCPServer(FastMCP):
|
|||||||
|
|
||||||
async def find(
|
async def find(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
query: str,
|
query: Annotated[str, Field(description="What to search for")],
|
||||||
collection_name: str,
|
collection_name: Annotated[
|
||||||
) -> List[str]:
|
str, Field(description="The collection to search in")
|
||||||
|
],
|
||||||
|
query_filter: ArbitraryFilter | None = None,
|
||||||
|
) -> list[str] | None:
|
||||||
"""
|
"""
|
||||||
Find memories in Qdrant.
|
Find memories in Qdrant.
|
||||||
:param ctx: The context for the request.
|
:param ctx: The context for the request.
|
||||||
:param query: The query to use for the search.
|
:param query: The query to use for the search.
|
||||||
:param collection_name: The name of the collection to search in, optional. If not provided,
|
:param collection_name: The name of the collection to search in, optional. If not provided,
|
||||||
the default collection is used.
|
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}")
|
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(
|
entries = await self.qdrant_connector.search(
|
||||||
query,
|
query,
|
||||||
collection_name=collection_name,
|
collection_name=collection_name,
|
||||||
limit=self.qdrant_settings.search_limit,
|
limit=self.qdrant_settings.search_limit,
|
||||||
|
query_filter=query_filter,
|
||||||
)
|
)
|
||||||
if not entries:
|
if not entries:
|
||||||
return [f"No information found for the query '{query}'"]
|
return None
|
||||||
content = [
|
content = [
|
||||||
f"Results for the query '{query}'",
|
f"Results for the query '{query}'",
|
||||||
]
|
]
|
||||||
@@ -124,6 +181,15 @@ class QdrantMCPServer(FastMCP):
|
|||||||
find_foo = find
|
find_foo = find
|
||||||
store_foo = store
|
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:
|
if self.qdrant_settings.collection_name:
|
||||||
find_foo = make_partial_function(
|
find_foo = make_partial_function(
|
||||||
find_foo, {"collection_name": self.qdrant_settings.collection_name}
|
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}
|
store_foo, {"collection_name": self.qdrant_settings.collection_name}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.add_tool(
|
self.tool(
|
||||||
find_foo,
|
find_foo,
|
||||||
name="qdrant-find",
|
name="qdrant-find",
|
||||||
description=self.tool_settings.tool_find_description,
|
description=self.tool_settings.tool_find_description,
|
||||||
@@ -140,7 +206,7 @@ class QdrantMCPServer(FastMCP):
|
|||||||
|
|
||||||
if not self.qdrant_settings.read_only:
|
if not self.qdrant_settings.read_only:
|
||||||
# Those methods can modify the database
|
# Those methods can modify the database
|
||||||
self.add_tool(
|
self.tool(
|
||||||
store_foo,
|
store_foo,
|
||||||
name="qdrant-store",
|
name="qdrant-store",
|
||||||
description=self.tool_settings.tool_store_description,
|
description=self.tool_settings.tool_store_description,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from qdrant_client import AsyncQdrantClient, models
|
from qdrant_client import AsyncQdrantClient, models
|
||||||
|
|
||||||
from mcp_server_qdrant.embeddings.base import EmbeddingProvider
|
from mcp_server_qdrant.embeddings.base import EmbeddingProvider
|
||||||
|
from mcp_server_qdrant.settings import METADATA_PATH
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Metadata = Dict[str, Any]
|
Metadata = dict[str, Any]
|
||||||
|
ArbitraryFilter = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class Entry(BaseModel):
|
class Entry(BaseModel):
|
||||||
@@ -18,7 +20,10 @@ class Entry(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
content: str
|
content: str
|
||||||
metadata: Optional[Metadata] = None
|
metadata: Metadata | None = None
|
||||||
|
|
||||||
|
|
||||||
|
SPARSE_VECTOR_NAME = "bm25"
|
||||||
|
|
||||||
|
|
||||||
class QdrantConnector:
|
class QdrantConnector:
|
||||||
@@ -30,23 +35,30 @@ class QdrantConnector:
|
|||||||
the collection name to be provided.
|
the collection name to be provided.
|
||||||
:param embedding_provider: The embedding provider to use.
|
: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 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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
qdrant_url: Optional[str],
|
qdrant_url: str | None,
|
||||||
qdrant_api_key: Optional[str],
|
qdrant_api_key: str | None,
|
||||||
collection_name: Optional[str],
|
collection_name: str | None,
|
||||||
embedding_provider: EmbeddingProvider,
|
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_url = qdrant_url.rstrip("/") if qdrant_url else None
|
||||||
self._qdrant_api_key = qdrant_api_key
|
self._qdrant_api_key = qdrant_api_key
|
||||||
self._default_collection_name = collection_name
|
self._default_collection_name = collection_name
|
||||||
self._embedding_provider = embedding_provider
|
self._embedding_provider = embedding_provider
|
||||||
|
self._hybrid_search = hybrid_search and embedding_provider.supports_sparse()
|
||||||
self._client = AsyncQdrantClient(
|
self._client = AsyncQdrantClient(
|
||||||
location=qdrant_url, api_key=qdrant_api_key, path=qdrant_local_path
|
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]:
|
async def get_collection_names(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
@@ -56,7 +68,7 @@ class QdrantConnector:
|
|||||||
response = await self._client.get_collections()
|
response = await self._client.get_collections()
|
||||||
return [collection.name for collection in response.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.
|
Store some information in the Qdrant collection, along with the specified metadata.
|
||||||
:param entry: The entry to store in the Qdrant collection.
|
:param entry: The entry to store in the Qdrant collection.
|
||||||
@@ -68,26 +80,42 @@ 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
|
# Build vector dict
|
||||||
vector_name = self._embedding_provider.get_vector_name()
|
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(
|
await self._client.upsert(
|
||||||
collection_name=collection_name,
|
collection_name=collection_name,
|
||||||
points=[
|
points=[
|
||||||
models.PointStruct(
|
models.PointStruct(
|
||||||
id=uuid.uuid4().hex,
|
id=uuid.uuid4().hex,
|
||||||
vector={vector_name: embeddings[0]},
|
vector=vector_data,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def search(
|
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]:
|
) -> list[Entry]:
|
||||||
"""
|
"""
|
||||||
Find points in the Qdrant collection. If there are no entries found, an empty list is returned.
|
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,
|
:param collection_name: The name of the collection to search in, optional. If not provided,
|
||||||
the default collection is used.
|
the default collection is used.
|
||||||
:param limit: The maximum number of entries to return.
|
: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.
|
:return: A list of entries found.
|
||||||
"""
|
"""
|
||||||
collection_name = collection_name or self._default_collection_name
|
collection_name = collection_name or self._default_collection_name
|
||||||
@@ -102,19 +132,42 @@ class QdrantConnector:
|
|||||||
if not collection_exists:
|
if not collection_exists:
|
||||||
return []
|
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)
|
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
|
# 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(
|
search_results = await self._client.query_points(
|
||||||
collection_name=collection_name,
|
collection_name=collection_name,
|
||||||
query=query_vector,
|
query=query_vector,
|
||||||
using=vector_name,
|
using=vector_name,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
query_filter=query_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -137,6 +190,16 @@ class QdrantConnector:
|
|||||||
|
|
||||||
# Use the vector name as defined in the embedding provider
|
# Use the vector name as defined in the embedding provider
|
||||||
vector_name = self._embedding_provider.get_vector_name()
|
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(
|
await self._client.create_collection(
|
||||||
collection_name=collection_name,
|
collection_name=collection_name,
|
||||||
vectors_config={
|
vectors_config={
|
||||||
@@ -145,4 +208,21 @@ class QdrantConnector:
|
|||||||
distance=models.Distance.COSINE,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 pydantic_settings import BaseSettings
|
||||||
|
|
||||||
from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
|
from mcp_server_qdrant.embeddings.types import EmbeddingProviderType
|
||||||
@@ -15,6 +15,8 @@ DEFAULT_TOOL_FIND_DESCRIPTION = (
|
|||||||
" - Get some personal information about the user"
|
" - Get some personal information about the user"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
METADATA_PATH = "metadata"
|
||||||
|
|
||||||
|
|
||||||
class ToolSettings(BaseSettings):
|
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):
|
class QdrantSettings(BaseSettings):
|
||||||
"""
|
"""
|
||||||
Configuration for the Qdrant connector.
|
Configuration for the Qdrant connector.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
location: Optional[str] = Field(default=None, validation_alias="QDRANT_URL")
|
location: str | None = Field(default=None, validation_alias="QDRANT_URL")
|
||||||
api_key: Optional[str] = Field(default=None, validation_alias="QDRANT_API_KEY")
|
api_key: str | None = Field(default=None, validation_alias="QDRANT_API_KEY")
|
||||||
collection_name: Optional[str] = Field(
|
hybrid_search: bool = Field(default=False, validation_alias="HYBRID_SEARCH")
|
||||||
|
collection_name: str | None = Field(
|
||||||
default=None, validation_alias="COLLECTION_NAME"
|
default=None, validation_alias="COLLECTION_NAME"
|
||||||
)
|
)
|
||||||
local_path: Optional[str] = Field(
|
local_path: str | None = Field(default=None, validation_alias="QDRANT_LOCAL_PATH")
|
||||||
default=None, validation_alias="QDRANT_LOCAL_PATH"
|
|
||||||
)
|
|
||||||
search_limit: int = Field(default=10, validation_alias="QDRANT_SEARCH_LIMIT")
|
search_limit: int = Field(default=10, validation_alias="QDRANT_SEARCH_LIMIT")
|
||||||
read_only: bool = Field(default=False, validation_alias="QDRANT_READ_ONLY")
|
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
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import pytest
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
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 (
|
||||||
@@ -18,34 +17,51 @@ class TestQdrantSettings:
|
|||||||
# Should not raise error because there are no required fields
|
# Should not raise error because there are no required fields
|
||||||
QdrantSettings()
|
QdrantSettings()
|
||||||
|
|
||||||
@patch.dict(
|
def test_minimal_config(self, monkeypatch):
|
||||||
os.environ,
|
|
||||||
{"QDRANT_URL": "http://localhost:6333", "COLLECTION_NAME": "test_collection"},
|
|
||||||
)
|
|
||||||
def test_minimal_config(self):
|
|
||||||
"""Test loading minimal configuration from environment variables."""
|
"""Test loading minimal configuration from environment variables."""
|
||||||
|
monkeypatch.setenv("QDRANT_URL", "http://localhost:6333")
|
||||||
|
monkeypatch.setenv("COLLECTION_NAME", "test_collection")
|
||||||
|
|
||||||
settings = QdrantSettings()
|
settings = QdrantSettings()
|
||||||
assert settings.location == "http://localhost:6333"
|
assert settings.location == "http://localhost:6333"
|
||||||
assert settings.collection_name == "test_collection"
|
assert settings.collection_name == "test_collection"
|
||||||
assert settings.api_key is None
|
assert settings.api_key is None
|
||||||
assert settings.local_path is None
|
assert settings.local_path is None
|
||||||
|
|
||||||
@patch.dict(
|
def test_full_config(self, monkeypatch):
|
||||||
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):
|
|
||||||
"""Test loading full configuration from environment variables."""
|
"""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()
|
settings = QdrantSettings()
|
||||||
assert settings.location == "http://qdrant.example.com:6333"
|
assert settings.location == "http://qdrant.example.com:6333"
|
||||||
assert settings.api_key == "test_api_key"
|
assert settings.api_key == "test_api_key"
|
||||||
assert settings.collection_name == "my_memories"
|
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:
|
class TestEmbeddingProviderSettings:
|
||||||
@@ -55,12 +71,9 @@ class TestEmbeddingProviderSettings:
|
|||||||
assert settings.provider_type == EmbeddingProviderType.FASTEMBED
|
assert settings.provider_type == EmbeddingProviderType.FASTEMBED
|
||||||
assert settings.model_name == "sentence-transformers/all-MiniLM-L6-v2"
|
assert settings.model_name == "sentence-transformers/all-MiniLM-L6-v2"
|
||||||
|
|
||||||
@patch.dict(
|
def test_custom_values(self, monkeypatch):
|
||||||
os.environ,
|
|
||||||
{"EMBEDDING_MODEL": "custom_model"},
|
|
||||||
)
|
|
||||||
def test_custom_values(self):
|
|
||||||
"""Test loading custom values from environment variables."""
|
"""Test loading custom values from environment variables."""
|
||||||
|
monkeypatch.setenv("EMBEDDING_MODEL", "custom_model")
|
||||||
settings = EmbeddingProviderSettings()
|
settings = EmbeddingProviderSettings()
|
||||||
assert settings.provider_type == EmbeddingProviderType.FASTEMBED
|
assert settings.provider_type == EmbeddingProviderType.FASTEMBED
|
||||||
assert settings.model_name == "custom_model"
|
assert settings.model_name == "custom_model"
|
||||||
@@ -73,35 +86,24 @@ class TestToolSettings:
|
|||||||
assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION
|
assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION
|
||||||
assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION
|
assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION
|
||||||
|
|
||||||
@patch.dict(
|
def test_custom_store_description(self, monkeypatch):
|
||||||
os.environ,
|
|
||||||
{"TOOL_STORE_DESCRIPTION": "Custom store description"},
|
|
||||||
)
|
|
||||||
def test_custom_store_description(self):
|
|
||||||
"""Test loading custom store description from environment variable."""
|
"""Test loading custom store description from environment variable."""
|
||||||
|
monkeypatch.setenv("TOOL_STORE_DESCRIPTION", "Custom store description")
|
||||||
settings = ToolSettings()
|
settings = ToolSettings()
|
||||||
assert settings.tool_store_description == "Custom store description"
|
assert settings.tool_store_description == "Custom store description"
|
||||||
assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION
|
assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION
|
||||||
|
|
||||||
@patch.dict(
|
def test_custom_find_description(self, monkeypatch):
|
||||||
os.environ,
|
|
||||||
{"TOOL_FIND_DESCRIPTION": "Custom find description"},
|
|
||||||
)
|
|
||||||
def test_custom_find_description(self):
|
|
||||||
"""Test loading custom find description from environment variable."""
|
"""Test loading custom find description from environment variable."""
|
||||||
|
monkeypatch.setenv("TOOL_FIND_DESCRIPTION", "Custom find description")
|
||||||
settings = ToolSettings()
|
settings = ToolSettings()
|
||||||
assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION
|
assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION
|
||||||
assert settings.tool_find_description == "Custom find description"
|
assert settings.tool_find_description == "Custom find description"
|
||||||
|
|
||||||
@patch.dict(
|
def test_all_custom_values(self, monkeypatch):
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"TOOL_STORE_DESCRIPTION": "Custom store description",
|
|
||||||
"TOOL_FIND_DESCRIPTION": "Custom find description",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def test_all_custom_values(self):
|
|
||||||
"""Test loading all custom values from environment variables."""
|
"""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()
|
settings = ToolSettings()
|
||||||
assert settings.tool_store_description == "Custom store description"
|
assert settings.tool_store_description == "Custom store description"
|
||||||
assert settings.tool_find_description == "Custom find description"
|
assert settings.tool_find_description == "Custom find description"
|
||||||
|
|||||||
Reference in New Issue
Block a user